1
0
mirror of https://github.com/janeczku/calibre-web synced 2024-06-26 15:13:17 +00:00

Merge branch 'master' into master

This commit is contained in:
A. Tammy 2024-01-03 21:01:13 -05:00 committed by GitHub
commit 1f0b569cf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
199 changed files with 39112 additions and 25516 deletions

1
.gitattributes vendored
View File

@ -1,4 +1,5 @@
constants.py ident export-subst constants.py ident export-subst
/test export-ignore /test export-ignore
/library export-ignore
cps/static/css/libs/* linguist-vendored cps/static/css/libs/* linguist-vendored
cps/static/js/libs/* linguist-vendored cps/static/js/libs/* linguist-vendored

2
.gitignore vendored
View File

@ -28,8 +28,10 @@ cps/cache
.idea/ .idea/
*.bak *.bak
*.log.* *.log.*
.key
settings.yaml settings.yaml
gdrive_credentials gdrive_credentials
client_secrets.json client_secrets.json
gmail.json gmail.json
/.key

1
.key
View File

@ -1 +0,0 @@
onLmA_LND5S8jNSvi8nNSGwevE13f7t8pW-wgWAXZgo=

145
README.md
View File

@ -1,99 +1,118 @@
# About # Calibre-Web
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. Calibre-Web is a web app that offers a clean and intuitive 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) [![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)]() ![Commit Activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)
[![GitHub all releases](https://img.shields.io/github/downloads/janeczku/calibre-web/total?logo=github&style=flat-square)](https://github.com/janeczku/calibre-web/releases) [![All Releases](https://img.shields.io/github/downloads/janeczku/calibre-web/total?logo=github&style=flat-square)](https://github.com/janeczku/calibre-web/releases)
[![PyPI](https://img.shields.io/pypi/v/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/) [![PyPI](https://img.shields.io/pypi/v/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
[![Discord](https://img.shields.io/discord/838810113564344381?label=Discord&logo=discord&style=flat-square)](https://discord.gg/h2VsJ2NEfB) [![Discord](https://img.shields.io/discord/838810113564344381?label=Discord&logo=discord&style=flat-square)](https://discord.gg/h2VsJ2NEfB)
<details>
<summary><strong>Table of Contents</strong> (click to expand)</summary>
1. [About](#calibre-web)
2. [Features](#features)
3. [Installation](#installation)
- [Installation via pip (recommended)](#installation-via-pip-recommended)
- [Quick start](#quick-start)
- [Requirements](#requirements)
4. [Docker Images](#docker-images)
5. [Contributor Recognition](#contributor-recognition)
6. [Contact](#contact)
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
</details>
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.* *This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png) ![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png)
## Features ## Features
- Bootstrap 3 HTML5 interface - Modern and responsive Bootstrap 3 HTML5 interface
- full graphical setup - Full graphical setup
- User management with fine-grained per-user permissions - Comprehensive user management with fine-grained per-user permissions
- Admin interface - Admin interface
- User Interface in brazilian, czech, dutch, english, finnish, french, galician, german, greek, hungarian, italian, japanese, khmer, korean, polish, russian, simplified and traditional chinese, spanish, swedish, turkish, ukrainian, vietnamese - Multilingual user interface supporting 20+ languages ([supported languages](https://github.com/janeczku/calibre-web/wiki/Translation-Status))
- OPDS feed for eBook reader apps - OPDS feed for eBook reader apps
- Filter and search by titles, authors, tags, series, book format and language - Advanced search and filtering options
- Create a custom book collection (shelves) - Custom book collection (shelves) creation
- Support for editing eBook metadata and deleting eBooks from Calibre library - eBook metadata editing and deletion support
- Support for downloading eBook metadata from various sources, sources can be extended via external plugins - Metadata download from various sources (extensible via plugins)
- Support for converting eBooks through Calibre binaries - eBook conversion through Calibre binaries
- Restrict eBook download to logged-in users - eBook download restriction to logged-in users
- Support for public user registration - Public user registration support
- Send eBooks to E-Readers with the click of a button - Send eBooks to E-Readers with a single click
- Sync your Kobo devices through Calibre-Web with your Calibre library - Sync Kobo devices with your Calibre library
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz, .djvu) - In-browser eBook reading support for multiple formats
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b) - Upload new books in various formats, including audio formats
- Support for Calibre Custom Columns - Calibre Custom Columns support
- Ability to hide content based on categories and Custom Column content per user - Content hiding based on categories and Custom Column content per user
- Self-update capability - Self-update capability
- "Magic Link" login to make it easy to log on eReaders - "Magic Link" login for easy access on eReaders
- Login via LDAP, google/github oauth and via proxy authentication - LDAP, Google/GitHub OAuth, and proxy authentication support
## Installation ## Installation
#### Installation via pip (recommended) #### Installation via pip (recommended)
1. To avoid problems with already installed python dependencies, it's recommended to create a virtual environment for Calibre-Web 1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
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`). 2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
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-and-Windows) for details 3. Install optional features via pip as needed, see [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
4. Calibre-Web can be started afterwards by typing `cps` 4. Start Calibre-Web by typing `cps`
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). *Note: Raspberry Pi OS users may encounter issues during installation. If so, please update pip (`./venv/bin/python3 -m pip install --upgrade pip`) and/or install cargo (`sudo apt install cargo`) before retrying the installation.*
## Quick start Refer to the Wiki for additional installation examples: [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20), [Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog \ ## Quick Start
Login with default admin login \
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button \
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#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: 1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
*Username:* admin\ 2. Log in with the default admin credentials
*Password:* admin123 3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
6. Configure your Calibre-Web instance via the admin page, referring to the [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) guides
#### Default Admin Login:
- **Username:** admin
- **Password:** admin123
## Requirements ## Requirements
python 3.5+ - Python 3.5+
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-ereader feature, or during editing of ebooks metadata: - Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
[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`.
## Docker Images ## Docker Images
A pre-built Docker image is available in these Docker Hub repository (maintained by the LinuxServer team): Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
#### **LinuxServer - x64, armhf, aarch64** #### **LinuxServer - x64, aarch64**
+ Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web) - [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
+ Github - [https://github.com/linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web) - [GitHub](https://github.com/linuxserver/docker-calibre-web)
+ Github - (Optional Calibre layer) - [https://github.com/linuxserver/docker-calibre-web/tree/calibre](https://github.com/linuxserver/docker-calibre-web/tree/calibre) - [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
This image has the option to pull in an extra docker manifest layer to include the Calibre `ebook-convert` binary. Just include the environmental variable `DOCKER_MODS=linuxserver/calibre-web:calibre` in your docker run/docker compose file. **(x64 only)** Include the environment variable `DOCKER_MODS=linuxserver/mods:universal-calibre` in your Docker run/compose file to add the Calibre `ebook-convert` binary (x64 only). Omit this variable for a lightweight image.
If you do not need this functionality then this can be omitted, keeping the image as lightweight as possible.
Both the Calibre-Web and Calibre-Mod images are rebuilt automatically on new releases of Calibre-Web and Calibre respectively, and on updates to any included base image packages on a weekly basis if required.
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
+ The "path to unrar" should be set to `/usr/bin/unrar`
# Contact Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
Just reach us out on [Discord](https://discord.gg/h2VsJ2NEfB) - Set "path to convertertool" to `/usr/bin/ebook-convert`
- Set "path to unrar" to `/usr/bin/unrar`
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki) ## Contributor Recognition
# Contributing to Calibre-Web We would like to thank all the [contributors](https://github.com/janeczku/calibre-web/graphs/contributors) and maintainers of Calibre-Web for their valuable input and dedication to the project. Your contributions are greatly appreciated.
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) ## Contact
Join us on [Discord](https://discord.gg/h2VsJ2NEfB)
For more information, How To's, and FAQs, please visit the [Wiki](https://github.com/janeczku/calibre-web/wiki)
## Contributing to Calibre-Web
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)

View File

@ -38,6 +38,13 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765| | V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
| 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 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| | 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|
| V 0.6.20 | Credentials for emails are now stored encrypted ||
| V 0.6.20 | Login is rate limited ||
| V 0.6.20 | Passwordstrength can be forced ||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
## Statement regarding Log4j (CVE-2021-44228 and related) ## Statement regarding Log4j (CVE-2021-44228 and related)

View File

@ -2,4 +2,3 @@
# has to be executed with jinja2 >=2.9 to have autoescape enabled automatically # has to be executed with jinja2 >=2.9 to have autoescape enabled automatically
[jinja2: **/templates/**.*ml] [jinja2: **/templates/**.*ml]
extensions=jinja2.ext.with_

2
cps.py
View File

@ -21,7 +21,7 @@ import os
import sys import sys
# Add local path to sys.path so we can import cps # Add local path to sys.path, so we can import cps
path = os.path.dirname(os.path.abspath(__file__)) path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path) sys.path.insert(0, path)

View File

@ -21,15 +21,32 @@
# 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_login import LoginManager from flask_login import LoginManager, confirm_login
from flask import session from flask import session, current_app
from flask_login.utils import decode_cookie
from flask_login.signals import user_loaded_from_cookie
class MyLoginManager(LoginManager): class MyLoginManager(LoginManager):
def _session_protection_failed(self): def _session_protection_failed(self):
_session = session._get_current_object() sess = session._get_current_object()
ident = self._session_identifier_generator() ident = self._session_identifier_generator()
if(_session and not (len(_session) == 1 if(sess and not (len(sess) == 1
and _session.get('csrf_token', None))) and ident != _session.get('_id', None): and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
return super(). _session_protection_failed() return super(). _session_protection_failed()
return False return False
def _load_user_from_remember_cookie(self, cookie):
user_id = decode_cookie(cookie)
if user_id is not None:
session["_user_id"] = user_id
session["_fresh"] = False
user = None
if self._user_callback:
user = self._user_callback(user_id)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
# if session was restored from remember me cookie make login valid
confirm_login()
return user
return None

View File

@ -36,11 +36,16 @@ 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 from .updater import Updater
from .babel import babel from .babel import babel, get_locale
from . import config_sql from . import config_sql
from . import cache_buster from . import cache_buster
from . import ub, db from . import ub, db
try:
from flask_limiter import Limiter
limiter_present = True
except ImportError:
limiter_present = False
try: try:
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
wtf_present = True wtf_present = True
@ -59,7 +64,8 @@ mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
mimetypes.add_type('application/x-cbr', '.cbr') mimetypes.add_type('application/x-cbr', '.cbr')
mimetypes.add_type('application/x-cbz', '.cbz') mimetypes.add_type('application/x-cbz', '.cbz')
mimetypes.add_type('application/x-cbt', '.cbt') mimetypes.add_type('application/x-cbt', '.cbt')
mimetypes.add_type('image/vnd.djvu', '.djvu') mimetypes.add_type('application/x-cb7', '.cb7')
mimetypes.add_type('image/vnd.djv', '.djv')
mimetypes.add_type('application/mpeg', '.mpeg') mimetypes.add_type('application/mpeg', '.mpeg')
mimetypes.add_type('application/mpeg', '.mp3') mimetypes.add_type('application/mpeg', '.mp3')
mimetypes.add_type('application/mp4', '.m4a') mimetypes.add_type('application/mp4', '.m4a')
@ -81,10 +87,10 @@ app.config.update(
lm = MyLoginManager() lm = MyLoginManager()
config = config_sql._ConfigSQL()
cli_param = CliParameter() cli_param = CliParameter()
config = config_sql.ConfigSQL()
if wtf_present: if wtf_present:
csrf = CSRFProtect() csrf = CSRFProtect()
else: else:
@ -96,33 +102,36 @@ web_server = WebServer()
updater_thread = Updater() updater_thread = Updater()
if limiter_present:
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=True)
else:
limiter = None
def create_app(): def create_app():
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
lm.session_protection = 'strong'
if csrf: if csrf:
csrf.init_app(app) csrf.init_app(app)
cli_param.init() cli_param.init()
ub.init_db(cli_param.settings_path, cli_param.user_credentials) ub.init_db(cli_param.settings_path)
# pylint: disable=no-member # pylint: disable=no-member
config_sql.load_configuration(config, ub.session, cli_param) encrypt_key, error = config_sql.get_encryption_key(os.path.dirname(cli_param.settings_path))
db.CalibreDB.update_config(config) config_sql.load_configuration(ub.session, encrypt_key)
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path) config.init_config(ub.session, encrypt_key, cli_param)
calibre_db.init_db()
updater_thread.init_updater(config, web_server) if error:
# Perform dry run of updater and exit afterwards log.error(error)
if cli_param.dry_run:
updater_thread.dry_run()
sys.exit(0)
updater_thread.start()
ub.password_change(cli_param.user_credentials)
if not limiter:
log.info('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
print('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
web_server.stop(True)
sys.exit(8)
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, ' '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
@ -139,8 +148,24 @@ def create_app():
'Please install it using pip: "pip install flask-WTF" ***') 'Please install it using pip: "pip install flask-WTF" ***')
web_server.stop(True) web_server.stop(True)
sys.exit(7) sys.exit(7)
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
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()
for res in dependency_check() + dependency_check(True): for res in dependency_check() + dependency_check(True):
log.info('*** "{}" version does not fit the requirements. ' log.info('*** "{}" version does not meet the requirements. '
'Should: {}, Found: {}, please consider installing required version ***' 'Should: {}, Found: {}, please consider installing required version ***'
.format(res['name'], .format(res['name'],
res['target'], res['target'],
@ -150,14 +175,16 @@ def create_app():
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
cache_buster.init_cache_busting(app) cache_buster.init_cache_busting(app)
log.info('Starting Calibre Web...') log.info('Starting Calibre Web...')
Principal(app) Principal(app)
lm.init_app(app) lm.init_app(app)
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session)) app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
web_server.init_app(app, config) web_server.init_app(app, config)
if hasattr(babel, "localeselector"):
babel.init_app(app) babel.init_app(app)
babel.localeselector(get_locale)
else:
babel.init_app(app, locale_selector=get_locale)
from . import services from . import services
@ -165,9 +192,13 @@ def create_app():
services.ldap.init_app(app, config) services.ldap.init_app(app, config)
if services.goodreads_support: if services.goodreads_support:
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_e,
config.config_use_goodreads) config.config_use_goodreads)
config.store_calibre_uuid(calibre_db, db.Library_Id) config.store_calibre_uuid(calibre_db, db.Library_Id)
# Configure rate limiter
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter)
limiter.init_app(app)
# Register scheduled tasks # Register scheduled tasks
from .schedule import register_scheduled_tasks, register_startup_tasks from .schedule import register_scheduled_tasks, register_startup_tasks
register_scheduled_tasks(config.schedule_reconnect) register_scheduled_tasks(config.schedule_reconnect)

View File

@ -81,4 +81,4 @@ def stats():
categories = calibre_db.session.query(db.Tags).count() categories = calibre_db.session.query(db.Tags).count()
series = calibre_db.session.query(db.Series).count() series = calibre_db.session.query(db.Series).count()
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(), return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(),
categorycounter=categories, seriecounter=series, title=_(u"Statistics"), page="stat") categorycounter=categories, seriecounter=series, title=_("Statistics"), page="stat")

View File

@ -22,7 +22,6 @@
import os import os
import re import re
import base64
import json import json
import operator import operator
import time import time
@ -31,9 +30,11 @@ import string
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import time as datetime_time from datetime import time as datetime_time
from functools import wraps from functools import wraps
from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from flask_login import login_required, current_user, logout_user, confirm_login from markupsafe import Markup
from flask_login import login_required, current_user, logout_user
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from flask import session as flask_session from flask import session as flask_session
@ -101,25 +102,26 @@ def admin_required(f):
@admi.before_app_request @admi.before_app_request
def before_request(): def before_request():
# make remember me function work try:
if current_user.is_authenticated: if not ub.check_user_session(current_user.id,
confirm_login() flask_session.get('_id')) and 'opds' not in request.path \
if not ub.check_user_session(current_user.id, flask_session.get('_id')) and 'opds' not in request.path: and config.config_session == 1:
logout_user() logout_user()
except AttributeError:
pass # ? fails on requesting /ajax/emailstat during restart ?
g.constants = constants g.constants = constants
g.user = current_user g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
g.allow_registration = config.config_public_reg g.allow_registration = config.config_public_reg
g.allow_anonymous = config.config_anonbrowse g.allow_anonymous = config.config_anonbrowse
g.allow_upload = config.config_uploading g.allow_upload = config.config_uploading
g.current_theme = config.config_theme g.current_theme = config.config_theme
g.config_authors_max = config.config_authors_max g.config_authors_max = config.config_authors_max
g.shelves_access = ub.session.query(ub.Shelf).filter(
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
if '/static/' not in request.path and not config.db_configured and \ if '/static/' not in request.path and not config.db_configured and \
request.endpoint not in ('admin.ajax_db_config', request.endpoint not in ('admin.ajax_db_config',
'admin.simulatedbchange', 'admin.simulatedbchange',
'admin.db_configuration', 'admin.db_configuration',
'web.login', 'web.login',
'web.login_post',
'web.logout', 'web.logout',
'admin.load_dialogtexts', 'admin.load_dialogtexts',
'admin.ajax_pathchooser'): 'admin.ajax_pathchooser'):
@ -144,9 +146,9 @@ def shutdown():
ub.dispose() ub.dispose()
if task == 0: if task == 0:
show_text['text'] = _(u'Server restarted, please reload page') show_text['text'] = _('Server restarted, please reload page.')
else: else:
show_text['text'] = _(u'Performing shutdown of server, please close window') show_text['text'] = _('Performing Server shutdown, please close window.')
# stop gevent/tornado server # stop gevent/tornado server
web_server.stop(task == 0) web_server.stop(task == 0)
return json.dumps(show_text) return json.dumps(show_text)
@ -154,10 +156,10 @@ def shutdown():
if task == 2: if task == 2:
log.warning("reconnecting to calibre database") log.warning("reconnecting to calibre database")
calibre_db.reconnect_db(config, ub.app_DB_path) calibre_db.reconnect_db(config, ub.app_DB_path)
show_text['text'] = _(u'Reconnect successful') show_text['text'] = _('Success! Database Reconnected')
return json.dumps(show_text) return json.dumps(show_text)
show_text['text'] = _(u'Unknown command') show_text['text'] = _('Unknown command')
return json.dumps(show_text), 400 return json.dumps(show_text), 400
@ -168,7 +170,7 @@ def queue_metadata_backup():
show_text = {} show_text = {}
log.warning("Queuing all books for metadata backup") log.warning("Queuing all books for metadata backup")
helper.set_all_metadata_dirty() helper.set_all_metadata_dirty()
show_text['text'] = _(u'Books successfully queued for Metadata Backup') show_text['text'] = _('Success! Books queued for Metadata Backup, please check Tasks for result')
return json.dumps(show_text) return json.dumps(show_text)
@ -201,7 +203,7 @@ def update_thumbnails():
def admin(): def admin():
version = updater_thread.get_current_version_info() version = updater_thread.get_current_version_info()
if version is False: if version is False:
commit = _(u'Unknown') commit = _('Unknown')
else: else:
if 'datetime' in version: if 'datetime' in version:
commit = version['datetime'] commit = version['datetime']
@ -218,15 +220,15 @@ def admin():
commit = version['version'] commit = version['version']
all_user = ub.session.query(ub.User).all() all_user = ub.session.query(ub.User).all()
email_settings = config.get_mail_settings() # email_settings = mail_config.get_mail_settings()
schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short") schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short")
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60) t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
schedule_duration = format_timedelta(t, threshold=.99) schedule_duration = format_timedelta(t, threshold=.99)
return render_title_template("admin.html", allUser=all_user, email=email_settings, config=config, commit=commit, return render_title_template("admin.html", allUser=all_user, config=config, commit=commit,
feature_support=feature_support, schedule_time=schedule_time, feature_support=feature_support, schedule_time=schedule_time,
schedule_duration=schedule_duration, schedule_duration=schedule_duration,
title=_(u"Admin page"), page="admin") title=_("Admin page"), page="admin")
@admi.route("/admin/dbconfig", methods=["GET", "POST"]) @admi.route("/admin/dbconfig", methods=["GET", "POST"])
@ -246,7 +248,7 @@ def configuration():
config=config, config=config,
provider=oauthblueprints, provider=oauthblueprints,
feature_support=feature_support, feature_support=feature_support,
title=_(u"Basic Configuration"), page="config") title=_("Basic Configuration"), page="config")
@admi.route("/admin/ajaxconfig", methods=["POST"]) @admi.route("/admin/ajaxconfig", methods=["POST"])
@ -284,7 +286,7 @@ def view_configuration():
restrictColumns=restrict_columns, restrictColumns=restrict_columns,
languages=languages, languages=languages,
translations=translations, translations=translations,
title=_(u"UI Configuration"), page="uiconfig") title=_("UI Configuration"), page="uiconfig")
@admi.route("/admin/usertable") @admi.route("/admin/usertable")
@ -318,7 +320,7 @@ def edit_user_table():
all_roles=constants.ALL_ROLES, all_roles=constants.ALL_ROLES,
kobo_support=kobo_support, kobo_support=kobo_support,
sidebar_settings=constants.sidebar_settings, sidebar_settings=constants.sidebar_settings,
title=_(u"Edit Users"), title=_("Edit Users"),
page="usertable") page="usertable")
@ -489,7 +491,7 @@ def edit_list_user(param):
ub.User.id != user.id).count(): ub.User.id != user.id).count():
return Response( return Response(
json.dumps([{'type': "danger", json.dumps([{'type': "danger",
'message': _(u"No admin user remaining, can't remove admin role", 'message': _("No admin user remaining, can't remove admin role",
nick=user.name)}]), mimetype='application/json') nick=user.name)}]), mimetype='application/json')
user.role &= ~value user.role &= ~value
else: else:
@ -566,13 +568,13 @@ def update_view_configuration():
calibre_db.update_title_sort(config) calibre_db.update_title_sort(config)
if not check_valid_read_column(to_save.get("config_read_column", "0")): if not check_valid_read_column(to_save.get("config_read_column", "0")):
flash(_(u"Invalid Read Column"), category="error") flash(_("Invalid Read Column"), category="error")
log.debug("Invalid Read column") log.debug("Invalid Read column")
return view_configuration() return view_configuration()
_config_int(to_save, "config_read_column") _config_int(to_save, "config_read_column")
if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")): if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
flash(_(u"Invalid Restricted Column"), category="error") flash(_("Invalid Restricted Column"), category="error")
log.debug("Invalid Restricted Column") log.debug("Invalid Restricted Column")
return view_configuration() return view_configuration()
_config_int(to_save, "config_restricted_column") _config_int(to_save, "config_restricted_column")
@ -592,7 +594,7 @@ def update_view_configuration():
config.config_default_show |= constants.DETAIL_RANDOM config.config_default_show |= constants.DETAIL_RANDOM
config.save() config.save()
flash(_(u"Calibre-Web configuration updated"), category="success") flash(_("Calibre-Web configuration updated"), category="success")
log.debug("Calibre-Web configuration updated") log.debug("Calibre-Web configuration updated")
before_request() before_request()
@ -1037,7 +1039,8 @@ def pathchooser():
for f in folders: for f in folders:
try: try:
data = {"name": f, "fullpath": os.path.join(cwd, f)} sanitized_f = str(Markup.escape(f))
data = {"name": sanitized_f, "fullpath": os.path.join(cwd, sanitized_f)}
data["sort"] = data["fullpath"].lower() data["sort"] = data["fullpath"].lower()
except Exception: except Exception:
continue continue
@ -1088,7 +1091,7 @@ def _config_checkbox_int(to_save, x):
def _config_string(to_save, x): def _config_string(to_save, x):
return config.set_from_dictionary(to_save, x, lambda y: y.strip() if y else y) return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
def _configuration_gdrive_helper(to_save): def _configuration_gdrive_helper(to_save):
@ -1162,7 +1165,6 @@ def _configuration_logfile_helper(to_save):
def _configuration_ldap_helper(to_save): def _configuration_ldap_helper(to_save):
reboot_required = False reboot_required = False
reboot_required |= _config_string(to_save, "config_ldap_provider_url")
reboot_required |= _config_int(to_save, "config_ldap_port") reboot_required |= _config_int(to_save, "config_ldap_port")
reboot_required |= _config_int(to_save, "config_ldap_authentication") reboot_required |= _config_int(to_save, "config_ldap_authentication")
reboot_required |= _config_string(to_save, "config_ldap_dn") reboot_required |= _config_string(to_save, "config_ldap_dn")
@ -1178,9 +1180,14 @@ def _configuration_ldap_helper(to_save):
reboot_required |= _config_string(to_save, "config_ldap_key_path") reboot_required |= _config_string(to_save, "config_ldap_key_path")
_config_string(to_save, "config_ldap_group_name") _config_string(to_save, "config_ldap_group_name")
_config_checkbox(to_save, "config_ldap_autocreate_user") _config_checkbox(to_save, "config_ldap_autocreate_user")
if to_save.get("config_ldap_serv_password", "") != "":
address = urlparse(to_save.get("config_ldap_provider_url", ""))
to_save["config_ldap_provider_url"] = (address.hostname or address.path).strip("/")
reboot_required |= _config_string(to_save, "config_ldap_provider_url")
if to_save.get("config_ldap_serv_password_e", "") != "":
reboot_required |= 1 reboot_required |= 1
config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') config.set_from_dictionary(to_save, "config_ldap_serv_password_e")
config.save() config.save()
if not config.config_ldap_provider_url \ if not config.config_ldap_provider_url \
@ -1192,7 +1199,7 @@ def _configuration_ldap_helper(to_save):
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS: if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE: if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password): if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password_e):
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password')) return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
else: else:
if not config.config_ldap_serv_username: if not config.config_ldap_serv_username:
@ -1256,16 +1263,16 @@ def new_user():
content.default_language = config.config_default_language content.default_language = config.config_default_language
return render_title_template("user_edit.html", new_user=1, content=content, return render_title_template("user_edit.html", new_user=1, content=content,
config=config, translations=translations, config=config, translations=translations,
languages=languages, title=_(u"Add new user"), page="newuser", languages=languages, title=_("Add New User"), page="newuser",
kobo_support=kobo_support, registered_oauth=oauth_check) kobo_support=kobo_support, registered_oauth=oauth_check)
@admi.route("/admin/mailsettings") @admi.route("/admin/mailsettings", methods=["GET"])
@login_required @login_required
@admin_required @admin_required
def edit_mailsettings(): def edit_mailsettings():
content = config.get_mail_settings() content = config.get_mail_settings()
return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"), return render_title_template("email_edit.html", content=content, title=_("Edit Email Server Settings"),
page="mailset", feature_support=feature_support) page="mailset", feature_support=feature_support)
@ -1284,7 +1291,7 @@ def update_mailsettings():
elif to_save.get("gmail"): elif to_save.get("gmail"):
try: try:
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token) config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
flash(_(u"Gmail Account Verification Successful"), category="success") flash(_("Success! Gmail Account Verified."), category="success")
except Exception as ex: except Exception as ex:
flash(str(ex), category="error") flash(str(ex), category="error")
log.error(ex) log.error(ex)
@ -1293,7 +1300,8 @@ def update_mailsettings():
else: else:
_config_int(to_save, "mail_port") _config_int(to_save, "mail_port")
_config_int(to_save, "mail_use_ssl") _config_int(to_save, "mail_use_ssl")
_config_string(to_save, "mail_password") if to_save.get("mail_password_e", ""):
_config_string(to_save, "mail_password_e")
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024) _config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
config.mail_server = to_save.get('mail_server', "").strip() config.mail_server = to_save.get('mail_server', "").strip()
config.mail_from = to_save.get('mail_from', "").strip() config.mail_from = to_save.get('mail_from', "").strip()
@ -1303,24 +1311,24 @@ def update_mailsettings():
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
return edit_mailsettings() return edit_mailsettings()
except Exception as e: except Exception as e:
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
return edit_mailsettings() return edit_mailsettings()
if to_save.get("test"): if to_save.get("test"):
if current_user.email: if current_user.email:
result = send_test_mail(current_user.email, current_user.name) result = send_test_mail(current_user.email, current_user.name)
if result is None: if result is None:
flash(_(u"Test e-mail queued for sending to %(email)s, please check Tasks for result", flash(_("Test e-mail queued for sending to %(email)s, please check Tasks for result",
email=current_user.email), category="info") email=current_user.email), category="info")
else: else:
flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error") flash(_("There was an error sending the Test e-mail: %(res)s", res=result), category="error")
else: else:
flash(_(u"Please configure your e-mail address first..."), category="error") flash(_("Please configure your e-mail address first..."), category="error")
else: else:
flash(_(u"E-mail server settings updated"), category="success") flash(_("Email Server Settings updated"), category="success")
return edit_mailsettings() return edit_mailsettings()
@ -1343,7 +1351,7 @@ def edit_scheduledtasks():
config=content, config=content,
starttime=time_field, starttime=time_field,
duration=duration_field, duration=duration_field,
title=_(u"Edit Scheduled Tasks Settings")) title=_("Edit Scheduled Tasks Settings"))
@admi.route("/admin/scheduledtasks", methods=["POST"]) @admi.route("/admin/scheduledtasks", methods=["POST"])
@ -1353,23 +1361,24 @@ def update_scheduledtasks():
error = False error = False
to_save = request.form.to_dict() to_save = request.form.to_dict()
if 0 <= int(to_save.get("schedule_start_time")) <= 23: if 0 <= int(to_save.get("schedule_start_time")) <= 23:
_config_int(to_save, "schedule_start_time") _config_int( to_save, "schedule_start_time")
else: else:
flash(_(u"Invalid start time for task specified"), category="error") flash(_("Invalid start time for task specified"), category="error")
error = True error = True
if 0 < int(to_save.get("schedule_duration")) <= 60: if 0 < int(to_save.get("schedule_duration")) <= 60:
_config_int(to_save, "schedule_duration") _config_int(to_save, "schedule_duration")
else: else:
flash(_(u"Invalid duration for task specified"), category="error") flash(_("Invalid duration for task specified"), category="error")
error = True error = True
_config_checkbox(to_save, "schedule_generate_book_covers") _config_checkbox(to_save, "schedule_generate_book_covers")
_config_checkbox(to_save, "schedule_generate_series_covers") _config_checkbox(to_save, "schedule_generate_series_covers")
_config_checkbox(to_save, "schedule_metadata_backup")
_config_checkbox(to_save, "schedule_reconnect") _config_checkbox(to_save, "schedule_reconnect")
if not error: if not error:
try: try:
config.save() config.save()
flash(_(u"Scheduled tasks settings updated"), category="success") flash(_("Scheduled tasks settings updated"), category="success")
# Cancel any running tasks # Cancel any running tasks
schedule.end_scheduled_tasks() schedule.end_scheduled_tasks()
@ -1379,7 +1388,7 @@ def update_scheduledtasks():
except IntegrityError: except IntegrityError:
ub.session.rollback() ub.session.rollback()
log.error("An unknown error occurred while saving scheduled tasks settings") log.error("An unknown error occurred while saving scheduled tasks settings")
flash(_(u"An unknown error occurred. Please try again later."), category="error") flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
except OperationalError: except OperationalError:
ub.session.rollback() ub.session.rollback()
log.error("Settings DB is not Writeable") log.error("Settings DB is not Writeable")
@ -1394,7 +1403,7 @@ def update_scheduledtasks():
def edit_user(user_id): def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
if not content or (not config.config_anonbrowse and content.name == "Guest"): if not content or (not config.config_anonbrowse and content.name == "Guest"):
flash(_(u"User not found"), category="error") flash(_("User not found"), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
languages = calibre_db.speaking_language(return_all_languages=True) languages = calibre_db.speaking_language(return_all_languages=True)
translations = get_available_locale() translations = get_available_locale()
@ -1413,7 +1422,7 @@ def edit_user(user_id):
registered_oauth=oauth_check, registered_oauth=oauth_check,
mail_configured=config.get_mail_server_configured(), mail_configured=config.get_mail_server_configured(),
kobo_support=kobo_support, kobo_support=kobo_support,
title=_(u"Edit User %(nick)s", nick=content.name), title=_("Edit User %(nick)s", nick=content.name),
page="edituser") page="edituser")
@ -1424,14 +1433,14 @@ def reset_user_password(user_id):
if current_user is not None and current_user.is_authenticated: if current_user is not None and current_user.is_authenticated:
ret, message = reset_password(user_id) ret, message = reset_password(user_id)
if ret == 1: if ret == 1:
log.debug(u"Password for user %s reset", message) log.debug("Password for user %s reset", message)
flash(_(u"Password for user %(user)s reset", user=message), category="success") flash(_("Success! Password for user %(user)s reset", user=message), category="success")
elif ret == 0: elif ret == 0:
log.error(u"An unknown error occurred. Please try again later.") log.error("An unknown error occurred. Please try again later.")
flash(_(u"An unknown error occurred. Please try again later."), category="error") flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
else: else:
log.error(u"Please configure the SMTP mail settings first...") log.error("Please configure the SMTP mail settings.")
flash(_(u"Please configure the SMTP mail settings first..."), category="error") flash(_("Oops! Please configure the SMTP mail settings."), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
@ -1442,7 +1451,7 @@ def view_logfile():
logfiles = {0: logger.get_logfile(config.config_logfile), logfiles = {0: logger.get_logfile(config.config_logfile),
1: logger.get_accesslogfile(config.config_access_logfile)} 1: logger.get_accesslogfile(config.config_access_logfile)}
return render_title_template("logviewer.html", return render_title_template("logviewer.html",
title=_(u"Logfile viewer"), title=_("Logfile viewer"),
accesslog_enable=config.config_access_log, accesslog_enable=config.config_access_log,
log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT), log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
logfiles=logfiles, logfiles=logfiles,
@ -1492,7 +1501,7 @@ def download_debug():
@admin_required @admin_required
def get_update_status(): def get_update_status():
if feature_support['updater']: if feature_support['updater']:
log.info(u"Update status requested") log.info("Update status requested")
return updater_thread.get_available_updates(request.method) return updater_thread.get_available_updates(request.method)
else: else:
return '' return ''
@ -1687,7 +1696,7 @@ def _db_configuration_update_helper():
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
_db_configuration_result(_(u"Database error: %(error)s.", error=e.orig), gdrive_error) _db_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig), gdrive_error)
try: try:
metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db") metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db")
if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db): if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
@ -1719,7 +1728,7 @@ def _db_configuration_update_helper():
_config_string(to_save, "config_calibre_dir") _config_string(to_save, "config_calibre_dir")
calibre_db.update_config(config) calibre_db.update_config(config)
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK): if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
flash(_(u"DB is not Writeable"), category="warning") flash(_("DB is not Writeable"), category="warning")
config.save() config.save()
return _db_configuration_result(None, gdrive_error) return _db_configuration_result(None, gdrive_error)
@ -1776,10 +1785,11 @@ def _configuration_update_helper():
# Goodreads configuration # Goodreads configuration
_config_checkbox(to_save, "config_use_goodreads") _config_checkbox(to_save, "config_use_goodreads")
_config_string(to_save, "config_goodreads_api_key") _config_string(to_save, "config_goodreads_api_key")
_config_string(to_save, "config_goodreads_api_secret") if to_save.get("config_goodreads_api_secret_e", ""):
_config_string(to_save, "config_goodreads_api_secret_e")
if services.goodreads_support: if services.goodreads_support:
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_e,
config.config_use_goodreads) config.config_use_goodreads)
_config_int(to_save, "config_updatechannel") _config_int(to_save, "config_updatechannel")
@ -1792,10 +1802,25 @@ def _configuration_update_helper():
if config.config_login_type == constants.LOGIN_OAUTH: if config.config_login_type == constants.LOGIN_OAUTH:
reboot_required |= _configuration_oauth_helper(to_save) reboot_required |= _configuration_oauth_helper(to_save)
# logfile configuration
reboot, message = _configuration_logfile_helper(to_save) reboot, message = _configuration_logfile_helper(to_save)
if message: if message:
return message return message
reboot_required |= reboot reboot_required |= reboot
# security configuration
_config_checkbox(to_save, "config_password_policy")
_config_checkbox(to_save, "config_password_number")
_config_checkbox(to_save, "config_password_lower")
_config_checkbox(to_save, "config_password_upper")
_config_checkbox(to_save, "config_password_special")
if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
_config_int(to_save, "config_password_min_length")
else:
return _configuration_result(_('Password length has to be between 1 and 40'))
reboot_required |= _config_int(to_save, "config_session")
reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
# Rarfile Content configuration # Rarfile Content configuration
_config_string(to_save, "config_rarfile_location") _config_string(to_save, "config_rarfile_location")
if "config_rarfile_location" in to_save: if "config_rarfile_location" in to_save:
@ -1805,7 +1830,7 @@ def _configuration_update_helper():
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
_configuration_result(_(u"Database error: %(error)s.", error=e.orig)) _configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig))
config.save() config.save()
if reboot_required: if reboot_required:
@ -1821,7 +1846,7 @@ def _configuration_result(error_flash=None, reboot=False):
config.load() config.load()
resp['result'] = [{'type': "danger", 'message': error_flash}] resp['result'] = [{'type': "danger", 'message': error_flash}]
else: else:
resp['result'] = [{'type': "success", 'message': _(u"Calibre-Web configuration updated")}] resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
resp['reboot'] = reboot resp['reboot'] = reboot
resp['config_upload'] = config.config_upload_formats resp['config_upload'] = config.config_upload_formats
return Response(json.dumps(resp), mimetype='application/json') return Response(json.dumps(resp), mimetype='application/json')
@ -1852,7 +1877,7 @@ def _db_configuration_result(error_flash=None, gdrive_error=None):
gdriveError=gdrive_error, gdriveError=gdrive_error,
gdrivefolders=gdrivefolders, gdrivefolders=gdrivefolders,
feature_support=feature_support, feature_support=feature_support,
title=_(u"Database Configuration"), page="dbconfig") title=_("Database Configuration"), page="dbconfig")
def _handle_new_user(to_save, content, languages, translations, kobo_support): def _handle_new_user(to_save, content, languages, translations, kobo_support):
@ -1864,11 +1889,11 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.sidebar_view |= constants.DETAIL_RANDOM content.sidebar_view |= constants.DETAIL_RANDOM
content.role = constants.selected_roles(to_save) content.role = constants.selected_roles(to_save)
content.password = generate_password_hash(to_save["password"])
try: try:
if not to_save["name"] or not to_save["email"] or not to_save["password"]: if not to_save["name"] or not to_save["email"] or not to_save["password"]:
log.info("Missing entries on new user") log.info("Missing entries on new user")
raise Exception(_(u"Please fill out all fields!")) raise Exception(_("Oops! Please complete all fields."))
content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
content.email = check_email(to_save["email"]) content.email = check_email(to_save["email"])
# Query username, if not existing, change # Query username, if not existing, change
content.name = check_username(to_save["name"]) content.name = check_username(to_save["name"])
@ -1876,13 +1901,13 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.kindle_mail = valid_email(to_save["kindle_mail"]) content.kindle_mail = valid_email(to_save["kindle_mail"])
if config.config_public_reg and not check_valid_domain(content.email): if config.config_public_reg and not check_valid_domain(content.email):
log.info("E-mail: {} for new user is not from valid domain".format(content.email)) log.info("E-mail: {} for new user is not from valid domain".format(content.email))
raise Exception(_(u"E-mail is not from valid domain")) raise Exception(_("E-mail is not from valid domain"))
except Exception as ex: except Exception as ex:
flash(str(ex), category="error") flash(str(ex), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, return render_title_template("user_edit.html", new_user=1, content=content,
config=config, config=config,
translations=translations, translations=translations,
languages=languages, title=_(u"Add new user"), page="newuser", languages=languages, title=_("Add new user"), page="newuser",
kobo_support=kobo_support, registered_oauth=oauth_check) kobo_support=kobo_support, registered_oauth=oauth_check)
try: try:
content.allowed_tags = config.config_allowed_tags content.allowed_tags = config.config_allowed_tags
@ -1893,17 +1918,17 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on" content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on"
ub.session.add(content) ub.session.add(content)
ub.session.commit() ub.session.commit()
flash(_(u"User '%(user)s' created", user=content.name), category="success") flash(_("User '%(user)s' created", user=content.name), category="success")
log.debug("User {} created".format(content.name)) log.debug("User {} created".format(content.name))
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
except IntegrityError: except IntegrityError:
ub.session.rollback() ub.session.rollback()
log.error("Found an existing account for {} or {}".format(content.name, content.email)) log.error("Found an existing account for {} or {}".format(content.name, content.email))
flash(_("Found an existing account for this e-mail address or name."), category="error") flash(_("Oops! An account already exists for this Email. or name."), category="error")
except OperationalError as e: except OperationalError as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
def _delete_user(content): def _delete_user(content):
@ -1931,10 +1956,10 @@ def _delete_user(content):
log.info("User {} deleted".format(content.name)) log.info("User {} deleted".format(content.name))
return _("User '%(nick)s' deleted", nick=content.name) return _("User '%(nick)s' deleted", nick=content.name)
else: else:
log.warning(_("Can't delete Guest User")) # log.warning(_("Can't delete Guest User"))
raise Exception(_("Can't delete Guest User")) raise Exception(_("Can't delete Guest User"))
else: else:
log.warning("No admin user remaining, can't delete user") # log.warning("No admin user remaining, can't delete user")
raise Exception(_("No admin user remaining, can't delete user")) raise Exception(_("No admin user remaining, can't delete user"))
@ -1952,14 +1977,6 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
log.warning("No admin user remaining, can't remove admin role from {}".format(content.name)) log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
flash(_("No admin user remaining, can't remove admin role"), category="error") flash(_("No admin user remaining, can't remove admin role"), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
if to_save.get("password"):
content.password = generate_password_hash(to_save["password"])
anonymous = content.is_anonymous
content.role = constants.selected_roles(to_save)
if anonymous:
content.role |= constants.ROLE_ANONYMOUS
else:
content.role &= ~constants.ROLE_ANONYMOUS
val = [int(k[5:]) for k in to_save if k.startswith('show_')] val = [int(k[5:]) for k in to_save if k.startswith('show_')]
sidebar, __ = get_sidebar_config() sidebar, __ = get_sidebar_config()
@ -1987,9 +2004,18 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
if to_save.get("locale"): if to_save.get("locale"):
content.locale = to_save["locale"] content.locale = to_save["locale"]
try: try:
anonymous = content.is_anonymous
content.role = constants.selected_roles(to_save)
if anonymous:
content.role |= constants.ROLE_ANONYMOUS
else:
content.role &= ~constants.ROLE_ANONYMOUS
if to_save.get("password", ""):
content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
new_email = valid_email(to_save.get("email", content.email)) new_email = valid_email(to_save.get("email", content.email))
if not new_email: if not new_email:
raise Exception(_(u"E-Mail Address can't be empty and has to be a valid E-Mail")) raise Exception(_("Email can't be empty and has to be a valid Email"))
if new_email != content.email: if new_email != content.email:
content.email = check_email(new_email) content.email = check_email(new_email)
# Query username, if not existing, change # Query username, if not existing, change
@ -2011,19 +2037,19 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
content=content, content=content,
config=config, config=config,
registered_oauth=oauth_check, registered_oauth=oauth_check,
title=_(u"Edit User %(nick)s", nick=content.name), title=_("Edit User %(nick)s", nick=content.name),
page="edituser") page="edituser")
try: try:
ub.session_commit() ub.session_commit()
flash(_(u"User '%(nick)s' updated", nick=content.name), category="success") flash(_("User '%(nick)s' updated", nick=content.name), category="success")
except IntegrityError as ex: except IntegrityError as ex:
ub.session.rollback() ub.session.rollback()
log.error("An unknown error occurred while changing user: {}".format(str(ex))) log.error("An unknown error occurred while changing user: {}".format(str(ex)))
flash(_(u"An unknown error occurred. Please try again later."), category="error") flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
except OperationalError as e: except OperationalError as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
return "" return ""

View File

@ -1,7 +1,8 @@
from babel import negotiate_locale from babel import negotiate_locale
from flask_babel import Babel, Locale from flask_babel import Babel, Locale
from babel.core import UnknownLocaleError from babel.core import UnknownLocaleError
from flask import request, g from flask import request
from flask_login import current_user
from . import logger from . import logger
@ -9,14 +10,12 @@ log = logger.create()
babel = Babel() babel = Babel()
@babel.localeselector
def get_locale(): def get_locale():
# if a user is logged in, use the locale from the user settings # if a user is logged in, use the locale from the user settings
user = getattr(g, 'user', None) if current_user is not None and hasattr(current_user, "locale"):
if user is not None and hasattr(user, "locale"): # if the account is the guest account bypass the config lang settings
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings if current_user.name != 'Guest':
return user.locale return current_user.locale
preferred = list() preferred = list()
if request.accept_languages: if request.accept_languages:

View File

@ -48,6 +48,7 @@ class CliParameter(object):
'works only in combination with keyfile') 'works only in combination with keyfile')
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, ' parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
'works only in combination with certfile') 'works only in combination with certfile')
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web', parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
version=version_info()) version=version_info())
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen') parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
@ -60,6 +61,7 @@ class CliParameter(object):
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect') parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
args = parser.parse_args() args = parser.parse_args()
self.logpath = args.o or ""
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE) self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE) self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)

View File

@ -36,6 +36,12 @@ try:
from comicapi import __version__ as comic_version from comicapi import __version__ as comic_version
except ImportError: except ImportError:
comic_version = '' comic_version = ''
try:
from comicapi.comicarchive import load_archive_plugins
import comicapi.utils
comicapi.utils.add_rar_paths()
except ImportError:
load_archive_plugins = None
except (ImportError, LookupError) as e: except (ImportError, LookupError) as e:
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e) log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
import zipfile import zipfile
@ -46,6 +52,12 @@ except (ImportError, LookupError) as e:
except (ImportError, SyntaxError) as e: except (ImportError, SyntaxError) as e:
log.debug('Cannot import rarfile, extracting cover files from rar files will not work: %s', e) log.debug('Cannot import rarfile, extracting cover files from rar files will not work: %s', e)
use_rarfile = False use_rarfile = False
try:
import py7zr
use_7zip = True
except (ImportError, SyntaxError) as e:
log.debug('Cannot import py7zr, extracting cover files from CB7 files will not work: %s', e)
use_7zip = False
use_comic_meta = False use_comic_meta = False
@ -78,23 +90,40 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
if len(ext) > 1: if len(ext) > 1:
extension = ext[1].lower() extension = ext[1].lower()
if extension in cover.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: {}'.format(ex)) log.error('Rarfile failed with error: {}'.format(ex))
elif original_file_extension.upper() == '.CB7' and use_7zip:
cf = py7zr.SevenZipFile(tmp_file_name)
for name in cf.getnames():
ext = os.path.splitext(name)
if len(ext) > 1:
extension = ext[1].lower()
if extension in cover.COVER_EXTENSIONS:
try:
cover_data = cf.read(name)[name].read()
except (py7zr.Bad7zFile, OSError) as ex:
log.error('7Zip file failed with error: {}'.format(ex))
break
return cover_data, extension return cover_data, extension
def _extract_cover(tmp_file_name, original_file_extension, rar_executable): 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=rar_executable) try:
for index, name in enumerate(archive.getPageNameList()): archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
except TypeError:
archive = ComicArchive(tmp_file_name)
name_list = archive.getPageNameList if hasattr(archive, "getPageNameList") else archive.get_page_name_list
for index, name in enumerate(name_list()):
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.COVER_EXTENSIONS: if extension in cover.COVER_EXTENSIONS:
cover_data = archive.getPage(index) get_page = archive.getPage if hasattr(archive, "getPageNameList") else archive.get_page
cover_data = get_page(index)
break break
else: else:
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable) cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
@ -103,17 +132,26 @@ def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable): 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=rar_executable) try:
if archive.seemsToBeAComicArchive(): archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
if archive.hasMetadata(MetaDataStyle.CIX): except TypeError:
load_archive_plugins(force=True, rar=rar_executable)
archive = ComicArchive(tmp_file_path)
if hasattr(archive, "seemsToBeAComicArchive"):
seems_archive = archive.seemsToBeAComicArchive
else:
seems_archive = archive.seems_to_be_a_comic_archive
if seems_archive():
has_metadata = archive.hasMetadata if hasattr(archive, "hasMetadata") else archive.has_metadata
if has_metadata(MetaDataStyle.CIX):
style = MetaDataStyle.CIX style = MetaDataStyle.CIX
elif archive.hasMetadata(MetaDataStyle.CBI): elif has_metadata(MetaDataStyle.CBI):
style = MetaDataStyle.CBI style = MetaDataStyle.CBI
else: else:
style = None style = None
# if style is not None: read_metadata = archive.readMetadata if hasattr(archive, "readMetadata") else archive.read_metadata
loaded_metadata = archive.readMetadata(style) loaded_metadata = read_metadata(style)
lang = loaded_metadata.language or "" lang = loaded_metadata.language or ""
loaded_metadata.language = isoLanguages.get_lang3(lang) loaded_metadata.language = isoLanguages.get_lang3(lang)
@ -138,7 +176,7 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
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='Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable), cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
description="", description="",
tags="", tags="",

View File

@ -23,6 +23,10 @@ import json
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from sqlalchemy import exists
from cryptography.fernet import Fernet
import cryptography.exceptions
from base64 import urlsafe_b64decode
try: try:
# Compatibility with sqlalchemy 2.0 # Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
@ -56,7 +60,8 @@ class _Settings(_Base):
mail_port = Column(Integer, default=25) mail_port = Column(Integer, default=25)
mail_use_ssl = Column(SmallInteger, default=0) mail_use_ssl = Column(SmallInteger, default=0)
mail_login = Column(String, default='mail@example.com') mail_login = Column(String, default='mail@example.com')
mail_password = Column(String, default='mypassword') mail_password_e = Column(String)
mail_password = Column(String)
mail_from = Column(String, default='automailer <mail@example.com>') mail_from = Column(String, default='automailer <mail@example.com>')
mail_size = Column(Integer, default=25*1024*1024) mail_size = Column(Integer, default=25*1024*1024)
mail_server_type = Column(SmallInteger, default=0) mail_server_type = Column(SmallInteger, default=0)
@ -69,19 +74,18 @@ class _Settings(_Base):
config_certfile = Column(String) config_certfile = Column(String)
config_keyfile = Column(String) config_keyfile = Column(String)
config_trustedhosts = Column(String, default='') config_trustedhosts = Column(String, default='')
config_calibre_web_title = Column(String, default=u'Calibre-Web') config_calibre_web_title = Column(String, default='Calibre-Web')
config_books_per_page = Column(Integer, default=60) config_books_per_page = Column(Integer, default=60)
config_random_books = Column(Integer, default=4) config_random_books = Column(Integer, default=4)
config_authors_max = Column(Integer, default=0) config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0) config_read_column = Column(Integer, default=0)
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+') config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
# config_mature_content_tags = Column(String, default='')
config_theme = Column(Integer, default=0) config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL) config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
config_logfile = Column(String) config_logfile = Column(String, default=logger.DEFAULT_LOG_FILE)
config_access_log = Column(SmallInteger, default=0) config_access_log = Column(SmallInteger, default=0)
config_access_logfile = Column(String) config_access_logfile = Column(String, default=logger.DEFAULT_ACCESS_LOG)
config_uploading = Column(SmallInteger, default=0) config_uploading = Column(SmallInteger, default=0)
config_anonbrowse = Column(SmallInteger, default=0) config_anonbrowse = Column(SmallInteger, default=0)
@ -107,6 +111,7 @@ class _Settings(_Base):
config_use_goodreads = Column(Boolean, default=False) config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String) config_goodreads_api_key = Column(String)
config_goodreads_api_secret_e = Column(String)
config_goodreads_api_secret = Column(String) config_goodreads_api_secret = Column(String)
config_register_email = Column(Boolean, default=False) config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0) config_login_type = Column(Integer, default=0)
@ -117,7 +122,8 @@ class _Settings(_Base):
config_ldap_port = Column(SmallInteger, default=389) config_ldap_port = Column(SmallInteger, default=389)
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE) config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org') config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
config_ldap_serv_password = Column(String, default="") config_ldap_serv_password_e = Column(String)
config_ldap_serv_password = Column(String)
config_ldap_encryption = Column(SmallInteger, default=0) config_ldap_encryption = Column(SmallInteger, default=0)
config_ldap_cacert_path = Column(String, default="") config_ldap_cacert_path = Column(String, default="")
config_ldap_cert_path = Column(String, default="") config_ldap_cert_path = Column(String, default="")
@ -148,24 +154,35 @@ class _Settings(_Base):
schedule_generate_book_covers = Column(Boolean, default=False) schedule_generate_book_covers = Column(Boolean, default=False)
schedule_generate_series_covers = Column(Boolean, default=False) schedule_generate_series_covers = Column(Boolean, default=False)
schedule_reconnect = Column(Boolean, default=False) schedule_reconnect = Column(Boolean, default=False)
schedule_metadata_backup = Column(Boolean, default=False)
config_password_policy = Column(Boolean, default=True)
config_password_min_length = Column(Integer, default=8)
config_password_number = Column(Boolean, default=True)
config_password_lower = Column(Boolean, default=True)
config_password_upper = Column(Boolean, default=True)
config_password_special = Column(Boolean, default=True)
config_session = Column(Integer, default=1)
config_ratelimiter = Column(Boolean, default=True)
def __repr__(self): def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__
# 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): def __init__(self):
pass self.__dict__["dirty"] = list()
def init_config(self, session, cli): def init_config(self, session, secret_key, 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._fernet = Fernet(secret_key)
self.cli = cli self.cli = cli
self.load()
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
@ -294,10 +311,10 @@ class _ConfigSQL(object):
setattr(self, field, new_value) setattr(self, field, new_value)
return True return True
def toDict(self): def to_dict(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") and not k == "cli": if k[0] != '_' and not k.endswith("_e") and not k == "cli":
storage[k] = v storage[k] = v
return storage return storage
@ -311,7 +328,13 @@ class _ConfigSQL(object):
column = s.__class__.__dict__.get(k) column = s.__class__.__dict__.get(k)
if column.default is not None: if column.default is not None:
v = column.default.arg v = column.default.arg
setattr(self, k, v) if k.endswith("_e") and v is not None:
try:
setattr(self, k, self._fernet.decrypt(v).decode())
except cryptography.fernet.InvalidToken:
setattr(self, k, "")
else:
setattr(self, k, v)
have_metadata_db = bool(self.config_calibre_dir) have_metadata_db = bool(self.config_calibre_dir)
if have_metadata_db: if have_metadata_db:
@ -319,30 +342,37 @@ class _ConfigSQL(object):
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(',')]
from . import cli_param
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG) logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
else: else:
# pylint: disable=access-member-before-definition # pylint: disable=access-member-before-definition
logfile = logger.setup(self.config_logfile, self.config_log_level) logfile = logger.setup(cli_param.logpath or self.config_logfile, self.config_log_level)
if logfile != self.config_logfile: if logfile != os.path.abspath(self.config_logfile):
log.warning("Log path %s not valid, falling back to default", self.config_logfile) if logfile != os.path.abspath(cli_param.logpath):
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
self.config_logfile = logfile self.config_logfile = logfile
s.config_logfile = logfile
self._session.merge(s) self._session.merge(s)
try: try:
self._session.commit() self._session.commit()
except OperationalError as e: except OperationalError as e:
log.error('Database error: %s', e) log.error('Database error: %s', e)
self._session.rollback() self._session.rollback()
self.__dict__["dirty"] = list()
def save(self): def save(self):
"""Apply all configuration values to the underlying storage.""" """Apply all configuration values to the underlying storage."""
s = self._read_from_storage() # type: _Settings s = self._read_from_storage() # type: _Settings
for k, v in self.__dict__.items(): for k in self.dirty:
if k[0] == '_': if k[0] == '_':
continue continue
if hasattr(s, k): if hasattr(s, k):
setattr(s, k, v) if k.endswith("_e"):
setattr(s, k, self._fernet.encrypt(self.__dict__[k].encode()))
else:
setattr(s, k, self.__dict__[k])
log.debug("_ConfigSQL updating storage") log.debug("_ConfigSQL updating storage")
self._session.merge(s) self._session.merge(s)
@ -358,7 +388,6 @@ class _ConfigSQL(object):
log.error(error) log.error(error)
log.warning("invalidating configuration") log.warning("invalidating configuration")
self.db_configured = False self.db_configured = False
# self.config_calibre_dir = None
self.save() self.save()
def store_calibre_uuid(self, calibre_db, Library_table): def store_calibre_uuid(self, calibre_db, Library_table):
@ -370,8 +399,40 @@ class _ConfigSQL(object):
except AttributeError: except AttributeError:
pass pass
def __setattr__(self, attr_name, attr_value):
super().__setattr__(attr_name, attr_value)
self.__dict__["dirty"].append(attr_name)
def _migrate_table(session, orm_class):
def _encrypt_fields(session, secret_key):
try:
session.query(exists().where(_Settings.mail_password_e)).scalar()
except OperationalError:
with session.bind.connect() as conn:
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
conn.execute(text("ALTER TABLE settings ADD column 'config_goodreads_api_secret_e' String"))
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
session.commit()
crypter = Fernet(secret_key)
settings = session.query(_Settings.mail_password, _Settings.config_goodreads_api_secret,
_Settings.config_ldap_serv_password).first()
if settings.mail_password:
session.query(_Settings).update(
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
if settings.config_goodreads_api_secret:
session.query(_Settings).update(
{_Settings.config_goodreads_api_secret_e:
crypter.encrypt(settings.config_goodreads_api_secret.encode())})
if settings.config_ldap_serv_password:
session.query(_Settings).update(
{_Settings.config_ldap_serv_password_e:
crypter.encrypt(settings.config_ldap_serv_password.encode())})
session.commit()
def _migrate_table(session, orm_class, secret_key=None):
if secret_key:
_encrypt_fields(session, secret_key)
changed = False changed = False
for column_name, column in orm_class.__dict__.items(): for column_name, column in orm_class.__dict__.items():
@ -447,22 +508,18 @@ def autodetect_kepubify_binary():
return "" return ""
def _migrate_database(session): def _migrate_database(session, secret_key):
# 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)
_migrate_table(session, _Settings) _migrate_table(session, _Settings, secret_key)
_migrate_table(session, _Flask_Settings) _migrate_table(session, _Flask_Settings)
def load_configuration(conf, session, cli): def load_configuration(session, secret_key):
_migrate_database(session) _migrate_database(session, secret_key)
if not session.query(_Settings).count(): if not session.query(_Settings).count():
session.add(_Settings()) session.add(_Settings())
session.commit() session.commit()
# conf = _ConfigSQL()
conf.init_config(session, cli)
# return conf
def get_flask_session_key(_session): def get_flask_session_key(_session):
@ -472,3 +529,25 @@ def get_flask_session_key(_session):
_session.add(flask_settings) _session.add(flask_settings)
_session.commit() _session.commit()
return flask_settings.flask_session_key return flask_settings.flask_session_key
def get_encryption_key(key_path):
key_file = os.path.join(key_path, ".key")
generate = True
error = ""
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
with open(key_file, "rb") as f:
key = f.read()
try:
urlsafe_b64decode(key)
generate = False
except ValueError:
pass
if generate:
key = Fernet.generate_key()
try:
with open(key_file, "wb") as f:
f.write(key)
except PermissionError as e:
error = e
return key, error

View File

@ -34,6 +34,8 @@ 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))
# if executable file the files should be placed in the parent dir (parallel to the exe file)
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')
@ -49,6 +51,9 @@ if HOME_CONFIG:
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)
if getattr(sys, 'frozen', False):
CONFIG_DIR = os.path.abspath(os.path.join(CONFIG_DIR, os.pardir))
DEFAULT_SETTINGS_FILE = "app.db" DEFAULT_SETTINGS_FILE = "app.db"
DEFAULT_GDRIVE_FILE = "gdrive.db" DEFAULT_GDRIVE_FILE = "gdrive.db"
@ -144,10 +149,10 @@ 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', EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr'] 'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'] 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'cb7', 'djvu', 'djv',
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg', 'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
'opus', 'wav', 'flac', 'm4a', 'm4b'} 'opus', 'wav', 'flac', 'm4a', 'm4b'}
@ -163,7 +168,8 @@ def selected_roles(dictionary):
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, pubdate, identifiers') 'series_id, languages, publisher, pubdate, identifiers')
STABLE_VERSION = {'version': '0.6.19'} # python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = {'version': '0.6.22 Beta'}
NIGHTLY_VERSION = dict() NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'

121
cps/db.py
View File

@ -111,66 +111,73 @@ class Identifiers(Base):
def format_type(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 "Amazon"
elif format_type.startswith("amazon_"): elif format_type.startswith("amazon_"):
return u"Amazon.{0}".format(format_type[7:]) return "Amazon.{0}".format(format_type[7:])
elif format_type == "isbn": elif format_type == "isbn":
return u"ISBN" return "ISBN"
elif format_type == "doi": elif format_type == "doi":
return u"DOI" return "DOI"
elif format_type == "douban": elif format_type == "douban":
return u"Douban" return "Douban"
elif format_type == "goodreads": elif format_type == "goodreads":
return u"Goodreads" return "Goodreads"
elif format_type == "babelio": elif format_type == "babelio":
return u"Babelio" return "Babelio"
elif format_type == "google": elif format_type == "google":
return u"Google Books" return "Google Books"
elif format_type == "kobo": elif format_type == "kobo":
return u"Kobo" return "Kobo"
elif format_type == "litres": elif format_type == "litres":
return u"ЛитРес" return "ЛитРес"
elif format_type == "issn": elif format_type == "issn":
return u"ISSN" return "ISSN"
elif format_type == "isfdb": elif format_type == "isfdb":
return u"ISFDB" return "ISFDB"
if format_type == "lubimyczytac": if format_type == "lubimyczytac":
return u"Lubimyczytac" return "Lubimyczytac"
if format_type == "databazeknih":
return "Databáze knih"
else: else:
return self.type return self.type
def __repr__(self): def __repr__(self):
format_type = self.type.lower() format_type = self.type.lower()
if format_type == "amazon" or format_type == "asin": if format_type == "amazon" or format_type == "asin":
return u"https://amazon.com/dp/{0}".format(self.val) return "https://amazon.com/dp/{0}".format(self.val)
elif format_type.startswith('amazon_'): elif format_type.startswith('amazon_'):
return u"https://amazon.{0}/dp/{1}".format(format_type[7:], self.val) return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
elif format_type == "isbn": elif format_type == "isbn":
return u"https://www.worldcat.org/isbn/{0}".format(self.val) return "https://www.worldcat.org/isbn/{0}".format(self.val)
elif format_type == "doi": elif format_type == "doi":
return u"https://dx.doi.org/{0}".format(self.val) return "https://dx.doi.org/{0}".format(self.val)
elif format_type == "goodreads": elif format_type == "goodreads":
return u"https://www.goodreads.com/book/show/{0}".format(self.val) return "https://www.goodreads.com/book/show/{0}".format(self.val)
elif format_type == "babelio": elif format_type == "babelio":
return u"https://www.babelio.com/livres/titre/{0}".format(self.val) return "https://www.babelio.com/livres/titre/{0}".format(self.val)
elif format_type == "douban": elif format_type == "douban":
return u"https://book.douban.com/subject/{0}".format(self.val) return "https://book.douban.com/subject/{0}".format(self.val)
elif format_type == "google": elif format_type == "google":
return u"https://books.google.com/books?id={0}".format(self.val) return "https://books.google.com/books?id={0}".format(self.val)
elif format_type == "kobo": elif format_type == "kobo":
return u"https://www.kobo.com/ebook/{0}".format(self.val) return "https://www.kobo.com/ebook/{0}".format(self.val)
elif format_type == "lubimyczytac": elif format_type == "lubimyczytac":
return u"https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val) return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
elif format_type == "litres": elif format_type == "litres":
return u"https://www.litres.ru/{0}".format(self.val) return "https://www.litres.ru/{0}".format(self.val)
elif format_type == "issn": elif format_type == "issn":
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val) return "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 "http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
elif format_type == "databazeknih":
return "https://www.databazeknih.cz/knihy/{0}".format(self.val)
elif self.val.lower().startswith("javascript:"): elif self.val.lower().startswith("javascript:"):
return quote(self.val) return quote(self.val)
elif self.val.lower().startswith("data:"):
link , __, __ = str.partition(self.val, ",")
return link
else: else:
return u"{0}".format(self.val) return "{0}".format(self.val)
class Comments(Base): class Comments(Base):
@ -188,7 +195,7 @@ class Comments(Base):
return self.text return self.text
def __repr__(self): def __repr__(self):
return u"<Comments({0})>".format(self.text) return "<Comments({0})>".format(self.text)
class Tags(Base): class Tags(Base):
@ -203,8 +210,11 @@ class Tags(Base):
def get(self): def get(self):
return self.name return self.name
def __eq__(self, other):
return self.name == other
def __repr__(self): def __repr__(self):
return u"<Tags('{0})>".format(self.name) return "<Tags('{0})>".format(self.name)
class Authors(Base): class Authors(Base):
@ -215,7 +225,7 @@ class Authors(Base):
sort = Column(String(collation='NOCASE')) sort = Column(String(collation='NOCASE'))
link = Column(String, nullable=False, default="") link = Column(String, nullable=False, default="")
def __init__(self, name, sort, link): def __init__(self, name, sort, link=""):
self.name = name self.name = name
self.sort = sort self.sort = sort
self.link = link self.link = link
@ -223,8 +233,11 @@ class Authors(Base):
def get(self): def get(self):
return self.name return self.name
def __eq__(self, other):
return self.name == other
def __repr__(self): def __repr__(self):
return u"<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link) return "<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link)
class Series(Base): class Series(Base):
@ -241,8 +254,11 @@ class Series(Base):
def get(self): def get(self):
return self.name return self.name
def __eq__(self, other):
return self.name == other
def __repr__(self): def __repr__(self):
return u"<Series('{0},{1}')>".format(self.name, self.sort) return "<Series('{0},{1}')>".format(self.name, self.sort)
class Ratings(Base): class Ratings(Base):
@ -257,8 +273,11 @@ class Ratings(Base):
def get(self): def get(self):
return self.rating return self.rating
def __eq__(self, other):
return self.rating == other
def __repr__(self): def __repr__(self):
return u"<Ratings('{0}')>".format(self.rating) return "<Ratings('{0}')>".format(self.rating)
class Languages(Base): class Languages(Base):
@ -271,13 +290,16 @@ class Languages(Base):
self.lang_code = lang_code self.lang_code = lang_code
def get(self): def get(self):
if self.language_name: if hasattr(self, "language_name"):
return self.language_name return self.language_name
else: else:
return self.lang_code return self.lang_code
def __eq__(self, other):
return self.lang_code == other
def __repr__(self): def __repr__(self):
return u"<Languages('{0}')>".format(self.lang_code) return "<Languages('{0}')>".format(self.lang_code)
class Publishers(Base): class Publishers(Base):
@ -294,8 +316,11 @@ class Publishers(Base):
def get(self): def get(self):
return self.name return self.name
def __eq__(self, other):
return self.name == other
def __repr__(self): def __repr__(self):
return u"<Publishers('{0},{1}')>".format(self.name, self.sort) return "<Publishers('{0},{1}')>".format(self.name, self.sort)
class Data(Base): class Data(Base):
@ -319,7 +344,7 @@ class Data(Base):
return self.name return self.name
def __repr__(self): def __repr__(self):
return u"<Data('{0},{1}{2}{3}')>".format(self.book, self.format, self.uncompressed_size, self.name) return "<Data('{0},{1}{2}{3}')>".format(self.book, self.format, self.uncompressed_size, self.name)
class Metadata_Dirtied(Base): class Metadata_Dirtied(Base):
@ -373,7 +398,7 @@ class Books(Base):
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 "<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,
self.last_modified, self.path, self.has_cover) self.last_modified, self.path, self.has_cover)
@ -404,7 +429,7 @@ class CustomColumns(Base):
content['table'] = "custom_column_" + str(self.id) content['table'] = "custom_column_" + str(self.id)
content['column'] = "value" content['column'] = "value"
content['datatype'] = self.datatype content['datatype'] = self.datatype
content['is_multiple'] = None if not self.is_multiple else self.is_multiple content['is_multiple'] = None if not self.is_multiple else "|"
content['kind'] = "field" content['kind'] = "field"
content['name'] = self.name content['name'] = self.name
content['search_terms'] = ['#' + self.label] content['search_terms'] = ['#' + self.label]
@ -418,9 +443,12 @@ class CustomColumns(Base):
content['is_csp'] = False content['is_csp'] = False
content['is_editable'] = self.editable content['is_editable'] = self.editable
content['rec_index'] = sequence + 22 # toDo why ?? content['rec_index'] = sequence + 22 # toDo why ??
content['#value#'] = value if isinstance(value, datetime):
content['#value#'] = {"__class__": "datetime.datetime", "__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
else:
content['#value#'] = value
content['#extra#'] = extra content['#extra#'] = extra
content['is_multiple2'] = {} content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",", "list_to_ui": ", "}
return json.dumps(content, ensure_ascii=False) return json.dumps(content, ensure_ascii=False)
@ -635,7 +663,7 @@ class CalibreDB:
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, future=True))
for inst in cls.instances: for inst in cls.instances:
inst.init_session() inst.init_session()
@ -822,8 +850,6 @@ class CalibreDB:
# 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('&')
@ -988,7 +1014,12 @@ class CalibreDB:
title = title[len(prep):] + ', ' + prep title = title[len(prep):] + ', ' + prep
return title.strip() return title.strip()
conn = conn or self.session.connection().connection.connection try:
# sqlalchemy <1.4.24
conn = conn or self.session.connection().connection.driver_connection
except AttributeError:
# sqlalchemy >1.4.24 and sqlalchemy 2.0
conn = conn or self.session.connection().connection.connection
try: try:
conn.create_function("title_sort", 1, _title_sort) conn.create_function("title_sort", 1, _title_sort)
except sqliteOperationalError: except sqliteOperationalError:

View File

@ -65,7 +65,7 @@ def send_debug():
file_list.remove(element) file_list.remove(element)
memory_zip = BytesIO() memory_zip = BytesIO()
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr('settings.txt', json.dumps(config.toDict(), sort_keys=True, indent=2)) zf.writestr('settings.txt', json.dumps(config.to_dict(), sort_keys=True, indent=2))
zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder)) zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder))
for fp in file_list: for fp in file_list:
zf.write(fp, os.path.basename(fp)) zf.write(fp, os.path.basename(fp))

View File

@ -61,7 +61,7 @@ def dependency_check(optional=False):
deps = load_dependencies(optional) deps = load_dependencies(optional)
for dep in deps: for dep in deps:
try: try:
dep_version_int = [int(x) for x in dep[0].split('.')] dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
low_check = [int(x) for x in dep[3].split('.')] low_check = [int(x) for x in dep[3].split('.')]
high_check = [int(x) for x in dep[5].split('.')] high_check = [int(x) for x in dep[5].split('.')]
except AttributeError: except AttributeError:

171
cps/editbooks.py Executable file → Normal file
View File

@ -25,21 +25,31 @@ from datetime import datetime
import json import json
from shutil import copyfile from shutil import copyfile
from uuid import uuid4 from uuid import uuid4
from markupsafe import escape # dependency of flask from markupsafe import escape, Markup # dependency of flask
from functools import wraps from functools import wraps
try: try:
from lxml.html.clean import clean_html from bleach import clean_text as clean_html
BLEACH = True
except ImportError: except ImportError:
clean_html = None try:
from nh3 import clean as clean_html
BLEACH = False
except ImportError:
try:
from lxml.html.clean import clean_html
BLEACH = False
except ImportError:
clean_html = None
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask import Blueprint, request, flash, redirect, url_for, abort, Response
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
from flask_babel import get_locale from flask_babel import get_locale
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import func
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status
from . import config, ub, db, calibre_db from . import config, ub, db, calibre_db
@ -107,7 +117,7 @@ def edit_book(book_id):
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found # Book not found
if not book: if not book:
flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error") category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
@ -151,7 +161,7 @@ def edit_book(book_id):
if to_save.get("cover_url", None): if to_save.get("cover_url", None):
if not current_user.role_upload(): if not current_user.role_upload():
edit_error = True edit_error = True
flash(_(u"User has no rights to upload cover"), category="error") flash(_("User has no rights to upload cover"), category="error")
if to_save["cover_url"].endswith('/static/generic_cover.jpg'): if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
book.has_cover = 0 book.has_cover = 0
else: else:
@ -226,7 +236,7 @@ def edit_book(book_id):
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e: except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
calibre_db.session.rollback() calibre_db.session.rollback()
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
return redirect(url_for('web.show_book', book_id=book.id)) return redirect(url_for('web.show_book', book_id=book.id))
except Exception as ex: except Exception as ex:
log.error_or_exception(ex) log.error_or_exception(ex)
@ -288,7 +298,7 @@ def upload():
if error: if error:
flash(error, category="error") flash(error, category="error")
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(title)) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(title))
upload_text = N_(u"File %(file)s uploaded", file=link) upload_text = N_("File %(file)s uploaded", file=link)
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(title))) WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(title)))
helper.add_book_to_thumbnail_cache(book_id) helper.add_book_to_thumbnail_cache(book_id)
@ -302,7 +312,8 @@ def upload():
except (OperationalError, IntegrityError, StaleDataError) as e: except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
@ -315,7 +326,7 @@ def convert_bookformat(book_id):
book_format_to = request.form.get('book_format_to', None) book_format_to = request.form.get('book_format_to', None)
if (book_format_from is None) or (book_format_to is None): if (book_format_from is None) or (book_format_to is None):
flash(_(u"Source or destination format for conversion missing"), category="error") flash(_("Source or destination format for conversion missing"), category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id)) return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to) log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to)
@ -323,11 +334,11 @@ def convert_bookformat(book_id):
book_format_to.upper(), current_user.name) book_format_to.upper(), current_user.name)
if rtn is None: if rtn is None:
flash(_(u"Book successfully queued for converting to %(book_format)s", flash(_("Book successfully queued for converting to %(book_format)s",
book_format=book_format_to), book_format=book_format_to),
category="success") category="success")
else: else:
flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error") flash(_("There was an error converting this book: %(res)s", res=rtn), category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id)) return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
@ -451,7 +462,7 @@ def edit_list_book(param):
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
ret = Response(json.dumps({'success': False, ret = Response(json.dumps({'success': False,
'msg': 'Database error: {}'.format(e.orig)}), 'msg': 'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}),
mimetype='application/json') mimetype='application/json')
return ret return ret
@ -469,7 +480,7 @@ def get_sorted_entry(field, bookid):
if field == 'sort': if field == 'sort':
return json.dumps({'sort': book.title}) return json.dumps({'sort': book.title})
if field == 'author_sort': if field == 'author_sort':
return json.dumps({'author_sort': book.author}) return json.dumps({'authors': " & ".join([a.name for a in calibre_db.order_authors([book])])})
return "" return ""
@ -563,7 +574,7 @@ def table_xchange_author_title():
calibre_db.session.commit() calibre_db.session.commit()
except (OperationalError, IntegrityError, StaleDataError) as e: except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: %s", e) log.error_or_exception("Database error: {}".format(e))
return json.dumps({'success': False}) return json.dumps({'success': False})
if config.config_use_google_drive: if config.config_use_google_drive:
@ -573,9 +584,9 @@ def table_xchange_author_title():
def merge_metadata(to_save, meta): def merge_metadata(to_save, meta):
if to_save.get('author_name', "") == _(u'Unknown'): if to_save.get('author_name', "") == _('Unknown'):
to_save['author_name'] = '' to_save['author_name'] = ''
if to_save.get('book_title', "") == _(u'Unknown'): if to_save.get('book_title', "") == _('Unknown'):
to_save['book_title'] = '' to_save['book_title'] = ''
for s_field, m_field in [ for s_field, m_field in [
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'), ('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
@ -597,6 +608,8 @@ def identifier_list(to_save, book):
val_key = id_val_prefix + type_key[len(id_type_prefix):] val_key = id_val_prefix + type_key[len(id_type_prefix):]
if val_key not in to_save.keys(): if val_key not in to_save.keys():
continue continue
if to_save[val_key].startswith("data:"):
to_save[val_key], __, __ = str.partition(to_save[val_key], ",")
result.append(db.Identifiers(to_save[val_key], type_value, book.id)) result.append(db.Identifiers(to_save[val_key], type_value, book.id))
return result return result
@ -611,7 +624,7 @@ def prepare_authors(authr):
# we have all author names now # we have all author names now
if input_authors == ['']: if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author input_authors = [_('Unknown')] # prevent empty Author
renamed = list() renamed = list()
for in_aut in input_authors: for in_aut in input_authors:
@ -628,11 +641,11 @@ def prepare_authors(authr):
def prepare_authors_on_upload(title, authr): def prepare_authors_on_upload(title, authr):
if title != _(u'Unknown') and authr != _(u'Unknown'): if title != _('Unknown') and authr != _('Unknown'):
entry = calibre_db.check_exists_book(authr, title) entry = calibre_db.check_exists_book(authr, title)
if entry: if entry:
log.info("Uploaded book probably exists in library") log.info("Uploaded book probably exists in library")
flash(_(u"Uploaded book probably exists in the library, consider to change before upload new: ") flash(_("Uploaded book probably exists in the library, consider to change before upload new: ")
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning") + Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
input_authors, renamed = prepare_authors(authr) input_authors, renamed = prepare_authors(authr)
@ -687,7 +700,7 @@ def create_book_on_upload(modify_date, meta):
modify_date |= edit_book_languages(meta.languages, db_book, upload_mode=True, invalid=invalid) modify_date |= edit_book_languages(meta.languages, db_book, upload_mode=True, invalid=invalid)
if invalid: if invalid:
for lang in invalid: for lang in invalid:
flash(_(u"'%(langname)s' is not a valid language", langname=lang), category="warning") flash(_("'%(langname)s' is not a valid language", langname=lang), category="warning")
# handle tags # handle tags
modify_date |= edit_book_tags(meta.tags, db_book) modify_date |= edit_book_tags(meta.tags, db_book)
@ -737,7 +750,7 @@ def file_handling_on_upload(requested_file):
meta = uploader.upload(requested_file, config.config_rarfile_location) meta = uploader.upload(requested_file, config.config_rarfile_location)
except (IOError, OSError): except (IOError, OSError):
log.error("File %s could not saved to temp dir", requested_file.filename) log.error("File %s could not saved to temp dir", requested_file.filename)
flash(_(u"File %(filename)s could not saved to temp dir", flash(_("File %(filename)s could not saved to temp dir",
filename=requested_file.filename), category="error") filename=requested_file.filename), category="error")
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
return meta, None return meta, None
@ -757,7 +770,7 @@ def move_coverfile(meta, db_book):
os.unlink(meta.cover) os.unlink(meta.cover)
except OSError as e: except OSError as e:
log.error("Failed to move cover file %s: %s", new_cover_path, e) log.error("Failed to move cover file %s: %s", new_cover_path, e)
flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=new_cover_path, flash(_("Failed to Move Cover File %(file)s: %(error)s", file=new_cover_path,
error=e), error=e),
category="error") category="error")
@ -771,7 +784,7 @@ def delete_whole_book(book_id, book):
# check if only this book links to: # check if only this book links to:
# author, language, series, tags, custom columns # author, language, series, tags, custom columns
modify_database_object([u''], book.authors, db.Authors, calibre_db.session, 'author') modify_database_object([''], book.authors, db.Authors, calibre_db.session, 'author')
modify_database_object([u''], book.tags, db.Tags, calibre_db.session, 'tags') modify_database_object([u''], book.tags, db.Tags, calibre_db.session, 'tags')
modify_database_object([u''], book.series, db.Series, calibre_db.session, 'series') modify_database_object([u''], book.series, db.Series, calibre_db.session, 'series')
modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages') modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages')
@ -892,7 +905,7 @@ def render_edit_book(book_id):
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all() cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
if not book: if not book:
flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error") category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
@ -927,7 +940,7 @@ def render_edit_book(book_id):
if kepub_possible: if kepub_possible:
allowed_conversion_formats.append('kepub') allowed_conversion_formats.append('kepub')
return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc, return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc,
title=_(u"edit metadata"), page="editbook", title=_("edit metadata"), page="editbook",
conversion_formats=allowed_conversion_formats, conversion_formats=allowed_conversion_formats,
config=config, config=config,
source_formats=valid_source_formats) source_formats=valid_source_formats)
@ -988,7 +1001,10 @@ def edit_book_series_index(series_index, book):
def edit_book_comments(comments, book): def edit_book_comments(comments, book):
modify_date = False modify_date = False
if comments: if comments:
comments = clean_html(comments) if BLEACH:
comments = clean_html(comments, tags=None, attributes=None)
else:
comments = clean_html(comments)
if len(book.comments): if len(book.comments):
if book.comments[0].text != comments: if book.comments[0].text != comments:
book.comments[0].text = comments book.comments[0].text = comments
@ -1012,7 +1028,7 @@ def edit_book_languages(languages, book, upload_mode=False, invalid=None):
if isinstance(invalid, list): if isinstance(invalid, list):
invalid.append(lang) invalid.append(lang)
else: else:
raise ValueError(_(u"'%(langname)s' is not a valid language", langname=lang)) raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
# ToDo: Not working correct # ToDo: Not working correct
if upload_mode and len(input_l) == 1: if upload_mode and len(input_l) == 1:
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view # If the language of the file is excluded from the users view, it's not imported, to allow the user to view
@ -1123,9 +1139,10 @@ def edit_cc_data(book_id, book, to_save, cc):
cc_db_value = None cc_db_value = None
if to_save[cc_string].strip(): if to_save[cc_string].strip():
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]: if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
changed, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string) change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else: else:
changed, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string) change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
changed |= change
else: else:
if cc_db_value is not None: if cc_db_value is not None:
# remove old cc_val # remove old cc_val
@ -1154,7 +1171,7 @@ def upload_single_file(file_request, book, book_id):
# check for empty request # check for empty request
if requested_file.filename != '': if requested_file.filename != '':
if not current_user.role_upload(): if not current_user.role_upload():
flash(_(u"User has no rights to upload additional file formats"), category="error") flash(_("User has no rights to upload additional file formats"), category="error")
return False return False
if '.' in requested_file.filename: if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
@ -1175,12 +1192,12 @@ def upload_single_file(file_request, book, book_id):
try: try:
os.makedirs(filepath) os.makedirs(filepath)
except OSError: except OSError:
flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error") flash(_("Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
return False return False
try: try:
requested_file.save(saved_filename) requested_file.save(saved_filename)
except OSError: except OSError:
flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error") flash(_("Failed to store file %(file)s.", file=saved_filename), category="error")
return False return False
file_size = os.path.getsize(saved_filename) file_size = os.path.getsize(saved_filename)
@ -1198,17 +1215,18 @@ def upload_single_file(file_request, book, book_id):
except (OperationalError, IntegrityError, StaleDataError) as e: except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error")
return False # return redirect(url_for('web.show_book', book_id=book.id)) return False # return redirect(url_for('web.show_book', book_id=book.id))
# Queue uploader info # Queue uploader info
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title)) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
upload_text = N_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link) upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title))) WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
return uploader.process( return uploader.process(
saved_filename, *os.path.splitext(requested_file.filename), saved_filename, *os.path.splitext(requested_file.filename),
rarExecutable=config.config_rarfile_location) rar_executable=config.config_rarfile_location)
return None return None
@ -1218,7 +1236,7 @@ def upload_cover(cover_request, book):
# check for empty request # check for empty request
if requested_file.filename != '': if requested_file.filename != '':
if not current_user.role_upload(): if not current_user.role_upload():
flash(_(u"User has no rights to upload cover"), category="error") flash(_("User has no rights to upload cover"), category="error")
return False return False
ret, message = helper.save_cover(requested_file, book.path) ret, message = helper.save_cover(requested_file, book.path)
if ret is True: if ret is True:
@ -1242,18 +1260,18 @@ def handle_title_on_edit(book, book_title):
def handle_author_on_edit(book, author_name, update_stored=True): def handle_author_on_edit(book, author_name, update_stored=True):
change = False
# handle author(s) # handle author(s)
input_authors, renamed = prepare_authors(author_name) input_authors, renamed = prepare_authors(author_name)
change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author') # change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, author name and sorted author name is generated new # Search for each author if author is in database, if not, author name and sorted author name is generated new
# everything then is assembled for sorted author field in database # everything then is assembled for sorted author field in database
sort_authors_list = list() sort_authors_list = list()
for inp in input_authors: for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first() stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author: if not stored_author:
stored_author = helper.get_sorted_author(inp) stored_author = helper.get_sorted_author(inp.replace('|', ','))
else: else:
stored_author = stored_author.sort stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author)) sort_authors_list.append(helper.get_sorted_author(stored_author))
@ -1261,6 +1279,9 @@ def handle_author_on_edit(book, author_name, update_stored=True):
if book.author_sort != sort_authors and update_stored: if book.author_sort != sort_authors and update_stored:
book.author_sort = sort_authors book.author_sort = sort_authors
change = True change = True
change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
return input_authors, change, renamed return input_authors, change, renamed
@ -1268,14 +1289,15 @@ def search_objects_remove(db_book_object, db_type, input_elements):
del_elements = [] del_elements = []
for c_elements in db_book_object: for c_elements in db_book_object:
found = False found = False
if db_type == 'languages': #if db_type == 'languages':
type_elements = c_elements.lang_code # type_elements = c_elements.lang_code
elif db_type == 'custom': if db_type == 'custom':
type_elements = c_elements.value type_elements = c_elements.value
else: else:
type_elements = c_elements.name # type_elements = c_elements.name
type_elements = c_elements
for inp_element in input_elements: for inp_element in input_elements:
if inp_element.lower() == type_elements.lower(): if type_elements == inp_element:
found = True found = True
break break
# if the element was not found in the new list, add it to remove list # if the element was not found in the new list, add it to remove list
@ -1289,13 +1311,11 @@ def search_objects_add(db_book_object, db_type, input_elements):
for inp_element in input_elements: for inp_element in input_elements:
found = False found = False
for c_elements in db_book_object: for c_elements in db_book_object:
if db_type == 'languages': if db_type == 'custom':
type_elements = c_elements.lang_code
elif db_type == 'custom':
type_elements = c_elements.value type_elements = c_elements.value
else: else:
type_elements = c_elements.name type_elements = c_elements
if inp_element == type_elements: if type_elements == inp_element:
found = True found = True
break break
if not found: if not found:
@ -1311,6 +1331,7 @@ def remove_objects(db_book_object, db_session, del_elements):
changed = True changed = True
if len(del_element.books) == 0: if len(del_element.books) == 0:
db_session.delete(del_element) db_session.delete(del_element)
db_session.flush()
return changed return changed
@ -1324,27 +1345,34 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
db_filter = db_object.name db_filter = db_object.name
for add_element in add_elements: for add_element in add_elements:
# check if an element with that name exists # check if an element with that name exists
db_element = db_session.query(db_object).filter(db_filter == add_element).first() changed = True
# db_session.query(db.Tags).filter((func.lower(db.Tags.name).ilike("GênOt"))).all()
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).first()
# db_element = db_session.query(db_object).filter(func.lower(db_filter) == add_element.lower()).first()
# if no element is found add it # if no element is found add it
if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "")
elif db_type == 'series':
new_element = db_object(add_element, add_element)
elif db_type == 'custom':
new_element = db_object(value=add_element)
elif db_type == 'publisher':
new_element = db_object(add_element, None)
else: # db_type should be tag or language
new_element = db_object(add_element)
if db_element is None: if db_element is None:
changed = True if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
elif db_type == 'series':
new_element = db_object(add_element, add_element)
elif db_type == 'custom':
new_element = db_object(value=add_element)
elif db_type == 'publisher':
new_element = db_object(add_element, None)
else: # db_type should be tag or language
new_element = db_object(add_element)
db_session.add(new_element) db_session.add(new_element)
db_book_object.append(new_element) db_book_object.append(new_element)
else: else:
db_element = create_objects_for_addition(db_element, add_element, db_type) db_no_case = db_session.query(db_object).filter(db_filter == add_element).first()
if db_no_case:
# check for new case of element
db_element = create_objects_for_addition(db_element, add_element, db_type)
else:
db_element = create_objects_for_addition(db_element, add_element, db_type)
# add element to book # add element to book
changed = True
db_book_object.append(db_element) db_book_object.append(db_element)
return changed return changed
@ -1379,13 +1407,24 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
if not isinstance(input_elements, list): if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list") raise TypeError(str(input_elements) + " should be passed as a list")
input_elements = [x for x in input_elements if x != ''] input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
changed = False
# If elements are renamed (upper lower case), rename it
for rec_a, rec_b in zip(db_book_object, input_elements):
if db_type == "custom":
if rec_a.value.casefold() == rec_b.casefold() and rec_a.value != rec_b:
create_objects_for_addition(rec_a, rec_b, db_type)
else:
if rec_a.get().casefold() == rec_b.casefold() and rec_a.get() != rec_b:
create_objects_for_addition(rec_a, rec_b, db_type)
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove # 1. search for elements to remove
del_elements = search_objects_remove(db_book_object, db_type, input_elements) del_elements = search_objects_remove(db_book_object, db_type, input_elements)
# 2. search for elements that need to be added # 2. search for elements that need to be added
add_elements = search_objects_add(db_book_object, db_type, input_elements) add_elements = search_objects_add(db_book_object, db_type, input_elements)
# if there are elements to remove, we remove them now # if there are elements to remove, we remove them now
changed = remove_objects(db_book_object, db_session, del_elements) changed |= remove_objects(db_book_object, db_session, del_elements)
# if there are elements to add, we add them now! # if there are elements to add, we add them now!
if len(add_elements) > 0: if len(add_elements) > 0:
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements) changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)

View File

@ -21,25 +21,54 @@ import zipfile
from lxml import etree from lxml import etree
from . import isoLanguages, cover from . import isoLanguages, cover
from . import config, logger
from .helper import split_authors from .helper import split_authors
from .constants import BookMeta from .constants import BookMeta
log = logger.create()
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name): def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
if cover_file is None: if cover_file is None:
return None return None
else:
cf = extension = None
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
prefix = os.path.splitext(tmp_file_name)[0] cf = extension = None
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path) zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
ext = os.path.splitext(tmp_cover_name)
if len(ext) > 1: prefix = os.path.splitext(tmp_file_name)[0]
extension = ext[1].lower() tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
if extension in cover.COVER_EXTENSIONS: ext = os.path.splitext(tmp_cover_name)
cf = zip_file.read(zip_cover_path) if len(ext) > 1:
return cover.cover_processing(tmp_file_name, cf, extension) extension = ext[1].lower()
if extension in cover.COVER_EXTENSIONS:
cf = zip_file.read(zip_cover_path)
return cover.cover_processing(tmp_file_name, cf, extension)
def get_epub_layout(book, book_data):
ns = {
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
'pkg': 'http://www.idpf.org/2007/opf',
}
file_path = os.path.normpath(os.path.join(config.config_calibre_dir, book.path, book_data.name + "." + book_data.format.lower()))
try:
epubZip = zipfile.ZipFile(file_path)
txt = epubZip.read('META-INF/container.xml')
tree = etree.fromstring(txt)
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epubZip.read(cfname)
tree = etree.fromstring(cf)
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=ns)
except (etree.XMLSyntaxError, KeyError, IndexError) as e:
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
layout = []
if len(layout) == 0:
return None
else:
return layout[0]
def get_epub_info(tmp_file_path, original_file_name, original_file_extension): def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
@ -80,13 +109,13 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
if epub_metadata['subject'] == 'Unknown': if epub_metadata['subject'] == 'Unknown':
epub_metadata['subject'] = '' epub_metadata['subject'] = ''
if epub_metadata['publisher'] == u'Unknown': if epub_metadata['publisher'] == 'Unknown':
epub_metadata['publisher'] = '' epub_metadata['publisher'] = ''
if epub_metadata['date'] == u'Unknown': if epub_metadata['date'] == 'Unknown':
epub_metadata['date'] = '' epub_metadata['date'] = ''
if epub_metadata['description'] == u'Unknown': if epub_metadata['description'] == 'Unknown':
description = tree.xpath("//*[local-name() = 'description']/text()") description = tree.xpath("//*[local-name() = 'description']/text()")
if len(description) > 0: if len(description) > 0:
epub_metadata['description'] = description epub_metadata['description'] = description
@ -102,7 +131,10 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
identifiers = [] identifiers = []
for node in p.xpath('dc:identifier', namespaces=ns): for node in p.xpath('dc:identifier', namespaces=ns):
identifier_name = node.attrib.values()[-1] try:
identifier_name = node.attrib.values()[-1]
except IndexError:
continue
identifier_value = node.text identifier_value = node.text
if identifier_name in ('uuid', 'calibre') or identifier_value is None: if identifier_name in ('uuid', 'calibre') or identifier_value is None:
continue continue
@ -131,40 +163,40 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path): def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns) cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
cover_file = None
# if len(cover_section) > 0:
for cs in cover_section: for cs in cover_section:
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path) cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
if cover_file: if cover_file:
break return cover_file
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:
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( cover_section = tree.xpath(
"/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns) "/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
if not cover_section: else:
cover_section = tree.xpath( cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
cover_file = None
for cs in cover_section:
if cs.endswith('.xhtml') or cs.endswith('.html'):
markup = epub_zip.read(os.path.join(cover_path, cs))
markup_tree = etree.fromstring(markup)
# no matter xhtml or html with no namespace
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
# Alternative image source
if not len(img_src):
img_src = markup_tree.xpath("//attribute::*[contains(local-name(), 'href')]")
if len(img_src):
# img_src maybe start with "../"" so fullpath join then relpath to cwd
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(cover_path, cover_section[0])),
img_src[0]))
cover_file = _extract_cover(epub_zip, filename, "", tmp_file_path)
else: else:
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns) cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
for cs in cover_section: if cover_file:
filetype = cs.rsplit('.', 1)[-1] break
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
markup = epub_zip.read(os.path.join(cover_path, cs))
markup_tree = etree.fromstring(markup)
# no matter xhtml or html with no namespace
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
# Alternative image source
if not len(img_src):
img_src = markup_tree.xpath("//attribute::*[contains(local-name(), 'href')]")
if len(img_src):
# img_src maybe start with "../"" so fullpath join then relpath to cwd
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(cover_path, cover_section[0])),
img_src[0]))
cover_file = _extract_cover(epub_zip, filename, "", tmp_file_path)
else:
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
if cover_file: break
return cover_file return cover_file

View File

@ -38,19 +38,19 @@ def get_fb2_info(tmp_file_path, original_file_extension):
if len(last_name): if len(last_name):
last_name = last_name[0] last_name = last_name[0]
else: else:
last_name = u'' last_name = ''
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns) middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
if len(middle_name): if len(middle_name):
middle_name = middle_name[0] middle_name = middle_name[0]
else: else:
middle_name = u'' middle_name = ''
first_name = element.xpath('fb:first-name/text()', namespaces=ns) first_name = element.xpath('fb:first-name/text()', namespaces=ns)
if len(first_name): if len(first_name):
first_name = first_name[0] first_name = first_name[0]
else: else:
first_name = u'' first_name = ''
return (first_name + u' ' return (first_name + ' '
+ middle_name + u' ' + middle_name + ' '
+ last_name) + last_name)
author = str(", ".join(map(get_author, authors))) author = str(", ".join(map(get_author, authors)))
@ -59,12 +59,12 @@ def get_fb2_info(tmp_file_path, original_file_extension):
if len(title): if len(title):
title = str(title[0]) title = str(title[0])
else: else:
title = u'' title = ''
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns) description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
if len(description): if len(description):
description = str(description[0]) description = str(description[0])
else: else:
description = u'' description = ''
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,

View File

@ -55,7 +55,7 @@ def authenticate_google_drive():
try: try:
authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl() authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl()
except gdriveutils.InvalidConfigError: except gdriveutils.InvalidConfigError:
flash(_(u'Google Drive setup not completed, try to deactivate and activate Google Drive again'), flash(_('Google Drive setup not completed, try to deactivate and activate Google Drive again'),
category="error") category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return redirect(authUrl) return redirect(authUrl)
@ -91,9 +91,9 @@ def watch_gdrive():
config.save() config.save()
except HttpError as e: except HttpError as e:
reason=json.loads(e.content)['error']['errors'][0] reason=json.loads(e.content)['error']['errors'][0]
if reason['reason'] == u'push.webhookUrlUnauthorized': if reason['reason'] == 'push.webhookUrlUnauthorized':
flash(_(u'Callback domain is not verified, ' flash(_('Callback domain is not verified, '
u'please follow steps to verify domain in google developer console'), category="error") 'please follow steps to verify domain in google developer console'), category="error")
else: else:
flash(reason['message'], category="error") flash(reason['message'], category="error")

View File

@ -147,7 +147,7 @@ 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
Session = sessionmaker() Session = sessionmaker(autoflush=False)
Session.configure(bind=engine) Session.configure(bind=engine)
session = scoped_session(Session) session = scoped_session(Session)
@ -174,30 +174,12 @@ class PermissionAdded(Base):
return str(self.gdrive_id) return str(self.gdrive_id)
def migrate():
if not engine.dialect.has_table(engine.connect(), "permissions_added"):
PermissionAdded.__table__.create(bind = engine)
for sql in session.execute(text("select sql from sqlite_master where type='table'")):
if 'CREATE TABLE gdrive_ids' in sql[0]:
currUniqueConstraint = 'UNIQUE (gdrive_id)'
if currUniqueConstraint in sql[0]:
sql=sql[0].replace(currUniqueConstraint, 'UNIQUE (gdrive_id, path)')
sql=sql.replace(GdriveId.__tablename__, GdriveId.__tablename__ + '2')
session.execute(sql)
session.execute("INSERT INTO gdrive_ids2 (id, gdrive_id, path) SELECT id, "
"gdrive_id, path FROM gdrive_ids;")
session.commit()
session.execute('DROP TABLE %s' % 'gdrive_ids')
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
break
if not os.path.exists(cli_param.gd_path): if not os.path.exists(cli_param.gd_path):
try: try:
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
except Exception as ex: except Exception as ex:
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex)) log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
raise raise
migrate()
def getDrive(drive=None, gauth=None): def getDrive(drive=None, gauth=None):
@ -344,7 +326,7 @@ def getFileFromEbooksFolder(path, fileName):
def moveGdriveFileRemote(origin_file_id, new_title): def moveGdriveFileRemote(origin_file_id, new_title):
origin_file_id['title']= new_title origin_file_id['title'] = new_title
origin_file_id.Upload() origin_file_id.Upload()
@ -422,7 +404,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
driveFile.Upload() driveFile.Upload()
def uploadFileToEbooksFolder(destFile, f): def uploadFileToEbooksFolder(destFile, f, string=False):
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
parent = getEbooksFolder(drive) parent = getEbooksFolder(drive)
splitDir = destFile.split('/') splitDir = destFile.split('/')
@ -435,7 +417,10 @@ def uploadFileToEbooksFolder(destFile, f):
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) if not string:
driveFile.SetContentFile(f)
else:
driveFile.SetContentString(f)
driveFile.Upload() driveFile.Upload()
else: else:
existing_Folder = 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" %
@ -556,7 +541,7 @@ def updateGdriveCalibreFromLocal():
# update gdrive.db on edit of books title # update gdrive.db on edit of books title
def updateDatabaseOnEdit(ID,newPath): def updateDatabaseOnEdit(ID,newPath):
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + u'/' sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first() storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
if storedPathName: if storedPathName:
storedPathName.path = sqlCheckPath storedPathName.path = sqlCheckPath

207
cps/helper.py Executable file → Normal file
View File

@ -18,6 +18,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 os import os
import random
import io import io
import mimetypes import mimetypes
import re import re
@ -77,29 +78,29 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
data = calibre_db.get_book_format(book.id, old_book_format) data = calibre_db.get_book_format(book.id, old_book_format)
if not data: if not data:
error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id) error_message = _("%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id)
log.error("convert_book_format: %s", error_message) log.error("convert_book_format: %s", error_message)
return error_message return error_message
file_path = os.path.join(calibre_path, book.path, data.name) file_path = os.path.join(calibre_path, book.path, data.name)
if config.config_use_google_drive: if config.config_use_google_drive:
if not gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower()): if not gd.getFileFromEbooksFolder(book.path, data.name + "." + old_book_format.lower()):
error_message = _(u"%(format)s not found on Google Drive: %(fn)s", error_message = _("%(format)s not found on Google Drive: %(fn)s",
format=old_book_format, fn=data.name + "." + old_book_format.lower()) format=old_book_format, fn=data.name + "." + old_book_format.lower())
return error_message return error_message
else: else:
if not os.path.exists(file_path + "." + old_book_format.lower()): if not os.path.exists(file_path + "." + old_book_format.lower()):
error_message = _(u"%(format)s not found: %(fn)s", error_message = _("%(format)s not found: %(fn)s",
format=old_book_format, fn=data.name + "." + old_book_format.lower()) format=old_book_format, fn=data.name + "." + old_book_format.lower())
return error_message return error_message
# read settings and append converter task to queue # read settings and append converter task to queue
if ereader_mail: if ereader_mail:
settings = config.get_mail_settings() settings = config.get_mail_settings()
settings['subject'] = _('Send to E-Reader') # pretranslate Subject for e-mail settings['subject'] = _('Send to eReader') # pretranslate Subject for Email
settings['body'] = _(u'This e-mail has been sent via Calibre-Web.') settings['body'] = _('This Email has been sent via Calibre-Web.')
else: else:
settings = dict() settings = dict()
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title)) # prevent xss link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title)) # prevent xss
txt = u"{} -> {}: {}".format( txt = "{} -> {}: {}".format(
old_book_format.upper(), old_book_format.upper(),
new_book_format.upper(), new_book_format.upper(),
link) link)
@ -111,30 +112,30 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
# Texts are not lazy translated as they are supposed to get send out as is # Texts are not lazy translated as they are supposed to get send out as is
def send_test_mail(ereader_mail, user_name): def send_test_mail(ereader_mail, user_name):
WorkerThread.add(user_name, TaskEmail(_(u'Calibre-Web test e-mail'), None, None, WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
config.get_mail_settings(), ereader_mail, N_(u"Test e-mail"), config.get_mail_settings(), ereader_mail, N_("Test Email"),
_(u'This e-mail has been sent via Calibre-Web.'))) _('This Email has been sent via Calibre-Web.')))
return return
# Send registration email or password reset email, depending on parameter resend (False means welcome email) # Send registration email or password reset email, depending on parameter resend (False means welcome email)
def send_registration_mail(e_mail, user_name, default_password, resend=False): def send_registration_mail(e_mail, user_name, default_password, resend=False):
txt = "Hello %s!\r\n" % user_name txt = "Hi %s!\r\n" % user_name
if not resend: if not resend:
txt += "Your new account at Calibre-Web has been created. Thanks for joining us!\r\n" txt += "Your account at Calibre-Web has been created.\r\n"
txt += "Please log in to your account using the following information:\r\n" txt += "Please log in using the following information:\r\n"
txt += "User name: %s\r\n" % user_name txt += "Username: %s\r\n" % user_name
txt += "Password: %s\r\n" % default_password txt += "Password: %s\r\n" % default_password
txt += "Don't forget to change your password after first login.\r\n" txt += "Don't forget to change your password after your first login.\r\n"
txt += "Sincerely\r\n\r\n" txt += "Regards,\r\n\r\n"
txt += "Your Calibre-Web team" txt += "Calibre-Web"
WorkerThread.add(None, TaskEmail( WorkerThread.add(None, TaskEmail(
subject=_(u'Get Started with Calibre-Web'), subject=_('Get Started with Calibre-Web'),
filepath=None, filepath=None,
attachment=None, attachment=None,
settings=config.get_mail_settings(), settings=config.get_mail_settings(),
recipient=e_mail, recipient=e_mail,
task_message=N_(u"Registration e-mail for user: %(name)s", name=user_name), task_message=N_("Registration Email for user: %(name)s", name=user_name),
text=txt text=txt
)) ))
return return
@ -145,13 +146,13 @@ def check_send_to_ereader_with_converter(formats):
if 'MOBI' in formats and 'EPUB' not in formats: if 'MOBI' in formats and 'EPUB' not in formats:
book_formats.append({'format': 'Epub', book_formats.append({'format': 'Epub',
'convert': 1, 'convert': 1,
'text': _('Convert %(orig)s to %(format)s and send to E-Reader', 'text': _('Convert %(orig)s to %(format)s and send to eReader',
orig='Mobi', orig='Mobi',
format='Epub')}) format='Epub')})
if 'AZW3' in formats and 'EPUB' not in formats: if 'AZW3' in formats and 'EPUB' not in formats:
book_formats.append({'format': 'Epub', book_formats.append({'format': 'Epub',
'convert': 2, 'convert': 2,
'text': _('Convert %(orig)s to %(format)s and send to E-Reader', 'text': _('Convert %(orig)s to %(format)s and send to eReader',
orig='Azw3', orig='Azw3',
format='Epub')}) format='Epub')})
return book_formats return book_formats
@ -159,7 +160,7 @@ def check_send_to_ereader_with_converter(formats):
def check_send_to_ereader(entry): def check_send_to_ereader(entry):
""" """
returns all available book formats for sending to E-Reader returns all available book formats for sending to eReader
""" """
formats = list() formats = list()
book_formats = list() book_formats = list()
@ -170,31 +171,27 @@ def check_send_to_ereader(entry):
if 'EPUB' in formats: if 'EPUB' in formats:
book_formats.append({'format': 'Epub', book_formats.append({'format': 'Epub',
'convert': 0, 'convert': 0,
'text': _('Send %(format)s to E-Reader', format='Epub')}) 'text': _('Send %(format)s to eReader', format='Epub')})
if 'MOBI' in formats:
book_formats.append({'format': 'Mobi',
'convert': 0,
'text': _('Send %(format)s to E-Reader', format='Mobi')})
if 'PDF' in formats: if 'PDF' in formats:
book_formats.append({'format': 'Pdf', book_formats.append({'format': 'Pdf',
'convert': 0, 'convert': 0,
'text': _('Send %(format)s to E-Reader', format='Pdf')}) 'text': _('Send %(format)s to eReader', format='Pdf')})
if 'AZW' in formats: if 'AZW' in formats:
book_formats.append({'format': 'Azw', book_formats.append({'format': 'Azw',
'convert': 0, 'convert': 0,
'text': _('Send %(format)s to E-Reader', format='Azw')}) 'text': _('Send %(format)s to eReader', format='Azw')})
if config.config_converterpath: if config.config_converterpath:
book_formats.extend(check_send_to_ereader_with_converter(formats)) book_formats.extend(check_send_to_ereader_with_converter(formats))
return book_formats return book_formats
else: else:
log.error(u'Cannot find book entry %d', entry.id) log.error('Cannot find book entry %d', entry.id)
return None return None
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return # Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
# list with supported formats # list with supported formats
def check_read_formats(entry): def check_read_formats(entry):
extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU'} extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
book_formats = list() book_formats = list()
if len(entry.data): if len(entry.data):
for ele in iter(entry.data): for ele in iter(entry.data):
@ -204,30 +201,30 @@ def check_read_formats(entry):
# Files are processed in the following order/priority: # Files are processed in the following order/priority:
# 1: If Mobi file is existing, it's directly send to E-Reader email, # 1: If epub file is existing, it's directly send to eReader email,
# 2: If Epub file is existing, it's converted and send to E-Reader email, # 2: If mobi file is existing, it's converted and send to eReader email,
# 3: If Pdf file is existing, it's directly send to E-Reader email # 3: If Pdf file is existing, it's directly send to eReader email
def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id): def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id):
"""Send email with attachments""" """Send email with attachments"""
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
if convert == 1: if convert == 1:
# returns None if success, otherwise errormessage # returns None if success, otherwise errormessage
return convert_book_format(book_id, calibrepath, u'epub', book_format.lower(), user_id, ereader_mail) return convert_book_format(book_id, calibrepath, 'mobi', book_format.lower(), user_id, ereader_mail)
if convert == 2: if convert == 2:
# returns None if success, otherwise errormessage # returns None if success, otherwise errormessage
return convert_book_format(book_id, calibrepath, u'azw3', book_format.lower(), user_id, ereader_mail) return convert_book_format(book_id, calibrepath, 'azw3', book_format.lower(), user_id, ereader_mail)
for entry in iter(book.data): for entry in iter(book.data):
if entry.format.upper() == book_format.upper(): if entry.format.upper() == book_format.upper():
converted_file_name = entry.name + '.' + book_format.lower() converted_file_name = entry.name + '.' + book_format.lower()
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title)) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
email_text = N_(u"%(book)s send to E-Reader", book=link) email_text = N_("%(book)s send to eReader", book=link)
WorkerThread.add(user_id, TaskEmail(_(u"Send to E-Reader"), book.path, converted_file_name, WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), ereader_mail, config.get_mail_settings(), ereader_mail,
email_text, _(u'This e-mail has been sent via Calibre-Web.'))) email_text, _('This Email has been sent via Calibre-Web.')))
return return
return _(u"The requested file could not be read. Maybe wrong permissions?") return _("The requested file could not be read. Maybe wrong permissions?")
def get_valid_filename(value, replace_whitespace=True, chars=128): def get_valid_filename(value, replace_whitespace=True, chars=128):
@ -235,16 +232,16 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
Returns the given string converted to a string that can be used for a clean Returns the given string converted to a string that can be used for a clean
filename. Limits num characters to 128 max. filename. Limits num characters to 128 max.
""" """
if value[-1:] == u'.': if value[-1:] == '.':
value = value[:-1]+u'_' value = value[:-1]+'_'
value = value.replace("/", "_").replace(":", "_").strip('\0') value = value.replace("/", "_").replace(":", "_").strip('\0')
if config.config_unicode_filename: if config.config_unicode_filename:
value = (unidecode.unidecode(value)) value = (unidecode.unidecode(value))
if replace_whitespace: if replace_whitespace:
# *+:\"/<>? are replaced by _ # *+:\"/<>? are replaced by _
value = re.sub(r'[*+:\\\"/<>?]+', u'_', value, flags=re.U) value = re.sub(r'[*+:\\\"/<>?]+', '_', value, flags=re.U)
# pipe has to be replaced with comma # pipe has to be replaced with comma
value = re.sub(r'[|]+', u',', value, flags=re.U) value = re.sub(r'[|]+', ',', value, flags=re.U)
value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip() value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip()
@ -341,7 +338,7 @@ def edit_book_read_status(book_id, read_status=None):
return "Custom Column No.{} does not exist in calibre database".format(config.config_read_column) return "Custom Column No.{} does not exist in calibre database".format(config.config_read_column)
except (OperationalError, InvalidRequestError) as ex: except (OperationalError, InvalidRequestError) as ex:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error(u"Read status could not set: {}".format(ex)) log.error("Read status could not set: {}".format(ex))
return _("Read status could not set: {}".format(ex.orig)) return _("Read status could not set: {}".format(ex.orig))
return "" return ""
@ -416,8 +413,8 @@ def clean_author_database(renamed_author, calibre_path="", local_book=None, gdri
g_file = gd.getFileFromEbooksFolder(all_new_path, g_file = gd.getFileFromEbooksFolder(all_new_path,
file_format.name + '.' + file_format.format.lower()) file_format.name + '.' + file_format.format.lower())
if g_file: if g_file:
gd.moveGdriveFileRemote(g_file, all_new_name + u'.' + file_format.format.lower()) gd.moveGdriveFileRemote(g_file, all_new_name + '.' + file_format.format.lower())
gd.updateDatabaseOnEdit(g_file['id'], all_new_name + u'.' + file_format.format.lower()) gd.updateDatabaseOnEdit(g_file['id'], all_new_name + '.' + file_format.format.lower())
else: else:
log.error("File {} not found on gdrive" log.error("File {} not found on gdrive"
.format(all_new_path, file_format.name + '.' + file_format.format.lower())) .format(all_new_path, file_format.name + '.' + file_format.format.lower()))
@ -510,25 +507,25 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author):
authordir = book.path.split('/')[0] authordir = book.path.split('/')[0]
titledir = book.path.split('/')[1] titledir = book.path.split('/')[1]
new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True) new_authordir = rename_all_authors(first_author, renamed_author, gdrive=True)
new_titledir = get_valid_filename(book.title, chars=96) + u" (" + str(book_id) + u")" new_titledir = get_valid_filename(book.title, chars=96) + " (" + str(book_id) + ")"
if titledir != new_titledir: if titledir != new_titledir:
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir) g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
if g_file: if g_file:
gd.moveGdriveFileRemote(g_file, new_titledir) gd.moveGdriveFileRemote(g_file, new_titledir)
book.path = book.path.split('/')[0] + u'/' + new_titledir book.path = book.path.split('/')[0] + '/' + new_titledir
gd.updateDatabaseOnEdit(g_file['id'], book.path) # only child folder affected gd.updateDatabaseOnEdit(g_file['id'], book.path) # only child folder affected
else: else:
return _(u'File %(file)s not found on Google Drive', file=book.path) # file not found return _('File %(file)s not found on Google Drive', file=book.path) # file not found
if authordir != new_authordir and authordir not in renamed_author: if authordir != new_authordir and authordir not in renamed_author:
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir) g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
if g_file: if g_file:
gd.moveGdriveFolderRemote(g_file, new_authordir) gd.moveGdriveFolderRemote(g_file, new_authordir)
book.path = new_authordir + u'/' + book.path.split('/')[1] book.path = new_authordir + '/' + book.path.split('/')[1]
gd.updateDatabaseOnEdit(g_file['id'], book.path) gd.updateDatabaseOnEdit(g_file['id'], book.path)
else: else:
return _(u'File %(file)s not found on Google Drive', file=authordir) # file not found return _('File %(file)s not found on Google Drive', file=authordir) # file not found
# change location in database to new author/title path # change location in database to new author/title path
book.path = os.path.join(new_authordir, new_titledir).replace('\\', '/') book.path = os.path.join(new_authordir, new_titledir).replace('\\', '/')
@ -600,7 +597,7 @@ def delete_book_gdrive(book, book_format):
gd.deleteDatabaseEntry(g_file['id']) gd.deleteDatabaseEntry(g_file['id'])
g_file.Trash() g_file.Trash()
else: else:
error = _(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found error = _('Book path %(path)s not found on Google Drive', path=book.path) # file not found
return error is None, error return error is None, error
@ -612,7 +609,7 @@ def reset_password(user_id):
if not config.get_mail_server_configured(): if not config.get_mail_server_configured():
return 2, None return 2, None
try: try:
password = generate_random_password() password = generate_random_password(config.config_password_min_length)
existing_user.password = generate_password_hash(password) existing_user.password = generate_password_hash(password)
ub.session.commit() ub.session.commit()
send_registration_mail(existing_user.email, existing_user.name, password, True) send_registration_mail(existing_user.email, existing_user.name, password, True)
@ -621,11 +618,35 @@ def reset_password(user_id):
ub.session.rollback() ub.session.rollback()
return 0, None return 0, None
def generate_random_password(min_length):
min_length = max(8, min_length) - 4
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
# select 1 lowercase
s = "abcdefghijklmnopqrstuvwxyz"
password = [s[c % len(s)] for c in os.urandom(1)]
# select 1 uppercase
s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
password.extend([s[c % len(s)] for c in os.urandom(1)])
# select 1 digit
s = "01234567890"
password.extend([s[c % len(s)] for c in os.urandom(1)])
# select 1 special symbol
s = "!@#$%&*()?"
password.extend([s[c % len(s)] for c in os.urandom(1)])
def generate_random_password(): # generate other characters
password.extend([random_source[c % len(random_source)] for c in os.urandom(min_length)])
# password_list = list(password)
# shuffle all characters
random.SystemRandom().shuffle(password)
return ''.join(password)
'''def generate_random_password(min_length):
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
passlen = 8 passlen = min_length
return "".join(s[c % len(s)] for c in os.urandom(passlen)) return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
def uniq(inpt): def uniq(inpt):
@ -640,16 +661,16 @@ def uniq(inpt):
def check_email(email): def check_email(email):
email = valid_email(email) email = valid_email(email)
if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first(): if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first():
log.error(u"Found an existing account for this e-mail address") log.error("Found an existing account for this Email address")
raise Exception(_(u"Found an existing account for this e-mail address")) raise Exception(_("Found an existing account for this Email address"))
return email return email
def check_username(username): def check_username(username):
username = username.strip() username = username.strip()
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar(): if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error(u"This username is already taken") log.error("This username is already taken")
raise Exception(_(u"This username is already taken")) raise Exception(_("This username is already taken"))
return username return username
@ -660,10 +681,27 @@ def valid_email(email):
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation # Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$", if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email): email):
log.error(u"Invalid e-mail address format") log.error("Invalid Email address format")
raise Exception(_(u"Invalid e-mail address format")) raise Exception(_("Invalid Email address format"))
return email return email
def valid_password(check_password):
if config.config_password_policy:
verify = ""
if config.config_password_min_length > 0:
verify += "^(?=.{" + str(config.config_password_min_length) + ",}$)"
if config.config_password_number:
verify += "(?=.*?\d)"
if config.config_password_lower:
verify += "(?=.*?[a-z])"
if config.config_password_upper:
verify += "(?=.*?[A-Z])"
if config.config_password_special:
verify += "(?=.*?[^A-Za-z\s0-9])"
match = re.match(verify, check_password)
if not match:
raise Exception(_("Password doesn't comply with password validation rules"))
return check_password
# ################################# External interface ################################# # ################################# External interface #################################
@ -694,28 +732,27 @@ def delete_book(book, calibrepath, book_format):
return delete_book_file(book, calibrepath, book_format) return delete_book_file(book, calibrepath, book_format)
def get_cover_on_failure(use_generic_cover): def get_cover_on_failure():
if use_generic_cover: try:
try: return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
return send_from_directory(_STATIC_DIR, "generic_cover.jpg") except PermissionError:
except PermissionError: log.error("No permission to access generic_cover.jpg file.")
log.error("No permission to access generic_cover.jpg file.") abort(403)
abort(403)
abort(404)
def get_book_cover(book_id, resolution=None): def get_book_cover(book_id, resolution=None):
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) return get_book_cover_internal(book, resolution=resolution)
# Called only by kobo sync -> cover not found should be answered with 404 and not with default cover
def get_book_cover_with_uuid(book_uuid, resolution=None): def get_book_cover_with_uuid(book_uuid, resolution=None):
book = calibre_db.get_book_by_uuid(book_uuid) book = calibre_db.get_book_by_uuid(book_uuid)
return get_book_cover_internal(book, use_generic_cover_on_failure=False, resolution=resolution) if not book:
return # allows kobo.HandleCoverImageRequest to proxy request
return get_book_cover_internal(book, resolution=resolution)
def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): def get_book_cover_internal(book, resolution=None):
if book and book.has_cover: if book and book.has_cover:
# Send the book cover thumbnail if it exists in cache # Send the book cover thumbnail if it exists in cache
@ -731,16 +768,16 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
if config.config_use_google_drive: if config.config_use_google_drive:
try: try:
if not gd.is_gdrive_ready(): if not gd.is_gdrive_ready():
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure()
path = gd.get_cover_via_gdrive(book.path) path = gd.get_cover_via_gdrive(book.path)
if path: if path:
return redirect(path) return redirect(path)
else: else:
log.error('{}/cover.jpg not found on Google Drive'.format(book.path)) log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure()
except Exception as ex: except Exception as ex:
log.error_or_exception(ex) log.error_or_exception(ex)
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure()
# Send the book cover from the Calibre directory # Send the book cover from the Calibre directory
else: else:
@ -748,9 +785,9 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
return send_from_directory(cover_file_path, "cover.jpg") return send_from_directory(cover_file_path, "cover.jpg")
else: else:
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure()
else: else:
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure()
def get_book_cover_thumbnail(book, resolution): def get_book_cover_thumbnail(book, resolution):
@ -773,7 +810,7 @@ def get_series_thumbnail_on_failure(series_id, resolution):
.filter(db.Books.has_cover == 1) \ .filter(db.Books.has_cover == 1) \
.first() .first()
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) return get_book_cover_internal(book, resolution=resolution)
def get_series_cover_thumbnail(series_id, resolution=None): def get_series_cover_thumbnail(series_id, resolution=None):
@ -837,8 +874,8 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
try: try:
os.makedirs(filepath) os.makedirs(filepath)
except OSError: except OSError:
log.error(u"Failed to create path for cover") log.error("Failed to create path for cover")
return False, _(u"Failed to create path for cover") return False, _("Failed to create path for cover")
try: try:
# upload of jgp file without wand # upload of jgp file without wand
if isinstance(img, requests.Response): if isinstance(img, requests.Response):
@ -853,8 +890,8 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
# upload of jpg/png... from hdd # upload of jpg/png... from hdd
img.save(os.path.join(filepath, saved_filename)) img.save(os.path.join(filepath, saved_filename))
except (IOError, OSError): except (IOError, OSError):
log.error(u"Cover-file is not a valid image file, or could not be stored") log.error("Cover-file is not a valid image file, or could not be stored")
return False, _(u"Cover-file is not a valid image file, or could not be stored") return False, _("Cover-file is not a valid image file, or could not be stored")
return True, None return True, None
@ -1004,7 +1041,7 @@ def get_download_link(book_id, book_format, client):
headers = Headers() headers = Headers()
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream") headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % ( headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % (
quote(file_name.encode('utf-8')), book_format, quote(file_name.encode('utf-8')), book_format) quote(file_name), book_format, quote(file_name), book_format)
return do_download_file(book, book_format, client, data1, headers) return do_download_file(book, book_format, client, data1, headers)
else: else:
abort(404) abort(404)

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ import base64
import datetime import datetime
import os import os
import uuid import uuid
import zipfile
from time import gmtime, strftime from time import gmtime, strftime
import json import json
from urllib.parse import unquote from urllib.parse import unquote
@ -46,7 +47,8 @@ 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 . import isoLanguages from . import isoLanguages
from .constants import sqlalchemy_version2, COVER_THUMBNAIL_SMALL from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL #, sqlalchemy_version2
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
@ -54,7 +56,7 @@ from .kobo_auth import requires_kobo_auth, get_auth_token
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]} KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
KOBO_STOREAPI_URL = "https://storeapi.kobo.com" KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
KOBO_IMAGEHOST_URL = "https://kbimages1-a.akamaihd.net" KOBO_IMAGEHOST_URL = "https://cdn.kobo.com/book-images"
SYNC_ITEM_LIMIT = 100 SYNC_ITEM_LIMIT = 100
@ -140,6 +142,7 @@ def HandleSyncRequest():
sync_token = SyncToken.SyncToken.from_headers(request.headers) sync_token = SyncToken.SyncToken.from_headers(request.headers)
log.info("Kobo library sync request received.") log.info("Kobo library sync request received.")
log.debug("SyncToken: {}".format(sync_token)) log.debug("SyncToken: {}".format(sync_token))
log.debug("Download link format {}".format(get_download_url_for_book('[bookid]','[bookformat]')))
if not current_app.wsgi_app.is_proxied: if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to external server port') log.debug('Kobo: Received unproxied request, changed request port to external server port')
@ -163,16 +166,10 @@ def HandleSyncRequest():
only_kobo_shelves = current_user.kobo_only_shelves_sync only_kobo_shelves = current_user.kobo_only_shelves_sync
if only_kobo_shelves: if only_kobo_shelves:
if sqlalchemy_version2: changed_entries = calibre_db.session.query(db.Books,
changed_entries = select(db.Books, ub.ArchivedBook.last_modified,
ub.ArchivedBook.last_modified, ub.BookShelf.date_added,
ub.BookShelf.date_added, ub.ArchivedBook.is_archived)
ub.ArchivedBook.is_archived)
else:
changed_entries = calibre_db.session.query(db.Books,
ub.ArchivedBook.last_modified,
ub.BookShelf.date_added,
ub.ArchivedBook.is_archived)
changed_entries = (changed_entries changed_entries = (changed_entries
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id, .join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
ub.ArchivedBook.user_id == current_user.id)) ub.ArchivedBook.user_id == current_user.id))
@ -189,12 +186,9 @@ def HandleSyncRequest():
.filter(ub.Shelf.kobo_sync) .filter(ub.Shelf.kobo_sync)
.distinct()) .distinct())
else: else:
if sqlalchemy_version2: changed_entries = calibre_db.session.query(db.Books,
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived) ub.ArchivedBook.last_modified,
else: ub.ArchivedBook.is_archived)
changed_entries = calibre_db.session.query(db.Books,
ub.ArchivedBook.last_modified,
ub.ArchivedBook.is_archived)
changed_entries = (changed_entries changed_entries = (changed_entries
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id, .join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
ub.ArchivedBook.user_id == current_user.id)) ub.ArchivedBook.user_id == current_user.id))
@ -206,10 +200,7 @@ def HandleSyncRequest():
.order_by(db.Books.id)) .order_by(db.Books.id))
reading_states_in_new_entitlements = [] reading_states_in_new_entitlements = []
if sqlalchemy_version2: books = changed_entries.limit(SYNC_ITEM_LIMIT)
books = calibre_db.session.execute(changed_entries.limit(SYNC_ITEM_LIMIT))
else:
books = changed_entries.limit(SYNC_ITEM_LIMIT)
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]
@ -227,7 +218,7 @@ def HandleSyncRequest():
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified) new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
reading_states_in_new_entitlements.append(book.Books.id) reading_states_in_new_entitlements.append(book.Books.id)
ts_created = book.Books.timestamp ts_created = book.Books.timestamp.replace(tzinfo=None)
try: try:
ts_created = max(ts_created, book.date_added) ts_created = max(ts_created, book.date_added)
@ -240,7 +231,7 @@ def HandleSyncRequest():
sync_results.append({"ChangedEntitlement": entitlement}) sync_results.append({"ChangedEntitlement": entitlement})
new_books_last_modified = max( new_books_last_modified = max(
book.Books.last_modified, new_books_last_modified book.Books.last_modified.replace(tzinfo=None), new_books_last_modified
) )
try: try:
new_books_last_modified = max( new_books_last_modified = max(
@ -252,27 +243,16 @@ def HandleSyncRequest():
new_books_last_created = max(ts_created, new_books_last_created) new_books_last_created = max(ts_created, new_books_last_created)
kobo_sync_status.add_synced_books(book.Books.id) kobo_sync_status.add_synced_books(book.Books.id)
if sqlalchemy_version2: max_change = changed_entries.filter(ub.ArchivedBook.is_archived)\
max_change = calibre_db.session.execute(changed_entries .filter(ub.ArchivedBook.user_id == current_user.id) \
.filter(ub.ArchivedBook.is_archived) .order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
.filter(ub.ArchivedBook.user_id == current_user.id)
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
.columns(db.Books).first()
else:
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
.filter(ub.ArchivedBook.user_id == current_user.id) \
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
max_change = max_change.last_modified if max_change else new_archived_last_modified max_change = max_change.last_modified if max_change else new_archived_last_modified
new_archived_last_modified = max(new_archived_last_modified, max_change) new_archived_last_modified = max(new_archived_last_modified, max_change)
# no. of books returned # no. of books returned
if sqlalchemy_version2: book_count = changed_entries.count()
entries = calibre_db.session.execute(changed_entries).all()
book_count = len(entries)
else:
book_count = changed_entries.count()
# last entry: # last entry:
cont_sync = bool(book_count) cont_sync = bool(book_count)
log.debug("Remaining books to Sync: {}".format(book_count)) log.debug("Remaining books to Sync: {}".format(book_count))
@ -335,7 +315,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
except Exception as ex: except Exception as ex:
log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex)) log.error_or_exception("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
if set_cont: if set_cont:
extra_headers["x-kobo-sync"] = "continue" extra_headers["x-kobo-sync"] = "continue"
sync_token.to_headers(extra_headers) sync_token.to_headers(extra_headers)
@ -356,7 +336,7 @@ def HandleMetadataRequest(book_uuid):
log.info("Kobo library metadata request received for book %s" % book_uuid) log.info("Kobo library metadata request received for book %s" % book_uuid)
book = calibre_db.get_book_by_uuid(book_uuid) book = calibre_db.get_book_by_uuid(book_uuid)
if not book or not book.data: if not book or not book.data:
log.info(u"Book %s not found in database", book_uuid) log.info("Book %s not found in database", book_uuid)
return redirect_or_proxy_request() return redirect_or_proxy_request()
metadata = get_metadata(book) metadata = get_metadata(book)
@ -365,7 +345,7 @@ def HandleMetadataRequest(book_uuid):
return response return response
def get_download_url_for_book(book, book_format): def get_download_url_for_book(book_id, book_format):
if not current_app.wsgi_app.is_proxied: if not current_app.wsgi_app.is_proxied:
if ':' in request.host and not request.host.endswith(']'): if ':' in request.host and not request.host.endswith(']'):
host = "".join(request.host.split(':')[:-1]) host = "".join(request.host.split(':')[:-1])
@ -377,13 +357,13 @@ def get_download_url_for_book(book, book_format):
url_base=host, url_base=host,
url_port=config.config_external_port, url_port=config.config_external_port,
auth_token=get_auth_token(), auth_token=get_auth_token(),
book_id=book.id, book_id=book_id,
book_format=book_format.lower() book_format=book_format.lower()
) )
return url_for( return url_for(
"kobo.download_book", "kobo.download_book",
auth_token=kobo_auth.get_auth_token(), auth_token=kobo_auth.get_auth_token(),
book_id=book.id, book_id=book_id,
book_format=book_format.lower(), book_format=book_format.lower(),
_external=True, _external=True,
) )
@ -459,16 +439,21 @@ def get_metadata(book):
continue continue
for kobo_format in KOBO_FORMATS[book_data.format]: for kobo_format in KOBO_FORMATS[book_data.format]:
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format)) # log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
download_urls.append( try:
{ if get_epub_layout(book, book_data) == 'pre-paginated':
"Format": kobo_format, kobo_format = 'EPUB3FL'
"Size": book_data.uncompressed_size, download_urls.append(
"Url": get_download_url_for_book(book, book_data.format), {
# The Kobo forma accepts platforms: (Generic, Android) "Format": kobo_format,
"Platform": "Generic", "Size": book_data.uncompressed_size,
# "DrmType": "None", # Not required "Url": get_download_url_for_book(book.id, book_data.format),
} # The Kobo forma accepts platforms: (Generic, Android)
) "Platform": "Generic",
# "DrmType": "None", # Not required
}
)
except (zipfile.BadZipfile, FileNotFoundError) as e:
log.error(e)
book_uuid = book.uuid book_uuid = book.uuid
metadata = { metadata = {
@ -515,7 +500,7 @@ def get_metadata(book):
@requires_kobo_auth @requires_kobo_auth
# Creates a Shelf with the given items, and returns the shelf's uuid. # Creates a Shelf with the given items, and returns the shelf's uuid.
def HandleTagCreate(): def HandleTagCreate():
# catch delete requests, otherwise the are handled in the book delete handler # catch delete requests, otherwise they are handled in the book delete handler
if request.method == "DELETE": if request.method == "DELETE":
abort(405) abort(405)
name, items = None, None name, items = None, None
@ -709,20 +694,12 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
}) })
extra_filters.append(ub.Shelf.kobo_sync) extra_filters.append(ub.Shelf.kobo_sync)
if sqlalchemy_version2: shelflist = ub.session.query(ub.Shelf).outerjoin(ub.BookShelf).filter(
shelflist = ub.session.execute(select(ub.Shelf).outerjoin(ub.BookShelf).filter( or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified, func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified), ub.Shelf.user_id == current_user.id,
ub.Shelf.user_id == current_user.id, *extra_filters
*extra_filters ).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())).columns(ub.Shelf)
else:
shelflist = ub.session.query(ub.Shelf).outerjoin(ub.BookShelf).filter(
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
ub.Shelf.user_id == current_user.id,
*extra_filters
).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):
@ -759,7 +736,7 @@ def create_kobo_tag(shelf):
for book_shelf in shelf.books: for book_shelf in shelf.books:
book = calibre_db.get_book(book_shelf.book_id) book = calibre_db.get_book(book_shelf.book_id)
if not book: if not book:
log.info(u"Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id) log.info("Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id)
continue continue
tag["Items"].append( tag["Items"].append(
{ {
@ -776,7 +753,7 @@ def create_kobo_tag(shelf):
def HandleStateRequest(book_uuid): def HandleStateRequest(book_uuid):
book = calibre_db.get_book_by_uuid(book_uuid) book = calibre_db.get_book_by_uuid(book_uuid)
if not book or not book.data: if not book or not book.data:
log.info(u"Book %s not found in database", book_uuid) log.info("Book %s not found in database", book_uuid)
return redirect_or_proxy_request() return redirect_or_proxy_request()
kobo_reading_state = get_or_create_reading_state(book.id) kobo_reading_state = get_or_create_reading_state(book.id)
@ -923,20 +900,26 @@ def get_current_bookmark_response(current_bookmark):
@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_uuid, resolution=COVER_THUMBNAIL_SMALL) try:
if not book_cover: resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
if config.config_kobo_proxy: except ValueError:
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid) log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
return redirect(KOBO_IMAGEHOST_URL + resolution = COVER_THUMBNAIL_SMALL
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid, book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=resolution)
width=width, if book_cover:
height=height), 307) log.debug("Serving local cover image of book %s" % book_uuid)
else: return book_cover
log.debug("Cover for unknown book: %s requested" % book_uuid)
# additional proxy request make no sense, -> direct return if not config.config_kobo_proxy:
return make_response(jsonify({})) log.debug("Returning 404 for cover image of unknown book %s" % book_uuid)
log.debug("Cover request received for book %s" % book_uuid) # additional proxy request make no sense, -> direct return
return book_cover return abort(404)
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
return redirect(KOBO_IMAGEHOST_URL +
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
width=width,
height=height), 307)
@kobo.route("") @kobo.route("")
@ -951,7 +934,7 @@ def HandleBookDeletionRequest(book_uuid):
log.info("Kobo book delete request received for book %s" % book_uuid) log.info("Kobo book delete request received for book %s" % book_uuid)
book = calibre_db.get_book_by_uuid(book_uuid) book = calibre_db.get_book_by_uuid(book_uuid)
if not book: if not book:
log.info(u"Book %s not found in database", book_uuid) log.info("Book %s not found in database", book_uuid)
return redirect_or_proxy_request() return redirect_or_proxy_request()
book_id = book.id book_id = book.id
@ -976,6 +959,7 @@ def HandleUnimplementedRequest(dummy=None):
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"]) @kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"]) @kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"]) @kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
@kobo.route("/v1/assets", methods=["GET"])
def HandleUserRequest(dummy=None): def HandleUserRequest(dummy=None):
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url) log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
return redirect_or_proxy_request() return redirect_or_proxy_request()
@ -1034,7 +1018,7 @@ def make_calibre_web_auth_response():
"RefreshToken": RefreshToken, "RefreshToken": RefreshToken,
"TokenType": "Bearer", "TokenType": "Bearer",
"TrackingId": str(uuid.uuid4()), "TrackingId": str(uuid.uuid4()),
"UserKey": content['UserKey'], "UserKey": content.get('UserKey',""),
} }
) )
) )

View File

@ -64,11 +64,12 @@ from datetime import datetime
from os import urandom from os import urandom
from functools import wraps from functools import wraps
from flask import g, Blueprint, url_for, abort, request from flask import g, Blueprint, abort, request
from flask_login import login_user, current_user, login_required from flask_login import login_user, current_user, login_required
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template from .render_template import render_title_template
log = logger.create() log = logger.create()
@ -112,7 +113,7 @@ def generate_auth_token(user_id):
return render_title_template( return render_title_template(
"generate_kobo_auth_url.html", "generate_kobo_auth_url.html",
title=_(u"Kobo Setup"), title=_("Kobo Setup"),
auth_token=auth_token.auth_token, auth_token=auth_token.auth_token,
warning = warning warning = warning
) )
@ -151,6 +152,10 @@ def requires_kobo_auth(f):
def inner(*args, **kwargs): def inner(*args, **kwargs):
auth_token = get_auth_token() auth_token = get_auth_token()
if auth_token is not None: if auth_token is not None:
try:
limiter.check()
except RateLimitExceeded:
return abort(429)
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)
@ -159,7 +164,8 @@ def requires_kobo_auth(f):
) )
if user is not None: if user is not None:
login_user(user) login_user(user)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return f(*args, **kwargs) return f(*args, **kwargs)
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

View File

@ -150,7 +150,7 @@ def setup(log_file, log_level=None):
else: else:
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
except IOError: except (IOError, PermissionError):
if log_file == DEFAULT_LOG_FILE: if log_file == DEFAULT_LOG_FILE:
raise raise
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
@ -177,7 +177,7 @@ def create_access_log(log_file, log_name, formatter):
access_log.setLevel(logging.INFO) access_log.setLevel(logging.INFO)
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
except IOError: except (IOError, PermissionError):
if log_file == DEFAULT_ACCESS_LOG: if log_file == DEFAULT_ACCESS_LOG:
raise raise
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')

View File

@ -18,9 +18,14 @@
import sys import sys
from . import create_app from . import create_app, limiter
from .jinjia import jinjia from .jinjia import jinjia
from .remotelogin import remotelogin from .remotelogin import remotelogin
from flask import request
def request_username():
return request.authorization.username
def main(): def main():
app = create_app() app = create_app()
@ -39,6 +44,7 @@ def main():
try: try:
from .kobo import kobo, get_kobo_activated from .kobo import kobo, get_kobo_activated
from .kobo_auth import kobo_auth from .kobo_auth import kobo_auth
from flask_limiter.util import get_remote_address
kobo_available = get_kobo_activated() kobo_available = get_kobo_activated()
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator) except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
kobo_available = False kobo_available = False
@ -56,6 +62,7 @@ def main():
app.register_blueprint(tasks) app.register_blueprint(tasks)
app.register_blueprint(web) app.register_blueprint(web)
app.register_blueprint(opds) app.register_blueprint(opds)
limiter.limit("3/minute",key_func=request_username)(opds)
app.register_blueprint(jinjia) app.register_blueprint(jinjia)
app.register_blueprint(about) app.register_blueprint(about)
app.register_blueprint(shelf) app.register_blueprint(shelf)
@ -67,6 +74,7 @@ def main():
if kobo_available: if kobo_available:
app.register_blueprint(kobo) app.register_blueprint(kobo)
app.register_blueprint(kobo_auth) app.register_blueprint(kobo_auth)
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()

View File

@ -63,11 +63,11 @@ class Amazon(Metadata):
r.raise_for_status() r.raise_for_status()
except Exception as ex: except Exception as ex:
log.warning(ex) log.warning(ex)
return return None
long_soup = BS(r.text, "lxml") #~4sec :/ long_soup = BS(r.text, "lxml") #~4sec :/
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"}) soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
if soup2 is None: if soup2 is None:
return return None
try: try:
match = MetaRecord( match = MetaRecord(
title = "", title = "",
@ -98,7 +98,7 @@ class Amazon(Metadata):
try: try:
match.authors = [next( match.authors = [next(
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"), filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
x.findAll(text=True))).strip() x.findAll(string=True))).strip()
for x in soup2.findAll("span", attrs={"class": "author"})] for x in soup2.findAll("span", attrs={"class": "author"})]
except (AttributeError, TypeError, StopIteration): except (AttributeError, TypeError, StopIteration):
match.authors = "" match.authors = ""
@ -115,7 +115,7 @@ class Amazon(Metadata):
return match, index return match, index
except Exception as e: except Exception as e:
log.error_or_exception(e) log.error_or_exception(e)
return return None
val = list() val = list()
if self.active: if self.active:
@ -127,10 +127,10 @@ class Amazon(Metadata):
results.raise_for_status() results.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
log.error_or_exception(e) log.error_or_exception(e)
return None return []
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
return None return []
soup = BS(results.text, 'html.parser') soup = BS(results.text, 'html.parser')
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in 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"})] soup.findAll("div", attrs={"data-component-type": "s-search-result"})]

View File

@ -43,7 +43,8 @@ class Douban(Metadata):
__id__ = "douban" __id__ = "douban"
DESCRIPTION = "豆瓣" DESCRIPTION = "豆瓣"
META_URL = "https://book.douban.com/" META_URL = "https://book.douban.com/"
SEARCH_URL = "https://www.douban.com/j/search" SEARCH_JSON_URL = "https://www.douban.com/j/search"
SEARCH_URL = "https://www.douban.com/search"
ID_PATTERN = re.compile(r"sid: (?P<id>\d+),") ID_PATTERN = re.compile(r"sid: (?P<id>\d+),")
AUTHORS_PATTERN = re.compile(r"作者|译者") AUTHORS_PATTERN = re.compile(r"作者|译者")
@ -52,6 +53,7 @@ class Douban(Metadata):
PUBLISHED_DATE_PATTERN = re.compile(r"出版年") PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
SERIES_PATTERN = re.compile(r"丛书") SERIES_PATTERN = re.compile(r"丛书")
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号") IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
CRITERIA_PATTERN = re.compile("criteria = '(.+)'")
TITTLE_XPATH = "//span[@property='v:itemreviewed']" TITTLE_XPATH = "//span[@property='v:itemreviewed']"
COVER_XPATH = "//a[@class='nbg']" COVER_XPATH = "//a[@class='nbg']"
@ -63,56 +65,90 @@ class Douban(Metadata):
session = requests.Session() session = requests.Session()
session.headers = { session.headers = {
'user-agent': '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', '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( def search(self,
self, query: str, generic_cover: str = "", locale: str = "en" query: str,
) -> Optional[List[MetaRecord]]: generic_cover: str = "",
locale: str = "en") -> List[MetaRecord]:
val = []
if self.active: if self.active:
log.debug(f"starting search {query} on douban") log.debug(f"start searching {query} on douban")
if title_tokens := list( if title_tokens := list(
self.get_title_tokens(query, strip_joiners=False) self.get_title_tokens(query, strip_joiners=False)):
):
query = "+".join(title_tokens) query = "+".join(title_tokens)
try: book_id_list = self._get_book_id_list_from_html(query)
r = self.session.get(
self.SEARCH_URL, params={"cat": 1001, "q": query}
)
r.raise_for_status()
except Exception as e: if not book_id_list:
log.warning(e) log.debug("No search results in Douban")
return None
results = r.json()
if results["total"] == 0:
return [] return []
book_id_list = [ with futures.ThreadPoolExecutor(
self.ID_PATTERN.search(item).group("id") max_workers=5, thread_name_prefix='douban') as executor:
for item in results["items"][:10] if self.ID_PATTERN.search(item)
]
with futures.ThreadPoolExecutor(max_workers=5) as executor:
fut = [ fut = [
executor.submit(self._parse_single_book, book_id, generic_cover) executor.submit(self._parse_single_book, book_id,
for book_id in book_id_list generic_cover) for book_id in book_id_list
] ]
val = [ val = [
future.result() future.result() for future in futures.as_completed(fut)
for future in futures.as_completed(fut) if future.result() if future.result()
] ]
return val return val
def _parse_single_book( def _get_book_id_list_from_html(self, query: str) -> List[str]:
self, id: str, generic_cover: str = "" try:
) -> Optional[MetaRecord]: r = self.session.get(self.SEARCH_URL,
params={
"cat": 1001,
"q": query
})
r.raise_for_status()
except Exception as e:
log.warning(e)
return []
html = etree.HTML(r.content.decode("utf8"))
result_list = html.xpath(self.COVER_XPATH)
return [
self.ID_PATTERN.search(item.get("onclick")).group("id")
for item in result_list[:10]
if self.ID_PATTERN.search(item.get("onclick"))
]
def _get_book_id_list_from_json(self, query: str) -> List[str]:
try:
r = self.session.get(self.SEARCH_JSON_URL,
params={
"cat": 1001,
"q": query
})
r.raise_for_status()
except Exception as e:
log.warning(e)
return []
results = r.json()
if results["total"] == 0:
return []
return [
self.ID_PATTERN.search(item).group("id")
for item in results["items"][:10] if self.ID_PATTERN.search(item)
]
def _parse_single_book(self,
id: str,
generic_cover: str = "") -> Optional[MetaRecord]:
url = f"https://book.douban.com/subject/{id}/" url = f"https://book.douban.com/subject/{id}/"
log.debug(f"start parsing {url}")
try: try:
r = self.session.get(url) r = self.session.get(url)
@ -133,10 +169,12 @@ class Douban(Metadata):
), ),
) )
html = etree.HTML(r.content.decode("utf8")) decode_content = r.content.decode("utf8")
html = etree.HTML(decode_content)
match.title = html.xpath(self.TITTLE_XPATH)[0].text match.title = html.xpath(self.TITTLE_XPATH)[0].text
match.cover = html.xpath(self.COVER_XPATH)[0].attrib["href"] or generic_cover match.cover = html.xpath(
self.COVER_XPATH)[0].attrib["href"] or generic_cover
try: try:
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip()) rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
except Exception: except Exception:
@ -146,35 +184,39 @@ class Douban(Metadata):
tag_elements = html.xpath(self.TAGS_XPATH) tag_elements = html.xpath(self.TAGS_XPATH)
if len(tag_elements): if len(tag_elements):
match.tags = [tag_element.text for tag_element in tag_elements] match.tags = [tag_element.text for tag_element in tag_elements]
else:
match.tags = self._get_tags(decode_content)
description_element = html.xpath(self.DESCRIPTION_XPATH) description_element = html.xpath(self.DESCRIPTION_XPATH)
if len(description_element): if len(description_element):
match.description = html2text(etree.tostring( match.description = html2text(
description_element[-1], encoding="utf8").decode("utf8")) etree.tostring(description_element[-1]).decode("utf8"))
info = html.xpath(self.INFO_XPATH) info = html.xpath(self.INFO_XPATH)
for element in info: for element in info:
text = element.text text = element.text
if self.AUTHORS_PATTERN.search(text): if self.AUTHORS_PATTERN.search(text):
next = element.getnext() next_element = element.getnext()
while next is not None and next.tag != "br": while next_element is not None and next_element.tag != "br":
match.authors.append(next.text) match.authors.append(next_element.text)
next = next.getnext() next_element = next_element.getnext()
elif self.PUBLISHER_PATTERN.search(text): elif self.PUBLISHER_PATTERN.search(text):
match.publisher = element.tail.strip() if publisher := element.tail.strip():
match.publisher = publisher
else:
match.publisher = element.getnext().text
elif self.SUBTITLE_PATTERN.search(text): elif self.SUBTITLE_PATTERN.search(text):
match.title = f'{match.title}:' + element.tail.strip() match.title = f'{match.title}:{element.tail.strip()}'
elif self.PUBLISHED_DATE_PATTERN.search(text): elif self.PUBLISHED_DATE_PATTERN.search(text):
match.publishedDate = self._clean_date(element.tail.strip()) match.publishedDate = self._clean_date(element.tail.strip())
elif self.SUBTITLE_PATTERN.search(text): elif self.SERIES_PATTERN.search(text):
match.series = element.getnext().text match.series = element.getnext().text
elif i_type := self.IDENTIFIERS_PATTERN.search(text): elif i_type := self.IDENTIFIERS_PATTERN.search(text):
match.identifiers[i_type.group()] = element.tail.strip() match.identifiers[i_type.group()] = element.tail.strip()
return match return match
def _clean_date(self, date: str) -> str: def _clean_date(self, date: str) -> str:
""" """
Clean up the date string to be in the format YYYY-MM-DD Clean up the date string to be in the format YYYY-MM-DD
@ -194,13 +236,24 @@ class Douban(Metadata):
if date[i].isdigit(): if date[i].isdigit():
digit.append(date[i]) digit.append(date[i])
elif digit: elif digit:
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}") ls.append("".join(digit) if len(digit) ==
2 else f"0{digit[0]}")
digit = [] digit = []
if digit: if digit:
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}") ls.append("".join(digit) if len(digit) ==
2 else f"0{digit[0]}")
moon = ls[0] moon = ls[0]
if len(ls)>1: if len(ls) > 1:
day = ls[1] day = ls[1]
return f"{year}-{moon}-{day}" return f"{year}-{moon}-{day}"
def _get_tags(self, text: str) -> List[str]:
tags = []
if criteria := self.CRITERIA_PATTERN.search(text):
tags.extend(
item.replace('7:', '') for item in criteria.group().split('|')
if item.startswith('7:'))
return tags

View File

@ -19,6 +19,7 @@
# 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 typing import Dict, List, Optional
from urllib.parse import quote from urllib.parse import quote
from datetime import datetime
import requests import requests
@ -81,7 +82,11 @@ class Google(Metadata):
match.description = result["volumeInfo"].get("description", "") match.description = result["volumeInfo"].get("description", "")
match.languages = self._parse_languages(result=result, locale=locale) match.languages = self._parse_languages(result=result, locale=locale)
match.publisher = result["volumeInfo"].get("publisher", "") match.publisher = result["volumeInfo"].get("publisher", "")
match.publishedDate = result["volumeInfo"].get("publishedDate", "") try:
datetime.strptime(result["volumeInfo"].get("publishedDate", ""), "%Y-%m-%d")
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
except ValueError:
match.publishedDate = ""
match.rating = result["volumeInfo"].get("averageRating", 0) match.rating = result["volumeInfo"].get("averageRating", 0)
match.series, match.series_index = "", 1 match.series, match.series_index = "", 1
match.tags = result["volumeInfo"].get("categories", []) match.tags = result["volumeInfo"].get("categories", [])
@ -103,6 +108,13 @@ class Google(Metadata):
def _parse_cover(result: Dict, generic_cover: str) -> str: def _parse_cover(result: Dict, generic_cover: str) -> str:
if result["volumeInfo"].get("imageLinks"): if result["volumeInfo"].get("imageLinks"):
cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"] cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"]
# strip curl in cover
cover_url = cover_url.replace("&edge=curl", "")
# request 800x900 cover image (higher resolution)
cover_url += "&fife=w800-h900"
return cover_url.replace("http://", "https://") return cover_url.replace("http://", "https://")
return generic_cover return generic_cover

View File

@ -102,7 +102,7 @@ class LubimyCzytac(Metadata):
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania" PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()" FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{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()" TAGS = "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
RATING = "//meta[@property='books:rating:value']/@content" RATING = "//meta[@property='books:rating:value']/@content"
COVER = "//meta[@property='og:image']/@content" COVER = "//meta[@property='og:image']/@content"

View File

@ -74,7 +74,7 @@ def register_user_with_oauth(user=None):
if len(all_oauth.keys()) == 0: if len(all_oauth.keys()) == 0:
return return
if user is None: if user is None:
flash(_(u"Register with %(provider)s", provider=", ".join(list(all_oauth.values()))), category="success") flash(_("Register with %(provider)s", provider=", ".join(list(all_oauth.values()))), category="success")
else: else:
for oauth_key in all_oauth.keys(): for oauth_key in all_oauth.keys():
# Find this OAuth token in the database, or create it # Find this OAuth token in the database, or create it
@ -134,8 +134,8 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
# already bind with user, just login # already bind with user, just login
if oauth_entry.user: if oauth_entry.user:
login_user(oauth_entry.user) login_user(oauth_entry.user)
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.name) log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.name), flash(_("Success! You are now logged in as: %(nickname)s", nickname= oauth_entry.user.name),
category="success") category="success")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
else: else:
@ -145,21 +145,21 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
try: try:
ub.session.add(oauth_entry) ub.session.add(oauth_entry)
ub.session.commit() ub.session.commit()
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success") flash(_("Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
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.error_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(_("Login failed, No User Linked With OAuth Account"), category="error")
log.info('Login failed, No User Linked With OAuth Account') log.info('Login failed, No User Linked With OAuth Account')
return redirect(url_for('web.login')) return redirect(url_for('web.login'))
# return redirect(url_for('web.login')) # return redirect(url_for('web.login'))
# if config.config_public_reg: # if config.config_public_reg:
# return redirect(url_for('web.register')) # return redirect(url_for('web.register'))
# else: # else:
# flash(_(u"Public registration is not enabled"), category="error") # flash(_("Public registration is not enabled"), category="error")
# return redirect(url_for(redirect_url)) # return redirect(url_for(redirect_url))
except (NoResultFound, AttributeError): except (NoResultFound, AttributeError):
return redirect(url_for(redirect_url)) return redirect(url_for(redirect_url))
@ -194,15 +194,15 @@ def unlink_oauth(provider):
ub.session.delete(oauth_entry) ub.session.delete(oauth_entry)
ub.session.commit() ub.session.commit()
logout_oauth_user() logout_oauth_user()
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success") flash(_("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.error_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(_("Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
except NoResultFound: except NoResultFound:
log.warning("oauth %s for user %d not found", provider, current_user.id) log.warning("oauth %s for user %d not found", provider, current_user.id)
flash(_(u"Not Linked to %(oauth)s", oauth=provider), category="error") flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
return redirect(url_for('web.profile')) return redirect(url_for('web.profile'))
def generate_oauth_blueprints(): def generate_oauth_blueprints():
@ -258,13 +258,13 @@ if ub.oauth_support:
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint']) @oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
def github_logged_in(blueprint, token): def github_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with GitHub."), category="error") flash(_("Failed to log in with GitHub."), category="error")
log.error("Failed to log in with GitHub") log.error("Failed to log in with GitHub")
return False return False
resp = blueprint.session.get("/user") resp = blueprint.session.get("/user")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from GitHub."), category="error") flash(_("Failed to fetch user info from GitHub."), category="error")
log.error("Failed to fetch user info from GitHub") log.error("Failed to fetch user info from GitHub")
return False return False
@ -276,13 +276,13 @@ if ub.oauth_support:
@oauth_authorized.connect_via(oauthblueprints[1]['blueprint']) @oauth_authorized.connect_via(oauthblueprints[1]['blueprint'])
def google_logged_in(blueprint, token): def google_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with Google."), category="error") flash(_("Failed to log in with Google."), category="error")
log.error("Failed to log in with Google") log.error("Failed to log in with Google")
return False return False
resp = blueprint.session.get("/oauth2/v2/userinfo") resp = blueprint.session.get("/oauth2/v2/userinfo")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from Google."), category="error") flash(_("Failed to fetch user info from Google."), category="error")
log.error("Failed to fetch user info from Google") log.error("Failed to fetch user info from Google")
return False return False
@ -295,8 +295,8 @@ if ub.oauth_support:
@oauth_error.connect_via(oauthblueprints[0]['blueprint']) @oauth_error.connect_via(oauthblueprints[0]['blueprint'])
def github_error(blueprint, error, error_description=None, error_uri=None): def github_error(blueprint, error, error_description=None, error_uri=None):
msg = ( msg = (
u"OAuth error from {name}! " "OAuth error from {name}! "
u"error={error} description={description} uri={uri}" "error={error} description={description} uri={uri}"
).format( ).format(
name=blueprint.name, name=blueprint.name,
error=error, error=error,
@ -308,8 +308,8 @@ if ub.oauth_support:
@oauth_error.connect_via(oauthblueprints[1]['blueprint']) @oauth_error.connect_via(oauthblueprints[1]['blueprint'])
def google_error(blueprint, error, error_description=None, error_uri=None): def google_error(blueprint, error, error_description=None, error_uri=None):
msg = ( msg = (
u"OAuth error from {name}! " "OAuth error from {name}! "
u"error={error} description={description} uri={uri}" "error={error} description={description} uri={uri}"
).format( ).format(
name=blueprint.name, name=blueprint.name,
error=error, error=error,
@ -329,10 +329,10 @@ def github_login():
if account_info.ok: if account_info.ok:
account_info_json = account_info.json() account_info_json = account_info.json()
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github') return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
flash(_(u"GitHub Oauth error, please retry later."), category="error") flash(_("GitHub Oauth error, please retry later."), category="error")
log.error("GitHub Oauth error, please retry later") log.error("GitHub Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e: except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"GitHub Oauth error: {}").format(e), category="error") flash(_("GitHub Oauth error: {}").format(e), category="error")
log.error(e) log.error(e)
return redirect(url_for('web.login')) return redirect(url_for('web.login'))
@ -353,10 +353,10 @@ def google_login():
if resp.ok: if resp.ok:
account_info_json = resp.json() account_info_json = resp.json()
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google') return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
flash(_(u"Google Oauth error, please retry later."), category="error") flash(_("Google Oauth error, please retry later."), category="error")
log.error("Google Oauth error, please retry later") log.error("Google Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e: except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"Google Oauth error: {}").format(e), category="error") flash(_("Google Oauth error: {}").format(e), category="error")
log.error(e) log.error(e)
return redirect(url_for('web.login')) return redirect(url_for('web.login'))

View File

@ -21,41 +21,28 @@
# 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 datetime import datetime
import json
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
from functools import wraps
from flask import Blueprint, request, render_template, Response, g, make_response, abort from flask import Blueprint, request, render_template, make_response, abort, Response
from flask_login import current_user from flask_login import current_user
from flask_babel import get_locale from flask_babel import get_locale
from flask_babel import gettext as _
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 sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.security import check_password_hash
from . import constants, logger, config, db, calibre_db, ub, services, isoLanguages from . import logger, config, db, calibre_db, ub, isoLanguages
from .usermanagement import requires_basic_auth_if_no_ano
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
from .usermanagement import load_user_from_request
from flask_babel import gettext as _
opds = Blueprint('opds', __name__) opds = Blueprint('opds', __name__)
log = logger.create() log = logger.create()
def requires_basic_auth_if_no_ano(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if config.config_anonbrowse != 1:
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
if config.config_login_type == constants.LOGIN_LDAP and services.ldap and config.config_anonbrowse != 1:
return services.ldap.basic_auth_required(f)
return decorated
@opds.route("/opds/") @opds.route("/opds/")
@opds.route("/opds") @opds.route("/opds")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
@ -69,7 +56,7 @@ def feed_osd():
return render_xml_template('osd.xml', lang='en-EN') return render_xml_template('osd.xml', lang='en-EN')
@opds.route("/opds/search", defaults={'query': ""}) # @opds.route("/opds/search", defaults={'query': ""})
@opds.route("/opds/search/<path:query>") @opds.route("/opds/search/<path:query>")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_cc_search(query): def feed_cc_search(query):
@ -328,7 +315,7 @@ def feed_format(book_id):
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_languagesindex(): def feed_languagesindex():
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
if current_user.filter_language() == u"all": if current_user.filter_language() == "all":
languages = calibre_db.speaking_language() languages = calibre_db.speaking_language()
else: else:
languages = calibre_db.session.query(db.Languages).filter( languages = calibre_db.session.query(db.Languages).filter(
@ -355,7 +342,8 @@ def feed_languages(book_id):
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_shelfindex(): def feed_shelfindex():
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
shelf = g.shelves_access shelf = ub.session.query(ub.Shelf).filter(
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
number = len(shelf) number = len(shelf)
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,
number) number)
@ -402,11 +390,7 @@ def feed_shelf(book_id):
@opds.route("/opds/download/<book_id>/<book_format>/") @opds.route("/opds/download/<book_id>/<book_format>/")
@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 if not current_user.role_download():
# workaround, loading the user from the request and checking its download rights here
# in case of anonymous browsing user is None
user = load_user_from_request(request) or current_user
if not user.role_download():
return abort(403) return abort(403)
if "Kobo" in request.headers.get('User-Agent'): if "Kobo" in request.headers.get('User-Agent'):
client = "kobo" client = "kobo"
@ -429,6 +413,17 @@ def get_metadata_calibre_companion(uuid, library):
return "" return ""
@opds.route("/opds/stats")
@requires_basic_auth_if_no_ano
def get_database_stats():
stat = dict()
stat['books'] = calibre_db.session.query(db.Books).count()
stat['authors'] = calibre_db.session.query(db.Authors).count()
stat['categories'] = calibre_db.session.query(db.Tags).count()
stat['series'] = calibre_db.session.query(db.Series).count()
return Response(json.dumps(stat), mimetype="application/json")
@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>")
@ -478,27 +473,6 @@ def feed_search(term):
return render_xml_template('feed.xml', searchterm="") 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): def render_xml_template(*args, **kwargs):
# ToDo: return time in current timezone similar to %z # ToDo: return time in current timezone similar to %z

View File

@ -58,8 +58,8 @@ def remote_login():
ub.session.add(auth_token) ub.session.add(auth_token)
ub.session_commit() ub.session_commit()
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true) verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
log.debug(u"Remot Login request with token: %s", auth_token.auth_token) log.debug("Remot Login request with token: %s", auth_token.auth_token)
return render_title_template('remote_login.html', title=_(u"Login"), token=auth_token.auth_token, return render_title_template('remote_login.html', title=_("Login"), token=auth_token.auth_token,
verify_url=verify_url, page="remotelogin") verify_url=verify_url, page="remotelogin")
@ -71,8 +71,8 @@ def verify_token(token):
# Token not found # Token not found
if auth_token is None: if auth_token is None:
flash(_(u"Token not found"), category="error") flash(_("Token not found"), category="error")
log.error(u"Remote Login token not found") log.error("Remote Login token not found")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
# Token expired # Token expired
@ -80,8 +80,8 @@ def verify_token(token):
ub.session.delete(auth_token) ub.session.delete(auth_token)
ub.session_commit() ub.session_commit()
flash(_(u"Token has expired"), category="error") flash(_("Token has expired"), category="error")
log.error(u"Remote Login token expired") log.error("Remote Login token expired")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
# Update token with user information # Update token with user information
@ -89,8 +89,8 @@ def verify_token(token):
auth_token.verified = True auth_token.verified = True
ub.session_commit() ub.session_commit()
flash(_(u"Success! Please return to your device"), category="success") flash(_("Success! Please return to your device"), category="success")
log.debug(u"Remote Login token for userid %s verified", auth_token.user_id) log.debug("Remote Login token for userid %s verified", auth_token.user_id)
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -105,7 +105,7 @@ def token_verified():
# Token not found # Token not found
if auth_token is None: if auth_token is None:
data['status'] = 'error' data['status'] = 'error'
data['message'] = _(u"Token not found") data['message'] = _("Token not found")
# Token expired # Token expired
elif datetime.now() > auth_token.expiration: elif datetime.now() > auth_token.expiration:
@ -113,7 +113,7 @@ def token_verified():
ub.session_commit() ub.session_commit()
data['status'] = 'error' data['status'] = 'error'
data['message'] = _(u"Token has expired") data['message'] = _("Token has expired")
elif not auth_token.verified: elif not auth_token.verified:
data['status'] = 'not_verified' data['status'] = 'not_verified'
@ -126,8 +126,8 @@ def token_verified():
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name)) ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
data['status'] = 'success' data['status'] = 'success'
log.debug(u"Remote Login for userid %s succeeded", user.id) log.debug("Remote Login for userid %s succeeded", user.id)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success") flash(_("Success! You are now logged in as: %(nickname)s", nickname=user.name), category="success")
response = make_response(json.dumps(data, ensure_ascii=False)) response = make_response(json.dumps(data, ensure_ascii=False))
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"

View File

@ -20,11 +20,13 @@ from flask import render_template, g, abort, request
from flask_babel import gettext as _ from flask_babel import gettext as _
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from flask_login import current_user from flask_login import current_user
from sqlalchemy.sql.expression import or_
from . import config, constants, logger from . import config, constants, logger, ub
from .ub import User from .ub import User
log = logger.create() log = logger.create()
def get_sidebar_config(kwargs=None): def get_sidebar_config(kwargs=None):
@ -45,12 +47,12 @@ def get_sidebar_config(kwargs=None):
"show_text": _('Show Hot Books'), "config_show": True}) "show_text": _('Show Hot Books'), "config_show": True})
if current_user.role_admin(): if current_user.role_admin():
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list', sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'), "page": "download", "show_text": _('Show Downloaded Books'),
"config_show": content}) "config_show": content})
else: else:
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'), "page": "download", "show_text": _('Show Downloaded Books'),
"config_show": content}) "config_show": content})
sidebar.append( sidebar.append(
@ -58,47 +60,50 @@ def get_sidebar_config(kwargs=None):
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated", "visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
"show_text": _('Show Top Rated Books'), "config_show": True}) "show_text": _('Show Top Rated Books'), "config_show": True})
sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read", sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read",
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous),
"page": "read", "show_text": _('Show read and unread'), "config_show": content}) "page": "read", "show_text": _('Show Read and Unread'), "config_show": content})
sidebar.append( sidebar.append(
{"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread", {"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread",
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "page": "unread", "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous), "page": "unread",
"show_text": _('Show unread'), "config_show": False}) "show_text": _('Show unread'), "config_show": False})
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
"show_text": _('Show Random Books'), "config_show": True}) "show_text": _('Show Random Books'), "config_show": True})
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
"show_text": _('Show category selection'), "config_show": True}) "show_text": _('Show Category Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie", sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", "visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
"show_text": _('Show series selection'), "config_show": True}) "show_text": _('Show Series Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author", sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", "visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
"show_text": _('Show author selection'), "config_show": True}) "show_text": _('Show Author Section'), "config_show": True})
sidebar.append( sidebar.append(
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher", {"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", "visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
"show_text": _('Show publisher selection'), "config_show":True}) "show_text": _('Show Publisher Section'), "config_show":True})
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang", sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'), "visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
"page": "language", "page": "language",
"show_text": _('Show language selection'), "config_show": True}) "show_text": _('Show Language Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate", sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
"visibility": constants.SIDEBAR_RATING, 'public': True, "visibility": constants.SIDEBAR_RATING, 'public': True,
"page": "rating", "show_text": _('Show ratings selection'), "config_show": True}) "page": "rating", "show_text": _('Show Ratings Section'), "config_show": True})
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format", sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
"visibility": constants.SIDEBAR_FORMAT, 'public': True, "visibility": constants.SIDEBAR_FORMAT, 'public': True,
"page": "format", "show_text": _('Show file formats selection'), "config_show": True}) "page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
sidebar.append( sidebar.append(
{"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 current_user.is_anonymous), "page": "archived",
"show_text": _('Show archived books'), "config_show": content}) "show_text": _('Show Archived Books'), "config_show": content})
if not simple: if not simple:
sidebar.append( sidebar.append(
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list", {"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list", "visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list",
"show_text": _('Show Books List'), "config_show": content}) "show_text": _('Show Books List'), "config_show": content})
g.shelves_access = ub.session.query(ub.Shelf).filter(
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
return sidebar, simple return sidebar, simple

View File

@ -19,7 +19,7 @@
import datetime import datetime
from . import config, constants from . import config, constants
from .services.background_scheduler import BackgroundScheduler, use_APScheduler from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
from .tasks.database import TaskReconnectDatabase from .tasks.database import TaskReconnectDatabase
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
from .services.worker import WorkerThread from .services.worker import WorkerThread
@ -27,13 +27,12 @@ from .tasks.metadata_backup import TaskBackupMetadata
def get_scheduled_tasks(reconnect=True): def get_scheduled_tasks(reconnect=True):
tasks = list() tasks = list()
# config.schedule_reconnect or # Reconnect Calibre database (metadata.db) based on config.schedule_reconnect
# Reconnect Calibre database (metadata.db)
if reconnect: if reconnect:
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False]) tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
# ToDo make configurable. Generate metadata.opf file for each changed book # Generate metadata.opf file for each changed book
if False: if config.schedule_metadata_backup:
tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False]) tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False])
# Generate all missing book cover thumbnails # Generate all missing book cover thumbnails
@ -66,10 +65,10 @@ def register_scheduled_tasks(reconnect=True):
duration = config.schedule_duration duration = config.schedule_duration
# Register scheduled tasks # Register scheduled tasks
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger='cron', hour=start) scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start))
end_time = calclulate_end_time(start, duration) end_time = calclulate_end_time(start, duration)
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', name="end scheduled task", hour=end_time.hour, scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute),
minute=end_time.minute) name="end scheduled task")
# Kick-off tasks, if they should currently be running # Kick-off tasks, if they should currently be running
if should_task_be_running(start, duration): if should_task_be_running(start, duration):

View File

@ -45,7 +45,7 @@ def simple_search():
return render_title_template('search.html', return render_title_template('search.html',
searchterm="", searchterm="",
result_count=0, result_count=0,
title=_(u"Search"), title=_("Search"),
page="search") page="search")
@ -185,18 +185,18 @@ def extend_search_term(searchterm,
searchterm.extend((author_name.replace('|', ','), book_title, publisher)) searchterm.extend((author_name.replace('|', ','), book_title, publisher))
if pub_start: if pub_start:
try: try:
searchterm.extend([_(u"Published after ") + searchterm.extend([_("Published after ") +
format_date(datetime.strptime(pub_start, "%Y-%m-%d"), format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
format='medium')]) format='medium')])
except ValueError: except ValueError:
pub_start = u"" pub_start = ""
if pub_end: if pub_end:
try: try:
searchterm.extend([_(u"Published before ") + searchterm.extend([_("Published before ") +
format_date(datetime.strptime(pub_end, "%Y-%m-%d"), format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
format='medium')]) format='medium')])
except ValueError: except ValueError:
pub_end = u"" pub_end = ""
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf} elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
for key, db_element in elements.items(): for key, db_element in elements.items():
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all() tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
@ -214,11 +214,11 @@ def extend_search_term(searchterm,
language_names = calibre_db.speaking_language(language_names) language_names = calibre_db.speaking_language(language_names)
searchterm.extend(language.name for language in language_names) searchterm.extend(language.name for language in language_names)
if rating_high: if rating_high:
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)]) searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
if rating_low: if rating_low:
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)]) searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
if read_status: if read_status != "Any":
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)]) searchterm.extend([_("Read Status = '%(status)s'", status=read_status)])
searchterm.extend(ext for ext in tags['include_extension']) searchterm.extend(ext for ext in tags['include_extension'])
searchterm.extend(ext for ext in tags['exclude_extension']) searchterm.extend(ext for ext in tags['exclude_extension'])
# handle custom columns # handle custom columns
@ -267,23 +267,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
column_start = term.get('custom_column_' + str(c.id) + '_start') column_start = term.get('custom_column_' + str(c.id) + '_start')
column_end = term.get('custom_column_' + str(c.id) + '_end') column_end = term.get('custom_column_' + str(c.id) + '_end')
if column_start: if column_start:
search_term.extend([u"{} >= {}".format(c.name, search_term.extend(["{} >= {}".format(c.name,
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(), format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
format='medium') format='medium')
)]) )])
cc_present = True cc_present = True
if column_end: if column_end:
search_term.extend([u"{} <= {}".format(c.name, search_term.extend(["{} <= {}".format(c.name,
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(), format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
format='medium') format='medium')
)]) )])
cc_present = True cc_present = True
elif term.get('custom_column_' + str(c.id)): elif term.get('custom_column_' + str(c.id)):
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))]) search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True cc_present = True
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \ 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: or rating_high or description or cc_present or read_status != "Any":
search_term, pub_start, pub_end = extend_search_term(search_term, search_term, pub_start, pub_end = extend_search_term(search_term,
author_name, author_name,
book_title, book_title,
@ -302,7 +302,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start)) q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
if pub_end: if pub_end:
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end)) q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
q = q.filter(adv_search_read_status(read_status)) if read_status != "Any":
q = q.filter(adv_search_read_status(read_status))
if publisher: if publisher:
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + 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_tag(q, tags['include_tag'], tags['exclude_tag'])
@ -339,7 +340,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
pagination=pagination, pagination=pagination,
entries=entries, entries=entries,
result_count=result_count, result_count=result_count,
title=_(u"Advanced Search"), page="advsearch", title=_("Advanced Search"), page="advsearch",
order=order[1]) order=order[1])
@ -366,22 +367,28 @@ def render_prepare_search_form(cc):
.filter(calibre_db.common_filters()) \ .filter(calibre_db.common_filters()) \
.group_by(db.Data.format)\ .group_by(db.Data.format)\
.order_by(db.Data.format).all() .order_by(db.Data.format).all()
if current_user.filter_language() == u"all": if current_user.filter_language() == "all":
languages = calibre_db.speaking_language() languages = calibre_db.speaking_language()
else: else:
languages = None languages = None
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions, 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") series=series,shelves=shelves, title=_("Advanced Search"), cc=cc, page="advsearch")
def render_search_results(term, offset=None, order=None, limit=None): 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 if term:
entries, result_count, pagination = calibre_db.get_search_results(term, join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
config, entries, result_count, pagination = calibre_db.get_search_results(term,
offset, config,
order, offset,
limit, order,
*join) limit,
*join)
else:
entries = list()
order = [None, None]
pagination = result_count = None
return render_title_template('search.html', return render_title_template('search.html',
searchterm=term, searchterm=term,
pagination=pagination, pagination=pagination,
@ -389,7 +396,7 @@ def render_search_results(term, offset=None, order=None, limit=None):
adv_searchterm=term, adv_searchterm=term,
entries=entries, entries=entries,
result_count=result_count, result_count=result_count,
title=_(u"Search"), title=_("Search"),
page="search", page="search",
order=order[1]) order=order[1])

View File

@ -21,12 +21,12 @@ import os
import errno import errno
import signal import signal
import socket import socket
import subprocess # nosec
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from .gevent_wsgi import MyWSGIHandler from .gevent_wsgi import MyWSGIHandler
from gevent.pool import Pool from gevent.pool import Pool
from gevent.socket import socket as GeventSocket
from gevent import __version__ as _version from gevent import __version__ as _version
from greenlet import GreenletExit from greenlet import GreenletExit
import ssl import ssl
@ -36,6 +36,7 @@ except ImportError:
from .tornado_wsgi import MyWSGIContainer 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.netutil import bind_unix_socket
from tornado import version as _version from tornado import version as _version
VERSION = 'Tornado ' + _version VERSION = 'Tornado ' + _version
_GEVENT = False _GEVENT = False
@ -95,7 +96,12 @@ class WebServer(object):
log.warning('Cert path: %s', certfile_path) log.warning('Cert path: %s', certfile_path)
log.warning('Key path: %s', keyfile_path) log.warning('Key path: %s', keyfile_path)
def _make_gevent_unix_socket(self, socket_file): def _make_gevent_socket_activated(self):
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
SD_LISTEN_FDS_START = 3
return GeventSocket(fileno=SD_LISTEN_FDS_START)
def _prepare_unix_socket(self, socket_file):
# the socket file must not exist prior to bind() # the socket file must not exist prior to bind()
if os.path.exists(socket_file): if os.path.exists(socket_file):
# avoid nuking regular files and symbolic links (could be a mistype or security issue) # avoid nuking regular files and symbolic links (could be a mistype or security issue)
@ -103,35 +109,41 @@ class WebServer(object):
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file) raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
os.remove(socket_file) os.remove(socket_file)
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
self.unix_socket_file = socket_file self.unix_socket_file = socket_file
# ensure current user and group have r/w permissions, no permissions for other users def _make_gevent_listener(self):
# this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver
os.chmod(socket_file, 0o660)
return unix_sock
def _make_gevent_socket(self):
if os.name != 'nt': if os.name != 'nt':
socket_activated = os.environ.get("LISTEN_FDS")
if socket_activated:
sock = self._make_gevent_socket_activated()
sock_info = sock.getsockname()
return sock, "systemd-socket:" + _readable_listen_address(sock_info[0], sock_info[1])
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET") unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
if unix_socket_file: if unix_socket_file:
return self._make_gevent_unix_socket(unix_socket_file), "unix:" + unix_socket_file self._prepare_unix_socket(unix_socket_file)
unix_sock = WSGIServer.get_listener(unix_socket_file, family=socket.AF_UNIX)
# ensure current user and group have r/w permissions, no permissions for other users
# this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver
os.chmod(unix_socket_file, 0o660)
return unix_sock, "unix:" + unix_socket_file
if self.listen_address: if self.listen_address:
return (self.listen_address, self.listen_port), None return ((self.listen_address, self.listen_port),
_readable_listen_address(self.listen_address, self.listen_port))
if os.name == 'nt': if os.name == 'nt':
self.listen_address = '0.0.0.0' self.listen_address = '0.0.0.0'
return (self.listen_address, self.listen_port), None return ((self.listen_address, self.listen_port),
_readable_listen_address(self.listen_address, self.listen_port))
try: try:
address = ('::', self.listen_port) address = ('::', self.listen_port)
sock = WSGIServer.get_listener(address, family=socket.AF_INET6) sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
except socket.error as ex: except socket.error as ex:
log.error('%s', ex) log.error('%s', ex)
log.warning('Unable to listen on "", trying on IPv4 only...') log.warning('Unable to listen on {}, trying on IPv4 only...'.format(address))
address = ('', self.listen_port) address = ('', self.listen_port)
sock = WSGIServer.get_listener(address, family=socket.AF_INET) sock = WSGIServer.get_listener(address, family=socket.AF_INET)
@ -152,7 +164,7 @@ class WebServer(object):
# The value of __package__ indicates how Python was called. It may # The value of __package__ indicates how Python was called. It may
# not exist if a setuptools script is installed as an egg. It may be # not exist if a setuptools script is installed as an egg. It may be
# set incorrectly for entry points created with pip on Windows. # set incorrectly for entry points created with pip on Windows.
if getattr(__main__, "__package__", None) is None or ( if getattr(__main__, "__package__", "") in ["", None] or (
os.name == "nt" os.name == "nt"
and __main__.__package__ == "" and __main__.__package__ == ""
and not os.path.exists(py_script) and not os.path.exists(py_script)
@ -193,15 +205,15 @@ class WebServer(object):
rv.extend(("-m", py_module.lstrip("."))) rv.extend(("-m", py_module.lstrip(".")))
rv.extend(args) rv.extend(args)
if os.name == 'nt':
rv = ['"{}"'.format(a) for a in rv]
return rv return rv
def _start_gevent(self): def _start_gevent(self):
ssl_args = self.ssl_args or {} ssl_args = self.ssl_args or {}
try: try:
sock, output = self._make_gevent_socket() sock, output = self._make_gevent_listener()
if output is None:
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, handler_class=MyWSGIHandler, self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
error_log=log, error_log=log,
@ -226,17 +238,42 @@ class WebServer(object):
if os.name == 'nt' and sys.version_info > (3, 7): if os.name == 'nt' and sys.version_info > (3, 7):
import asyncio import asyncio
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)) try:
# Max Buffersize set to 200MB
http_server = HTTPServer(MyWSGIContainer(self.app),
max_buffer_size=209700000,
ssl_options=self.ssl_args)
# Max Buffersize set to 200MB unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
http_server = HTTPServer(MyWSGIContainer(self.app), if os.environ.get("LISTEN_FDS") and os.name != 'nt':
max_buffer_size=209700000, SD_LISTEN_FDS_START = 3
ssl_options=self.ssl_args) sock = socket.socket(fileno=SD_LISTEN_FDS_START)
http_server.listen(self.listen_port, self.listen_address) http_server.add_socket(sock)
self.wsgiserver = IOLoop.current() sock.setblocking(0)
self.wsgiserver.start() socket_name =sock.getsockname()
# wait for stop signal output = "systemd-socket:" + _readable_listen_address(socket_name[0], socket_name[1])
self.wsgiserver.close(True) elif unix_socket_file and os.name != 'nt':
self._prepare_unix_socket(unix_socket_file)
output = "unix:" + unix_socket_file
unix_socket = bind_unix_socket(self.unix_socket_file)
http_server.add_socket(unix_socket)
# ensure current user and group have r/w permissions, no permissions for other users
# this way the socket can be shared in a semi-secure manner
# between the user running calibre-web and the user running the fronting webserver
os.chmod(self.unix_socket_file, 0o660)
else:
output = _readable_listen_address(self.listen_address, self.listen_port)
http_server.listen(self.listen_port, self.listen_address)
log.info('Starting Tornado server on %s', output)
self.wsgiserver = IOLoop.current()
self.wsgiserver.start()
# wait for stop signal
self.wsgiserver.close(True)
finally:
if self.unix_socket_file:
os.remove(self.unix_socket_file)
self.unix_socket_file = None
def start(self): def start(self):
try: try:
@ -262,9 +299,16 @@ class WebServer(object):
log.info("Performing restart of Calibre-Web") log.info("Performing restart of Calibre-Web")
args = self._get_args_for_reloading() args = self._get_args_for_reloading()
subprocess.call(args, close_fds=True) # nosec os.execv(args[0].lstrip('"').rstrip('"'), args)
return True return True
@staticmethod
def shutdown_scheduler():
from .services.background_scheduler import BackgroundScheduler
scheduler = BackgroundScheduler()
if scheduler:
scheduler.scheduler.shutdown()
def _killServer(self, __, ___): def _killServer(self, __, ___):
self.stop() self.stop()
@ -273,9 +317,13 @@ class WebServer(object):
updater_thread.stop() updater_thread.stop()
log.info("webserver stop (restart=%s)", restart) log.info("webserver stop (restart=%s)", restart)
self.shutdown_scheduler()
self.restart = restart self.restart = restart
if self.wsgiserver: if self.wsgiserver:
if _GEVENT: if _GEVENT:
self.wsgiserver.close() self.wsgiserver.close()
else: else:
self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop) if restart:
self.wsgiserver.call_later(1.0, self.wsgiserver.stop)
else:
self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop)

View File

@ -19,11 +19,9 @@
import sys import sys
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from jsonschema import validate, exceptions, __version__ from jsonschema import validate, exceptions
from datetime import datetime from datetime import datetime
from urllib.parse import unquote
from flask import json from flask import json
from .. import logger from .. import logger

View File

@ -23,6 +23,8 @@ from .worker import WorkerThread
try: try:
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
use_APScheduler = True use_APScheduler = True
except (ImportError, RuntimeError) as e: except (ImportError, RuntimeError) as e:
use_APScheduler = False use_APScheduler = False
@ -43,35 +45,33 @@ class BackgroundScheduler:
cls.scheduler = BScheduler() cls.scheduler = BScheduler()
cls.scheduler.start() cls.scheduler.start()
atexit.register(lambda: cls.scheduler.shutdown())
return cls._instance return cls._instance
def schedule(self, func, trigger, name=None, **trigger_args): def schedule(self, func, trigger, name=None):
if use_APScheduler: if use_APScheduler:
return self.scheduler.add_job(func=func, trigger=trigger, name=name, **trigger_args) return self.scheduler.add_job(func=func, trigger=trigger, name=name)
# Expects a lambda expression for the task # Expects a lambda expression for the task
def schedule_task(self, task, user=None, name=None, hidden=False, trigger='cron', **trigger_args): def schedule_task(self, task, user=None, name=None, hidden=False, trigger=None):
if use_APScheduler: if use_APScheduler:
def scheduled_task(): def scheduled_task():
worker_task = task() worker_task = task()
worker_task.scheduled = True worker_task.scheduled = True
WorkerThread.add(user, worker_task, hidden=hidden) WorkerThread.add(user, worker_task, hidden=hidden)
return self.schedule(func=scheduled_task, trigger=trigger, name=name, **trigger_args) return self.schedule(func=scheduled_task, trigger=trigger, name=name)
# Expects a list of lambda expressions for the tasks # Expects a list of lambda expressions for the tasks
def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args): def schedule_tasks(self, tasks, user=None, trigger=None):
if use_APScheduler: if use_APScheduler:
for task in tasks: for task in tasks:
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2], **trigger_args) self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2])
# Expects a lambda expression for the task # Expects a lambda expression for the task
def schedule_task_immediately(self, task, user=None, name=None, hidden=False): def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
if use_APScheduler: if use_APScheduler:
def immediate_task(): def immediate_task():
WorkerThread.add(user, task(), hidden) WorkerThread.add(user, task(), hidden)
return self.schedule(func=immediate_task, trigger='date', name=name) return self.schedule(func=immediate_task, trigger=DateTrigger(), name=name)
# Expects a list of lambda expressions for the tasks # Expects a list of lambda expressions for the tasks
def schedule_tasks_immediately(self, tasks, user=None): def schedule_tasks_immediately(self, tasks, user=None):

View File

@ -20,6 +20,7 @@ import base64
from flask_simpleldap import LDAP, LDAPException from flask_simpleldap import LDAP, LDAPException
from flask_simpleldap import ldap as pyLDAP from flask_simpleldap import ldap as pyLDAP
from flask import current_app
from .. import constants, logger from .. import constants, logger
try: try:
@ -28,8 +29,47 @@ except ImportError:
pass pass
log = logger.create() log = logger.create()
_ldap = LDAP()
class LDAPLogger(object):
def write(self, message):
try:
log.debug(message.strip("\n").replace("\n", ""))
except Exception:
log.debug("Logging Error")
class mySimpleLDap(LDAP):
@staticmethod
def init_app(app):
super(mySimpleLDap, mySimpleLDap).init_app(app)
app.config.setdefault('LDAP_LOGLEVEL', 0)
@property
def initialize(self):
"""Initialize a connection to the LDAP server.
:return: LDAP connection object.
"""
try:
log_level = 2 if current_app.config['LDAP_LOGLEVEL'] == logger.logging.DEBUG else 0
conn = pyLDAP.initialize('{0}://{1}:{2}'.format(
current_app.config['LDAP_SCHEMA'],
current_app.config['LDAP_HOST'],
current_app.config['LDAP_PORT']), trace_level=log_level, trace_file=LDAPLogger())
conn.set_option(pyLDAP.OPT_NETWORK_TIMEOUT,
current_app.config['LDAP_TIMEOUT'])
conn = self._set_custom_options(conn)
conn.protocol_version = pyLDAP.VERSION3
if current_app.config['LDAP_USE_TLS']:
conn.start_tls_s()
return conn
except pyLDAP.LDAPError as e:
raise LDAPException(self.error(e.args))
_ldap = mySimpleLDap()
def init_app(app, config): def init_app(app, config):
if config.config_login_type != constants.LOGIN_LDAP: if config.config_login_type != constants.LOGIN_LDAP:
@ -44,15 +84,15 @@ def init_app(app, config):
app.config['LDAP_SCHEMA'] = 'ldap' app.config['LDAP_SCHEMA'] = 'ldap'
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS: if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE: if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if config.config_ldap_serv_password is None: if config.config_ldap_serv_password_e is None:
config.config_ldap_serv_password = '' config.config_ldap_serv_password_e = ''
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password) app.config['LDAP_PASSWORD'] = config.config_ldap_serv_password_e
else: else:
app.config['LDAP_PASSWORD'] = base64.b64decode("") app.config['LDAP_PASSWORD'] = ""
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
else: else:
app.config['LDAP_USERNAME'] = "" app.config['LDAP_USERNAME'] = ""
app.config['LDAP_PASSWORD'] = base64.b64decode("") app.config['LDAP_PASSWORD'] = ""
if bool(config.config_ldap_cert_path): if bool(config.config_ldap_cert_path):
app.config['LDAP_CUSTOM_OPTIONS'].update({ app.config['LDAP_CUSTOM_OPTIONS'].update({
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND, pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,
@ -70,7 +110,7 @@ def init_app(app, config):
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap) app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
app.config['LDAP_LOGLEVEL'] = config.config_log_level
try: try:
_ldap.init_app(app) _ldap.init_app(app)
except ValueError: except ValueError:

View File

@ -46,13 +46,13 @@ def add_to_shelf(shelf_id, book_id):
if shelf is None: if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id) log.error("Invalid shelf specified: %s", shelf_id)
if not xhr: if not xhr:
flash(_(u"Invalid shelf specified"), category="error") flash(_("Invalid shelf specified"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Invalid shelf specified", 400 return "Invalid shelf specified", 400
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
if not xhr: if not xhr:
flash(_(u"Sorry you are not allowed to add a book to that shelf"), category="error") flash(_("Sorry you are not allowed to add a book to that shelf"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Sorry you are not allowed to add a book to the that shelf", 403 return "Sorry you are not allowed to add a book to the that shelf", 403
@ -61,7 +61,7 @@ def add_to_shelf(shelf_id, book_id):
if book_in_shelf: if book_in_shelf:
log.error("Book %s is already part of %s", book_id, shelf) log.error("Book %s is already part of %s", book_id, shelf)
if not xhr: if not xhr:
flash(_(u"Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error") flash(_("Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Book is already part of the shelf: %s" % shelf.name, 400 return "Book is already part of the shelf: %s" % shelf.name, 400
@ -79,14 +79,14 @@ def add_to_shelf(shelf_id, book_id):
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! 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:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not xhr: if not xhr:
log.debug("Book has been added to shelf: {}".format(shelf.name)) log.debug("Book has been added to shelf: {}".format(shelf.name))
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success") flash(_("Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
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:
@ -100,12 +100,12 @@ 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: {}".format(shelf_id)) log.error("Invalid shelf specified: {}".format(shelf_id))
flash(_(u"Invalid shelf specified"), category="error") flash(_("Invalid shelf specified"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
log.warning("You are not allowed to add a book to the shelf".format(shelf.name)) log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
flash(_(u"You are not allowed to add a book to the shelf"), category="error") flash(_("You are not allowed to add a book to the shelf"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]: if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
@ -123,7 +123,7 @@ def search_to_shelf(shelf_id):
if not books_for_shelf: if not books_for_shelf:
log.error("Books are already part of {}".format(shelf.name)) log.error("Books are already part of {}".format(shelf.name))
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error") flash(_("Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0 maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
@ -135,14 +135,14 @@ def search_to_shelf(shelf_id):
try: try:
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(_("Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! 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(_("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'))
@ -182,13 +182,13 @@ def remove_from_shelf(shelf_id, book_id):
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! 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:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not xhr: if not xhr:
flash(_(u"Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success") flash(_("Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
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:
@ -197,7 +197,7 @@ def remove_from_shelf(shelf_id, book_id):
else: else:
if not xhr: if not xhr:
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name)) log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
flash(_(u"Sorry you are not allowed to remove a book from this shelf"), flash(_("Sorry you are not allowed to remove a book from this shelf"),
category="error") category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Sorry you are not allowed to remove a book from this shelf", 403 return "Sorry you are not allowed to remove a book from this shelf", 403
@ -207,7 +207,7 @@ def remove_from_shelf(shelf_id, book_id):
@login_required @login_required
def create_shelf(): def create_shelf():
shelf = ub.Shelf() shelf = ub.Shelf()
return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate") return create_edit_shelf(shelf, page_title=_("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"])
@ -215,9 +215,9 @@ def create_shelf():
def edit_shelf(shelf_id): def edit_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 not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
flash(_(u"Sorry you are not allowed to edit this shelf"), category="error") flash(_("Sorry you are not allowed to edit this shelf"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return create_edit_shelf(shelf, page_title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id) return create_edit_shelf(shelf, page_title=_("Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"]) @shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
@ -232,7 +232,7 @@ def delete_shelf(shelf_id):
except InvalidRequestError as e: except InvalidRequestError as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -269,7 +269,7 @@ def order_shelf(shelf_id):
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
result = list() result = list()
if shelf: if shelf:
@ -278,7 +278,7 @@ def order_shelf(shelf_id):
.add_columns(calibre_db.common_filters().label("visible")) \ .add_columns(calibre_db.common_filters().label("visible")) \
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all() .filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
return render_title_template('shelf_order.html', entries=result, return render_title_template('shelf_order.html', entries=result,
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name), title=_("Change order of Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelforder") shelf=shelf, page="shelforder")
else: else:
abort(404) abort(404)
@ -295,11 +295,14 @@ def check_shelf_edit_permissions(cur_shelf):
def check_shelf_view_permissions(cur_shelf): def check_shelf_view_permissions(cur_shelf):
if cur_shelf.is_public: try:
return True if cur_shelf.is_public:
if current_user.is_anonymous or cur_shelf.user_id != current_user.id: return True
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name)) if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
return False log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
return False
except Exception as e:
log.error(e)
return True return True
@ -310,7 +313,7 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
if request.method == "POST": if request.method == "POST":
to_save = request.form.to_dict() to_save = request.form.to_dict()
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(_("Sorry you are not allowed to create a public shelf"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
is_public = 1 if to_save.get("is_public") == "on" else 0 is_public = 1 if to_save.get("is_public") == "on" else 0
if config.config_kobo_sync: if config.config_kobo_sync:
@ -327,24 +330,24 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
shelf.user_id = int(current_user.id) shelf.user_id = int(current_user.id)
ub.session.add(shelf) ub.session.add(shelf)
shelf_action = "created" shelf_action = "created"
flash_text = _(u"Shelf %(title)s created", title=shelf_title) flash_text = _("Shelf %(title)s created", title=shelf_title)
else: else:
shelf_action = "changed" shelf_action = "changed"
flash_text = _(u"Shelf %(title)s changed", title=shelf_title) flash_text = _("Shelf %(title)s changed", title=shelf_title)
try: try:
ub.session.commit() ub.session.commit()
log.info(u"Shelf {} {}".format(shelf_title, shelf_action)) log.info("Shelf {} {}".format(shelf_title, shelf_action))
flash(flash_text, category="success") flash(flash_text, category="success")
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.error_or_exception(ex) log.error_or_exception(ex)
log.error_or_exception("Settings Database error: {}".format(ex)) log.error_or_exception("Settings Database error: {}".format(ex))
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=ex.orig), category="error")
except Exception as ex: except Exception as ex:
ub.session.rollback() ub.session.rollback()
log.error_or_exception(ex) log.error_or_exception(ex)
flash(_(u"There was an error"), category="error") flash(_("There was an error"), category="error")
return render_title_template('shelf_edit.html', return render_title_template('shelf_edit.html',
shelf=shelf, shelf=shelf,
title=page_title, title=page_title,
@ -366,7 +369,7 @@ def check_shelf_is_unique(title, is_public, shelf_id=False):
if not is_shelf_name_unique: if not is_shelf_name_unique:
log.error("A public shelf with the name '{}' already exists.".format(title)) log.error("A public shelf with the name '{}' already exists.".format(title))
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=title), flash(_("A public shelf with the name '%(title)s' already exists.", title=title),
category="error") category="error")
else: else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \ is_shelf_name_unique = ub.session.query(ub.Shelf) \
@ -377,7 +380,7 @@ def check_shelf_is_unique(title, is_public, shelf_id=False):
if not is_shelf_name_unique: if not is_shelf_name_unique:
log.error("A private shelf with the name '{}' already exists.".format(title)) log.error("A private shelf with the name '{}' already exists.".format(title))
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=title), flash(_("A private shelf with the name '%(title)s' already exists.", title=title),
category="error") category="error")
return is_shelf_name_unique return is_shelf_name_unique
@ -454,14 +457,14 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
log.error_or_exception("Settings Database error: {}".format(e)) log.error_or_exception("Settings Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error") flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
return render_title_template(page, return render_title_template(page,
entries=result, entries=result,
pagination=pagination, pagination=pagination,
title=_(u"Shelf: '%(name)s'", name=shelf.name), title=_("Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, shelf=shelf,
page="shelf") page="shelf")
else: else:
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error") flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))

View File

@ -3290,10 +3290,13 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
-ms-transform-origin: center top; -ms-transform-origin: center top;
transform-origin: center top; transform-origin: center top;
border: 0; border: 0;
left: 0 !important;
overflow-y: auto; overflow-y: auto;
} }
.dropdown-menu:not(.datepicker-dropdown):not(.profileDropli) {
left: 0 !important;
}
#add-to-shelves { #add-to-shelves {
min-height: 48px;
max-height: calc(100% - 120px); max-height: calc(100% - 120px);
overflow-y: auto; overflow-y: auto;
} }
@ -4423,38 +4426,6 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
left: 49px; left: 49px;
margin-top: 5px margin-top: 5px
} }
body:not(.blur) > .navbar > .container-fluid > .navbar-header:after, body:not(.blur) > .navbar > .container-fluid > .navbar-header:before {
color: hsla(0, 0%, 100%, .7);
cursor: pointer;
display: block;
font-family: plex-icons-new, serif;
font-size: 20px;
font-stretch: 100%;
font-style: normal;
font-variant-caps: normal;
font-variant-east-asian: normal;
font-variant-numeric: normal;
font-weight: 400;
height: 60px;
letter-spacing: normal;
line-height: 60px;
position: absolute
}
body:not(.blur) > .navbar > .container-fluid > .navbar-header:before {
content: "\EA30";
-webkit-font-variant-ligatures: normal;
font-variant-ligatures: normal;
left: 20px
}
body:not(.blur) > .navbar > .container-fluid > .navbar-header:after {
content: "\EA2F";
-webkit-font-variant-ligatures: normal;
font-variant-ligatures: normal;
left: 60px
}
} }
body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > div.row:first-of-type > div.col > h2:before, body.admin > div.container-fluid > div > div.col-sm-10 > div.discover > h2:first-of-type:before, body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before, body.newuser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before { body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > div.row:first-of-type > div.col > h2:before, body.admin > div.container-fluid > div > div.col-sm-10 > div.discover > h2:first-of-type:before, body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before, body.newuser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before {
@ -4842,8 +4813,14 @@ body.advsearch:not(.blur) > div.container-fluid > div.row-fluid > div.col-sm-10
z-index: 999999999999999999999999999999999999 z-index: 999999999999999999999999999999999999
} }
.search #shelf-actions, body.login .home-btn { body.search #shelf-actions button#add-to-shelf {
display: none height: 40px;
}
@media screen and (max-width: 767px) {
body.search .discover, body.advsearch .discover {
display: flex;
flex-direction: column;
}
} }
body.read:not(.blur) a[href*=readbooks] { body.read:not(.blur) a[href*=readbooks] {
@ -5164,7 +5141,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
right: 5px right: 5px
} }
#shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] { body:not(.search) #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
pointer-events: none pointer-events: none
} }
@ -5181,7 +5158,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
color: var(--color-primary) color: var(--color-primary)
} }
#shelf-actions, #shelf-actions > .btn-group, #shelf-actions > .btn-group > .empty-ul { body:not(.search) #shelf-actions, body:not(.search) #shelf-actions > .btn-group, body:not(.search) #shelf-actions > .btn-group > .empty-ul {
pointer-events: none pointer-events: none
} }
@ -7309,6 +7286,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
float: right float: right
} }
body.blur #main-nav + #scnd-nav .create-shelf, body.blur #main-nav + .col-sm-2 #scnd-nav .create-shelf {
float: none;
margin: 5px 0 10px -10px;
}
#main-nav + #scnd-nav .nav-head.hidden-xs { #main-nav + #scnd-nav .nav-head.hidden-xs {
display: list-item !important; display: list-item !important;
width: 225px width: 225px

View File

@ -22,3 +22,7 @@ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
padding: 0 0; padding: 0 0;
line-height: 15px; line-height: 15px;
} }
input.datepicker {color: transparent}
input.datepicker:focus {color: transparent}
input.datepicker:focus + input {color: #555}

View File

@ -149,6 +149,20 @@ body {
word-wrap: break-word; word-wrap: break-word;
} }
#mainContent > canvas {
display: block;
margin-left: auto;
margin-right: auto;
}
.long-strip > .mainImage {
margin-bottom: 4px;
}
.long-strip > .mainImage:last-child {
margin-bottom: 0px !important;
}
#titlebar { #titlebar {
min-height: 25px; min-height: 25px;
height: auto; height: auto;

29
cps/static/css/reader.css Normal file
View File

@ -0,0 +1,29 @@
.fontSizeWrapper {
position: relative;
}
.slider {
position: absolute;
top: 50%;
transform: translate(0,-50%);
width: 90%;
height: 60px;
background: transparent;
border-radius: 20px;
display: flex;
align-items: center;
box-shadow: 0px 15px 40px #7E6D5766;
}
.slider label {
font-size: 20px;
font-weight: 400;
font-family: Open Sans;
padding-right: 10px;
color: white;
}
.slider input[type="range"] {
width: 80%;
height: 5px;
background: black;
border: none;
outline: none;
}

View File

@ -140,6 +140,7 @@ table .bg-dark-danger a { color: #fff; }
.container-fluid .book { .container-fluid .book {
margin-top: 20px; margin-top: 20px;
max-width: 180px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -433,3 +434,7 @@ div.log {
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; } #detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
#detailcover:-ms-fullscreen { cursor:zoom-out; border: 0; } #detailcover:-ms-fullscreen { cursor:zoom-out; border: 0; }
#detailcover:fullscreen { cursor:zoom-out; border: 0; } #detailcover:fullscreen { cursor:zoom-out; border: 0; }
.error-list {
margin-top: 5px;
}

View File

@ -16,7 +16,6 @@
*/ */
// Move advanced search to side-menu // Move advanced search to side-menu
$("a[href*='advanced']").parent().insertAfter("#nav_new"); $("a[href*='advanced']").parent().insertAfter("#nav_new");
$("body").addClass("blur");
$("body.stat").addClass("stats"); $("body.stat").addClass("stats");
$("body.config").addClass("admin"); $("body.config").addClass("admin");
$("body.uiconfig").addClass("admin"); $("body.uiconfig").addClass("admin");
@ -29,8 +28,8 @@ $("body > div.container-fluid > div > div.col-sm-10 > div.filterheader").attr("s
// Back button // Back button
curHref = window.location.href.split("/"); curHref = window.location.href.split("/");
prevHref = document.referrer.split("/"); prevHref = document.referrer.split("/");
$(".navbar-form.navbar-left") $(".plexBack a").attr('href', encodeURI(document.referrer));
.before('<div class="plexBack"><a href="' + encodeURI(document.referrer) + '"></a></div>');
if (history.length === 1 || if (history.length === 1 ||
curHref[0] + curHref[0] +
curHref[1] + curHref[1] +
@ -44,14 +43,9 @@ if (history.length === 1 ||
//Weird missing a after pressing back from edit. //Weird missing a after pressing back from edit.
setTimeout(function () { setTimeout(function () {
if ($(".plexBack a").length < 1) { $(".plexBack a").attr('href', encodeURI(document.referrer));
$(".plexBack").append('<a href="' + encodeURI(document.referrer) + '"></a>');
}
}, 10); }, 10);
// Home button
$(".plexBack").before('<div class="home-btn"></div>');
$("a.navbar-brand").clone().appendTo(".home-btn").empty().removeClass("navbar-brand");
///////////////////////////////// /////////////////////////////////
// Start of Book Details Work // // Start of Book Details Work //
/////////////////////////////// ///////////////////////////////
@ -320,19 +314,11 @@ $(document).mouseup(function (e) {
}); });
}); });
// Split path name to array and remove blanks
url = window.location.pathname
// Move create shelf // Move create shelf
$("#nav_createshelf").prependTo(".your-shelves"); $("#nav_createshelf").prependTo(".your-shelves");
// Create drop-down for profile and move elements to it // Move About link it the profile dropdown
$("#main-nav") $(".profileDropli #top_user").parent().after($("#nav_about").addClass("dropdown"))
.prepend('<li class="dropdown"><a href="#" class="dropdown-toggle profileDrop" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-user"></span></a><ul class="dropdown-menu profileDropli"></ul></li>');
$("#top_user").parent().addClass("dropdown").appendTo(".profileDropli");
$("#nav_about").addClass("dropdown").appendTo(".profileDropli");
$("#register").parent().addClass("dropdown").appendTo(".profileDropli");
$("#logout").parent().addClass("dropdown").appendTo(".profileDropli");
// Remove the modals except from some areas where they are needed // Remove the modals except from some areas where they are needed
bodyClass = $("body").attr("class").split(" "); bodyClass = $("body").attr("class").split(" ");
@ -371,31 +357,6 @@ $(document).on("click", ".dropdown-toggle", function () {
}); });
}); });
// Fade out content on page unload
// delegate all clicks on "a" tag (links)
/*$(document).on("click", "a:not(.btn-toolbar a, a[href*='shelf/remove'], .identifiers a, .bookinfo , .btn-group > a, #add-to-shelves a, #book-list a, .stat.blur a )", function () {
// get the href attribute
var newUrl = $(this).attr("href");
// veryfy if the new url exists or is a hash
if (!newUrl || newUrl[0] === "#") {
// set that hash
location.hash = newUrl;
return;
}
now, fadeout the html (whole page)
$( '.blur-wrapper' ).fadeOut(250);
$(".row-fluid .col-sm-10").fadeOut(500,function () {
// when the animation is complete, set the new location
location = newUrl;
});
// prevent the default browser behavior.
return false;
});*/
// Collapse long text into read-more // Collapse long text into read-more
$("div.comments").readmore({ $("div.comments").readmore({
collapsedHeight: 134, collapsedHeight: 134,
@ -408,6 +369,13 @@ $("div.comments").readmore({
// End of Global Work // // End of Global Work //
/////////////////////////////// ///////////////////////////////
// Search Results
if($("body.search").length > 0) {
$('div[aria-label="Add to shelves"]').click(function () {
$("#add-to-shelves").toggle();
});
}
// Advanced Search Results // Advanced Search Results
if($("body.advsearch").length > 0) { if($("body.advsearch").length > 0) {
$("#loader + .container-fluid") $("#loader + .container-fluid")
@ -458,6 +426,8 @@ if ($("body.author").length > 0) {
} }
} }
// Split path name to array and remove blanks
url = window.location.pathname
// Ereader Page - add class to iframe body on ereader page after it loads. // Ereader Page - add class to iframe body on ereader page after it loads.
backurl = "../../book/" + url[2] backurl = "../../book/" + url[2]
$("body.epub #title-controls") $("body.epub #title-controls")
@ -540,6 +510,7 @@ if ($("body.shelf").length > 0) {
// Rest of Tooltips // Rest of Tooltips
$(".home-btn > a").attr({ $(".home-btn > a").attr({
"data-toggle": "tooltip", "data-toggle": "tooltip",
"href": $(".navbar-brand")[0].href,
"title": $(document.body).attr("data-text"), // Home "title": $(document.body).attr("data-text"), // Home
"data-placement": "bottom" "data-placement": "bottom"
}) })
@ -666,7 +637,7 @@ $("#sendbtn").attr({
$("#sendbtn2").attr({ $("#sendbtn2").attr({
"data-toggle-two": "tooltip", "data-toggle-two": "tooltip",
"title": $("#sendbtn2").text(), // "Send to E-Reader", "title": $("#sendbtn2").text(), // "Send to eReader",
"data-placement": "bottom", "data-placement": "bottom",
"data-viewport": ".btn-toolbar" "data-viewport": ".btn-toolbar"
}) })

File diff suppressed because it is too large Load Diff

14
cps/static/js/compress/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -125,7 +125,7 @@ function loadArchiveFormats(formats, cb) {
_loaded_archive_formats.push(archive_format); _loaded_archive_formats.push(archive_format);
break; break;
case 'zip': case 'zip':
loadScript(path + 'jszip.js', checkForLoadDone); loadScript(path + 'jszip.min.js', checkForLoadDone);
_loaded_archive_formats.push(archive_format); _loaded_archive_formats.push(archive_format);
break; break;
case 'tar': case 'tar':

View File

@ -1,5 +1,5 @@
/* 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 jkrehm * Copyright (C) 2018-2023 jkrehm, 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
@ -17,6 +17,36 @@
/* global _ */ /* global _ */
function handleResponse (data) {
$(".row-fluid.text-center").remove();
$("#flash_danger").remove();
$("#flash_success").remove();
if (!jQuery.isEmptyObject(data)) {
if($("#bookDetailsModal").is(":visible")) {
data.forEach(function (item) {
$(".modal-header").after('<div id="flash_' + item.type +
'" class="text-center alert alert-' + item.type + '">' + item.message + '</div>');
});
} else {
data.forEach(function (item) {
$(".navbar").after('<div class="row-fluid text-center">' +
'<div id="flash_' + item.type + '" class="alert alert-' + item.type + '">' + item.message + '</div>' +
'</div>');
});
}
}
}
$(".sendbtn-form").click(function() {
$.ajax({
method: 'post',
url: $(this).data('href'),
data: {csrf_token: $("input[name='csrf_token']").val()},
success: function (data) {
handleResponse(data)
}
})
});
$(function() { $(function() {
$("#have_read_form").ajaxForm(); $("#have_read_form").ajaxForm();
}); });

View File

@ -62,6 +62,7 @@ var currentImage = 0;
var imageFiles = []; var imageFiles = [];
var imageFilenames = []; var imageFilenames = [];
var totalImages = 0; var totalImages = 0;
var prevScrollPosition = 0;
var settings = { var settings = {
hflip: false, hflip: false,
@ -70,8 +71,9 @@ var settings = {
fitMode: kthoom.Key.B, fitMode: kthoom.Key.B,
theme: "light", theme: "light",
direction: 0, // 0 = Left to Right, 1 = Right to Left direction: 0, // 0 = Left to Right, 1 = Right to Left
nextPage: 0, // 0 = Reset to Top, 1 = Remember Position nextPage: 0, // 0 = Reset to Top, 1 = Remember Position
scrollbar: 1 // 0 = Hide Scrollbar, 1 = Show Scrollbar scrollbar: 1, // 0 = Hide Scrollbar, 1 = Show Scrollbar
pageDisplay: 0 // 0 = Single Page, 1 = Long Strip
}; };
kthoom.saveSettings = function() { kthoom.saveSettings = function() {
@ -130,8 +132,8 @@ var createURLFromArray = function(array, mimeType) {
} }
if ((typeof URL !== "function" && typeof URL !== "object") || if ((typeof URL !== "function" && typeof URL !== "object") ||
typeof URL.createObjectURL !== "function") { typeof URL.createObjectURL !== "function") {
throw "Browser support for Object URLs is missing"; throw "Browser support for Object URLs is missing";
} }
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
@ -176,18 +178,38 @@ kthoom.ImageFile = function(file) {
} }
}; };
function updateDirectionButtons(){
var left, right = 1;
if (currentImage == 0 ) {
if (settings.direction === 0) {
left = 0;
} else {
right = 0;
}
}
if ((currentImage + 1) >= Math.max(totalImages, imageFiles.length)) {
if (settings.direction === 0) {
right = 0;
} else {
left = 0;
}
}
left === 1 ? $("#left").show() : $("#left").hide();
right === 1 ? $("#right").show() : $("#right").hide();
}
function initProgressClick() { function initProgressClick() {
$("#progress").click(function(e) { $("#progress").click(function(e) {
var offset = $(this).offset(); var offset = $(this).offset();
var x = e.pageX - offset.left; var x = e.pageX - offset.left;
var rate = settings.direction === 0 ? x / $(this).width() : 1 - x / $(this).width(); var rate = settings.direction === 0 ? x / $(this).width() : 1 - x / $(this).width();
currentImage = Math.max(1, Math.ceil(rate * totalImages)) - 1; currentImage = Math.max(1, Math.ceil(rate * totalImages)) - 1;
updateDirectionButtons();
setBookmark();
updatePage(); updatePage();
}); });
} }
function loadFromArrayBuffer(ab) { function loadFromArrayBuffer(ab) {
var lastCompletion = 0;
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
loadArchiveFormats(['rar', 'zip', 'tar'], function() { loadArchiveFormats(['rar', 'zip', 'tar'], function() {
// Open the file as an archive // Open the file as an archive
@ -216,9 +238,14 @@ function loadFromArrayBuffer(ab) {
"</a>" + "</a>" +
"</li>" "</li>"
); );
drawCanvas();
setImage(test.dataURI, null);
// display first page if we haven't yet // display first page if we haven't yet
if (imageFiles.length === currentImage + 1) { if (imageFiles.length === currentImage + 1) {
updatePage(lastCompletion); updateDirectionButtons();
updatePage();
} }
} else { } else {
totalImages--; totalImages--;
@ -233,6 +260,17 @@ function loadFromArrayBuffer(ab) {
} }
function scrollTocToActive() { function scrollTocToActive() {
$(".page").text((currentImage + 1 ) + "/" + totalImages);
// Mark the current page in the TOC
$("#tocView a[data-page]")
// Remove the currently active thumbnail
.removeClass("active")
// Find the new one
.filter("[data-page=" + (currentImage + 1) + "]")
// Set it to active
.addClass("active");
// Scroll to the thumbnail in the TOC on page change // Scroll to the thumbnail in the TOC on page change
$("#tocView").stop().animate({ $("#tocView").stop().animate({
scrollTop: $("#tocView a.active").position().top scrollTop: $("#tocView a.active").position().top
@ -240,33 +278,33 @@ function scrollTocToActive() {
} }
function updatePage() { function updatePage() {
$(".page").text((currentImage + 1 ) + "/" + totalImages);
// Mark the current page in the TOC
$("#tocView a[data-page]")
// Remove the currently active thumbnail
.removeClass("active")
// Find the new one
.filter("[data-page=" + (currentImage + 1) + "]")
// Set it to active
.addClass("active");
scrollTocToActive(); scrollTocToActive();
scrollCurrentImageIntoView();
updateProgress(); updateProgress();
pageDisplayUpdate();
if (imageFiles[currentImage]) { setTheme();
setImage(imageFiles[currentImage].dataURI);
} else {
setImage("loading");
}
$("body").toggleClass("dark-theme", settings.theme === "dark");
$("#mainContent").toggleClass("disabled-scrollbar", settings.scrollbar === 0);
kthoom.setSettings(); kthoom.setSettings();
kthoom.saveSettings(); kthoom.saveSettings();
} }
function setTheme() {
$("body").toggleClass("dark-theme", settings.theme === "dark");
$("#mainContent").toggleClass("disabled-scrollbar", settings.scrollbar === 0);
}
function pageDisplayUpdate() {
if(settings.pageDisplay === 0) {
$(".mainImage").addClass("hide");
$(".mainImage").eq(currentImage).removeClass("hide");
$("#mainContent").removeClass("long-strip");
} else {
$(".mainImage").removeClass("hide");
$("#mainContent").addClass("long-strip");
scrollCurrentImageIntoView();
}
}
function updateProgress(loadPercentage) { function updateProgress(loadPercentage) {
if (settings.direction === 0) { if (settings.direction === 0) {
$("#progress .bar-read") $("#progress .bar-read")
@ -298,100 +336,93 @@ function updateProgress(loadPercentage) {
$("#progress .bar-read").css({ width: totalImages === 0 ? 0 : Math.round((currentImage + 1) / totalImages * 100) + "%"}); $("#progress .bar-read").css({ width: totalImages === 0 ? 0 : Math.round((currentImage + 1) / totalImages * 100) + "%"});
} }
function setImage(url) { function setImage(url, _canvas) {
var canvas = $("#mainImage")[0]; var canvas = _canvas || $(".mainImage").slice(-1)[0]; // Select the last item on the array if _canvas is null
var x = $("#mainImage")[0].getContext("2d"); var x = canvas.getContext("2d");
$("#mainText").hide(); $("#mainText").hide();
if (url === "loading") { if (url === "error") {
updateScale(true);
canvas.width = innerWidth - 100;
canvas.height = 200;
x.fillStyle = "black"; x.fillStyle = "black";
x.textAlign = "center"; x.textAlign = "center";
x.font = "24px sans-serif"; x.font = "24px sans-serif";
x.strokeStyle = "black"; x.strokeStyle = (settings.theme === "dark") ? "white" : "black";
x.fillText("Loading Page #" + (currentImage + 1), innerWidth / 2, 100); x.fillText("Unable to decompress image #" + (currentImage + 1), innerWidth / 2, 100);
$(".mainImage").slice(-1).addClass("error");
} else { } else {
if (url === "error") { if ($("body").css("scrollHeight") / innerHeight > 1) {
updateScale(true); $("body").css("overflowY", "scroll");
canvas.width = innerWidth - 100;
canvas.height = 200;
x.fillStyle = "black";
x.textAlign = "center";
x.font = "24px sans-serif";
x.strokeStyle = "black";
x.fillText("Unable to decompress image #" + (currentImage + 1), innerWidth / 2, 100);
} else {
if ($("body").css("scrollHeight") / innerHeight > 1) {
$("body").css("overflowY", "scroll");
}
var img = new Image();
img.onerror = function() {
canvas.width = innerWidth - 100;
canvas.height = 300;
updateScale(true);
x.fillStyle = "black";
x.font = "50px sans-serif";
x.strokeStyle = "black";
x.fillText("Page #" + (currentImage + 1) + " (" +
imageFiles[currentImage].filename + ")", innerWidth / 2, 100);
x.fillStyle = "black";
x.fillText("Is corrupt or not an image", innerWidth / 2, 200);
var xhr = new XMLHttpRequest();
if (/(html|htm)$/.test(imageFiles[currentImage].filename)) {
xhr.open("GET", url, true);
xhr.onload = function() {
$("#mainText").css("display", "");
$("#mainText").innerHTML("<iframe style=\"width:100%;height:700px;border:0\" src=\"data:text/html," + escape(xhr.responseText) + "\"></iframe>");
};
xhr.send(null);
} else if (!/(jpg|jpeg|png|gif|webp)$/.test(imageFiles[currentImage].filename) && imageFiles[currentImage].data.uncompressedSize < 10 * 1024) {
xhr.open("GET", url, true);
xhr.onload = function() {
$("#mainText").css("display", "");
$("#mainText").innerText(xhr.responseText);
};
xhr.send(null);
}
};
img.onload = function() {
var h = img.height,
w = img.width,
sw = w,
sh = h;
settings.rotateTimes = (4 + settings.rotateTimes) % 4;
x.save();
if (settings.rotateTimes % 2 === 1) {
sh = w;
sw = h;
}
canvas.height = sh;
canvas.width = sw;
x.translate(sw / 2, sh / 2);
x.rotate(Math.PI / 2 * settings.rotateTimes);
x.translate(-w / 2, -h / 2);
if (settings.vflip) {
x.scale(1, -1);
x.translate(0, -h);
}
if (settings.hflip) {
x.scale(-1, 1);
x.translate(-w, 0);
}
canvas.style.display = "none";
scrollTo(0, 0);
x.drawImage(img, 0, 0);
updateScale(false);
canvas.style.display = "";
$("body").css("overflowY", "");
x.restore();
};
img.src = url;
} }
var img = new Image();
img.onerror = function() {
canvas.width = innerWidth - 100;
canvas.height = 300;
x.fillStyle = "black";
x.font = "50px sans-serif";
x.strokeStyle = "black";
x.fillText("Page #" + (currentImage + 1) + " (" +
imageFiles[currentImage].filename + ")", innerWidth / 2, 100);
x.fillStyle = "black";
x.fillText("Is corrupt or not an image", innerWidth / 2, 200);
var xhr = new XMLHttpRequest();
if (/(html|htm)$/.test(imageFiles[currentImage].filename)) {
xhr.open("GET", url, true);
xhr.onload = function() {
$("#mainText").css("display", "");
$("#mainText").innerHTML("<iframe style=\"width:100%;height:700px;border:0\" src=\"data:text/html," + escape(xhr.responseText) + "\"></iframe>");
};
xhr.send(null);
} else if (!/(jpg|jpeg|png|gif|webp)$/.test(imageFiles[currentImage].filename) && imageFiles[currentImage].data.uncompressedSize < 10 * 1024) {
xhr.open("GET", url, true);
xhr.onload = function() {
$("#mainText").css("display", "");
$("#mainText").innerText(xhr.responseText);
};
xhr.send(null);
}
};
img.onload = function() {
var h = img.height,
w = img.width,
sw = w,
sh = h;
settings.rotateTimes = (4 + settings.rotateTimes) % 4;
x.save();
if (settings.rotateTimes % 2 === 1) {
sh = w;
sw = h;
}
canvas.height = sh;
canvas.width = sw;
x.translate(sw / 2, sh / 2);
x.rotate(Math.PI / 2 * settings.rotateTimes);
x.translate(-w / 2, -h / 2);
if (settings.vflip) {
x.scale(1, -1);
x.translate(0, -h);
}
if (settings.hflip) {
x.scale(-1, 1);
x.translate(-w, 0);
}
canvas.style.display = "none";
scrollTo(0, 0);
x.drawImage(img, 0, 0);
canvas.style.display = "";
$("body").css("overflowY", "");
x.restore();
};
img.src = url;
}
}
// reloadImages is a slow process when multiple images are involved. Only used when rotating/mirroring
function reloadImages() {
for(i=0; i < imageFiles.length; i++) {
setImage(imageFiles[i].dataURI, $(".mainImage")[i]);
} }
} }
@ -401,6 +432,7 @@ function showLeftPage() {
} else { } else {
showNextPage(); showNextPage();
} }
setBookmark();
} }
function showRightPage() { function showRightPage() {
@ -409,6 +441,7 @@ function showRightPage() {
} else { } else {
showPrevPage(); showPrevPage();
} }
setBookmark();
} }
function showPrevPage() { function showPrevPage() {
@ -418,10 +451,8 @@ function showPrevPage() {
currentImage++; currentImage++;
} else { } else {
updatePage(); updatePage();
if (settings.nextPage === 0) {
$("#mainContent").scrollTop(0);
}
} }
updateDirectionButtons();
} }
function showNextPage() { function showNextPage() {
@ -431,36 +462,54 @@ function showNextPage() {
currentImage--; currentImage--;
} else { } else {
updatePage(); updatePage();
if (settings.nextPage === 0) { }
$("#mainContent").scrollTop(0); updateDirectionButtons();
} }
function scrollCurrentImageIntoView() {
if(settings.pageDisplay == 0) {
// This will scroll all the way up when Single Page is selected
$("#mainContent").scrollTop(0);
} else {
// This will scroll to the image when Long Strip is selected
$("#mainContent").stop().animate({
scrollTop: $(".mainImage").eq(currentImage).offset().top + $("#mainContent").scrollTop() - $("#mainContent").offset().top
}, 200);
} }
} }
function updateScale(clear) { function updateScale() {
var mainImageStyle = getElem("mainImage").style; var canvasArray = $("#mainContent > canvas");
mainImageStyle.width = "";
mainImageStyle.height = "";
mainImageStyle.maxWidth = "";
mainImageStyle.maxHeight = "";
var maxheight = innerHeight - 50; var maxheight = innerHeight - 50;
canvasArray.css("width", "");
canvasArray.css("height", "");
canvasArray.css("maxWidth", "");
canvasArray.css("maxHeight", "");
if (!clear) { if(settings.pageDisplay === 0) {
switch (settings.fitMode) { canvasArray.addClass("hide");
case kthoom.Key.B: pageDisplayUpdate();
mainImageStyle.maxWidth = "100%";
mainImageStyle.maxHeight = maxheight + "px";
break;
case kthoom.Key.H:
mainImageStyle.height = maxheight + "px";
break;
case kthoom.Key.W:
mainImageStyle.width = "100%";
break;
default:
break;
}
} }
switch (settings.fitMode) {
case kthoom.Key.B:
canvasArray.css("maxWidth", "100%");
canvasArray.css("maxHeight", maxheight + "px");
break;
case kthoom.Key.H:
canvasArray.css("maxHeight", maxheight + "px");
break;
case kthoom.Key.W:
canvasArray.css("width", "100%");
break;
default:
break;
}
$("#mainContent > canvas.error").css("width", innerWidth - 100);
$("#mainContent > canvas.error").css("height", 200);
$("#mainContent").css({maxHeight: maxheight + 5}); $("#mainContent").css({maxHeight: maxheight + 5});
kthoom.setSettings(); kthoom.setSettings();
kthoom.saveSettings(); kthoom.saveSettings();
@ -477,6 +526,20 @@ function keyHandler(evt) {
if (hasModifier) break; if (hasModifier) break;
showRightPage(); showRightPage();
break; break;
case kthoom.Key.S:
if (hasModifier) break;
settings.pageDisplay = 0;
pageDisplayUpdate();
kthoom.setSettings();
kthoom.saveSettings();
break;
case kthoom.Key.O:
if (hasModifier) break;
settings.pageDisplay = 1;
pageDisplayUpdate();
kthoom.setSettings();
kthoom.saveSettings();
break;
case kthoom.Key.L: case kthoom.Key.L:
if (hasModifier) break; if (hasModifier) break;
settings.rotateTimes--; settings.rotateTimes--;
@ -484,6 +547,7 @@ function keyHandler(evt) {
settings.rotateTimes = 3; settings.rotateTimes = 3;
} }
updatePage(); updatePage();
reloadImages();
break; break;
case kthoom.Key.R: case kthoom.Key.R:
if (hasModifier) break; if (hasModifier) break;
@ -492,6 +556,7 @@ function keyHandler(evt) {
settings.rotateTimes = 0; settings.rotateTimes = 0;
} }
updatePage(); updatePage();
reloadImages();
break; break;
case kthoom.Key.F: case kthoom.Key.F:
if (hasModifier) break; if (hasModifier) break;
@ -507,26 +572,27 @@ function keyHandler(evt) {
settings.hflip = true; settings.hflip = true;
} }
updatePage(); updatePage();
reloadImages();
break; break;
case kthoom.Key.W: case kthoom.Key.W:
if (hasModifier) break; if (hasModifier) break;
settings.fitMode = kthoom.Key.W; settings.fitMode = kthoom.Key.W;
updateScale(false); updateScale();
break; break;
case kthoom.Key.H: case kthoom.Key.H:
if (hasModifier) break; if (hasModifier) break;
settings.fitMode = kthoom.Key.H; settings.fitMode = kthoom.Key.H;
updateScale(false); updateScale();
break; break;
case kthoom.Key.B: case kthoom.Key.B:
if (hasModifier) break; if (hasModifier) break;
settings.fitMode = kthoom.Key.B; settings.fitMode = kthoom.Key.B;
updateScale(false); updateScale();
break; break;
case kthoom.Key.N: case kthoom.Key.N:
if (hasModifier) break; if (hasModifier) break;
settings.fitMode = kthoom.Key.N; settings.fitMode = kthoom.Key.N;
updateScale(false); updateScale();
break; break;
case kthoom.Key.SPACE: case kthoom.Key.SPACE:
if (evt.shiftKey) { if (evt.shiftKey) {
@ -545,37 +611,85 @@ function keyHandler(evt) {
} }
} }
function drawCanvas() {
var maxheight = innerHeight - 50;
var canvasElement = $("<canvas></canvas>");
var x = canvasElement[0].getContext("2d");
canvasElement.addClass("mainImage");
switch (settings.fitMode) {
case kthoom.Key.B:
canvasElement.css("maxWidth", "100%");
canvasElement.css("maxHeight", maxheight + "px");
break;
case kthoom.Key.H:
canvasElement.css("maxHeight", maxheight + "px");
break;
case kthoom.Key.W:
canvasElement.css("width", "100%");
break;
default:
break;
}
if(settings.pageDisplay === 0) {
canvasElement.addClass("hide");
}
//Fill with Placeholder text. setImage will override this
canvasElement.width = innerWidth - 100;
canvasElement.height = 200;
x.fillStyle = "black";
x.textAlign = "center";
x.font = "24px sans-serif";
x.strokeStyle = (settings.theme === "dark") ? "white" : "black";
x.fillText("Loading Page #" + (currentImage + 1), innerWidth / 2, 100);
$("#mainContent").append(canvasElement);
}
function updateArrows() {
if ($('input[name="direction"]:checked').val() === "0") {
$("#prev_page_key").html("&larr;");
$("#next_page_key").html("&rarr;");
} else {
$("#prev_page_key").html("&rarr;");
$("#next_page_key").html("&larr;");
}
};
function init(filename) { function init(filename) {
var request = new XMLHttpRequest(); var request = new XMLHttpRequest();
request.open("GET", filename); request.open("GET", filename);
request.responseType = "arraybuffer"; request.responseType = "arraybuffer";
request.addEventListener("load", function() { request.addEventListener("load", function () {
if (request.status >= 200 && request.status < 300) { if (request.status >= 200 && request.status < 300) {
loadFromArrayBuffer(request.response); loadFromArrayBuffer(request.response);
} else { } else {
console.warn(request.statusText, request.responseText); console.warn(request.statusText, request.responseText);
} }
}); });
kthoom.loadSettings();
setTheme();
updateScale();
request.send(); request.send();
initProgressClick(); initProgressClick();
document.body.className += /AppleWebKit/.test(navigator.userAgent) ? " webkit" : ""; document.body.className += /AppleWebKit/.test(navigator.userAgent) ? " webkit" : "";
kthoom.loadSettings();
updateScale(true);
$(document).keydown(keyHandler); $(document).keydown(keyHandler);
$(window).resize(function() { $(window).resize(function () {
updateScale(false); updateScale();
}); });
// Open TOC menu // Open TOC menu
$("#slider").click(function() { $("#slider").click(function () {
$("#sidebar").toggleClass("open"); $("#sidebar").toggleClass("open");
$("#main").toggleClass("closed"); $("#main").toggleClass("closed");
$(this).toggleClass("icon-menu icon-right"); $(this).toggleClass("icon-menu icon-right");
// We need this in a timeout because if we call it during the CSS transition, IE11 shakes the page ¯\_(ツ)_/¯ // We need this in a timeout because if we call it during the CSS transition, IE11 shakes the page ¯\_(ツ)_/¯
setTimeout(function() { setTimeout(function () {
// Focus on the TOC or the main content area, depending on which is open // Focus on the TOC or the main content area, depending on which is open
$("#main:not(.closed) #mainContent, #sidebar.open #tocView").focus(); $("#main:not(.closed) #mainContent, #sidebar.open #tocView").focus();
scrollTocToActive(); scrollTocToActive();
@ -583,12 +697,12 @@ function init(filename) {
}); });
// Open Settings modal // Open Settings modal
$("#setting").click(function() { $("#setting").click(function () {
$("#settings-modal").toggleClass("md-show"); $("#settings-modal").toggleClass("md-show");
}); });
// On Settings input change // On Settings input change
$("#settings input").on("change", function() { $("#settings input").on("change", function () {
// Get either the checked boolean or the assigned value // Get either the checked boolean or the assigned value
var value = this.type === "checkbox" ? this.checked : this.value; var value = this.type === "checkbox" ? this.checked : this.value;
@ -596,33 +710,41 @@ function init(filename) {
value = /^\d+$/.test(value) ? parseInt(value) : value; value = /^\d+$/.test(value) ? parseInt(value) : value;
settings[this.name] = value; settings[this.name] = value;
if (["hflip", "vflip", "rotateTimes"].includes(this.name)) {
reloadImages();
} else if (this.name === "direction") {
updateDirectionButtons();
return updateProgress();
}
updatePage(); updatePage();
updateScale(false); updateScale();
}); });
// Close modal // Close modal
$(".closer, .overlay").click(function() { $(".closer, .overlay").click(function () {
$(".md-show").removeClass("md-show"); $(".md-show").removeClass("md-show");
$("#mainContent").focus(); // focus back on the main container so you use up/down keys without having to click on it
}); });
// TOC thumbnail pagination // TOC thumbnail pagination
$("#thumbnails").on("click", "a", function() { $("#thumbnails").on("click", "a", function () {
currentImage = $(this).data("page") - 1; currentImage = $(this).data("page") - 1;
updatePage(); updatePage();
if (settings.nextPage === 0) {
$("#mainContent").scrollTop(0);
}
}); });
// Fullscreen mode // Fullscreen mode
if (typeof screenfull !== "undefined") { if (typeof screenfull !== "undefined") {
$("#fullscreen").click(function() { $("#fullscreen").click(function () {
screenfull.toggle($("#container")[0]); screenfull.toggle($("#container")[0]);
// Focus on main container so you can use up/down keys immediately after fullscreen
$("#mainContent").focus();
}); });
if (screenfull.raw) { if (screenfull.raw) {
var $button = $("#fullscreen"); var $button = $("#fullscreen");
document.addEventListener(screenfull.raw.fullscreenchange, function() { document.addEventListener(screenfull.raw.fullscreenchange, function () {
screenfull.isFullscreen screenfull.isFullscreen
? $button.addClass("icon-resize-small").removeClass("icon-resize-full") ? $button.addClass("icon-resize-small").removeClass("icon-resize-full")
: $button.addClass("icon-resize-full").removeClass("icon-resize-small"); : $button.addClass("icon-resize-full").removeClass("icon-resize-small");
@ -633,16 +755,16 @@ function init(filename) {
// Focus the scrollable area so that keyboard scrolling work as expected // Focus the scrollable area so that keyboard scrolling work as expected
$("#mainContent").focus(); $("#mainContent").focus();
$("#mainContent").swipe( { $("#mainContent").swipe({
swipeRight:function() { swipeRight: function () {
showLeftPage(); showLeftPage();
}, },
swipeLeft:function() { swipeLeft: function () {
showRightPage(); showRightPage();
}, },
}); });
$("#mainImage").click(function(evt) { $(".mainImage").click(function (evt) {
// Firefox does not support offsetX/Y so we have to manually calculate // Firefox does not support offsetX/Y, so we have to manually calculate
// where the user clicked in the image. // where the user clicked in the image.
var mainContentWidth = $("#mainContent").width(); var mainContentWidth = $("#mainContent").width();
var mainContentHeight = $("#mainContent").height(); var mainContentHeight = $("#mainContent").height();
@ -676,5 +798,73 @@ function init(filename) {
showRightPage(); showRightPage();
} }
}); });
// Scrolling up/down will update current image if a new image is into view (for Long Strip Display)
$("#mainContent").scroll(function (){
var scroll = $("#mainContent").scrollTop();
var viewLength = 0;
$(".mainImage").each(function(){
viewLength += $(this).height();
});
if (settings.pageDisplay === 0) {
// Don't trigger the scroll for Single Page
} else if (scroll > prevScrollPosition) {
//Scroll Down
if (currentImage + 1 < imageFiles.length) {
if (currentImageOffset(currentImage + 1) <= 1) {
currentImage = Math.floor((imageFiles.length) / (viewLength-viewLength/(imageFiles.length)) * scroll, 0);
if ( currentImage >= imageFiles.length) {
currentImage = imageFiles.length - 1;
}
console.log(currentImage);
scrollTocToActive();
updateProgress();
}
}
} else {
//Scroll Up
if (currentImage - 1 > -1) {
if (currentImageOffset(currentImage - 1) >= 0) {
currentImage = Math.floor((imageFiles.length) / (viewLength-viewLength/(imageFiles.length)) * scroll, 0);
console.log(currentImage);
scrollTocToActive();
updateProgress();
}
}
}
// Update scroll position
prevScrollPosition = scroll;
});
} }
function currentImageOffset(imageIndex) {
return $(".mainImage").eq(imageIndex).offset().top - $("#mainContent").position().top
}
function setBookmark() {
// get csrf_token
let csrf_token = $("input[name='csrf_token']").val();
//This sends a bookmark update to calibreweb.
$.ajax(calibre.bookmarkUrl, {
method: "post",
data: {
csrf_token: csrf_token,
bookmark: currentImage
}
}).fail(function (xhr, status, error) {
console.error(error);
});
}
$(function() {
$('input[name="direction"]').change(function () {
updateArrows();
});
$('#left').click(function () {
showLeftPage();
});
$('#right').click(function () {
showRightPage();
});
});

File diff suppressed because one or more lines are too long

View File

@ -177,6 +177,9 @@
whileplaying: function () { whileplaying: function () {
// get csrf_token
let csrf_token = $("input[name='csrf_token']").val();
//This sends a bookmark update to calibreweb every 30 seconds. //This sends a bookmark update to calibreweb every 30 seconds.
if (this.progressBuffer == undefined) { if (this.progressBuffer == undefined) {
@ -187,7 +190,10 @@
$.ajax(calibre.bookmarkUrl, { $.ajax(calibre.bookmarkUrl, {
method: "post", method: "post",
data: { bookmark: this.position } data: {
csrf_token: csrf_token,
bookmark: this.position
}
}).fail(function (xhr, status, error) { }).fail(function (xhr, status, error) {
console.error(error); console.error(error);
}); });
@ -313,14 +319,14 @@
}, },
onstop: function () { onstop: function () {
$.ajax(calibre.bookmarkUrl, { $.ajax(calibre.bookmarkUrl, {
method: "post", method: "post",
data: { bookmark: this.position } data: { bookmark: this.position }
}).fail(function (xhr, status, error) { }).fail(function (xhr, status, error) {
console.error(error); console.error(error);
}); });
utils.css.remove(dom.o, 'playing'); utils.css.remove(dom.o, 'playing');
}, },

View File

@ -0,0 +1 @@
!function(a){a.fn.datepicker.dates.id={days:["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"],daysShort:["Mgu","Sen","Sel","Rab","Kam","Jum","Sab"],daysMin:["Mg","Sn","Sl","Ra","Ka","Ju","Sa"],months:["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","November","Desember"],monthsShort:["Jan","Feb","Mar","Apr","Mei","Jun","Jul","Ags","Sep","Okt","Nov","Des"],today:"Hari Ini",clear:"Kosongkan"}}(jQuery);

View File

@ -0,0 +1 @@
!function(a){a.fn.datepicker.dates.no={days:["søndag","mandag","tirsdag","onsdag","torsdag","fredag","lørdag"],daysShort:["søn","man","tir","ons","tor","fre","lør"],daysMin:["sø","ma","ti","on","to","fr","lø"],months:["januar","februar","mars","april","mai","juni","juli","august","september","oktober","november","desember"],monthsShort:["jan","feb","mar","apr","mai","jun","jul","aug","sep","okt","nov","des"],today:"i dag",monthsTitle:"Måneder",clear:"Nullstill",weekStart:1,format:"dd.mm.yyyy"}}(jQuery);

View File

@ -0,0 +1 @@
!function(a){a.fn.datepicker.dates.pt={days:["Domingo","Segunda","Terça","Quarta","Quinta","Sexta","Sábado"],daysShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],daysMin:["Do","Se","Te","Qu","Qu","Se","Sa"],months:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthsShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],today:"Hoje",monthsTitle:"Meses",clear:"Limpar",format:"dd/mm/yyyy"}}(jQuery);

View File

@ -0,0 +1 @@
!function(a){a.fn.datepicker.dates.sk={days:["Nedeľa","Pondelok","Utorok","Streda","Štvrtok","Piatok","Sobota"],daysShort:["Ned","Pon","Uto","Str","Štv","Pia","Sob"],daysMin:["Ne","Po","Ut","St","Št","Pia","So"],months:["Január","Február","Marec","Apríl","Máj","Jún","Júl","August","September","Október","November","December"],monthsShort:["Jan","Feb","Mar","Apr","Máj","Jún","Júl","Aug","Sep","Okt","Nov","Dec"],today:"Dnes",clear:"Vymazať",weekStart:1,format:"d.m.yyyy"}}(jQuery);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "كلمة المرور قصيرة جداً",
"wordMaxLength": "كلمة المرور طويلة جدا",
"wordInvalidChar": "تحتوي كلمة المرور على رموز غير صالحة",
"wordNotEmail": "لا تستخدم بريدك الإلكتروني ككلمة مرور",
"wordSimilarToUsername": "لا يمكن ان تحتوي كلمة المرور على إسم المستخدم",
"wordTwoCharacterClasses": "إستخدم فئات أحرف مختلفة",
"wordRepetitions": "تكرارات كثيرة",
"wordSequences": "تحتوي كلمة المرور على أنماط متتابعة",
"errorList": "الأخطاء:",
"veryWeak": "ضعيفة جداً",
"weak": "ضعيفة",
"normal": "عادية",
"medium": "متوسطة",
"strong": "قوية",
"veryStrong": "قوية جداً"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Vaše heslo je příliš krátké",
"wordMaxLength": "Vaše heslo je příliš dlouhé",
"wordInvalidChar": "Vaše heslo obsahuje neplatný znak",
"wordNotEmail": "Nepoužívejte Váš email jako Vaše heslo",
"wordSimilarToUsername": "Vaše heslo nesmí obsahovat přihlašovací jméno",
"wordTwoCharacterClasses": "Použijte různé druhy znaků",
"wordRepetitions": "Příliš mnoho opakování",
"wordSequences": "Vaše heslo obsahuje postupnost",
"errorList": "Chyby:",
"veryWeak": "Velmi slabé",
"weak": "Slabé",
"normal": "Normální",
"medium": "Středně silné",
"strong": "Silné",
"veryStrong": "Velmi silné"
}

View File

@ -0,0 +1,21 @@
{
"wordMinLength": "Das Passwort ist zu kurz",
"wordMaxLength": "Das Passwort ist zu lang",
"wordInvalidChar": "Das Passwort enthält ein ungültiges Zeichen",
"wordNotEmail": "Das Passwort darf die E-Mail Adresse nicht enthalten",
"wordSimilarToUsername": "Das Passwort darf den Benutzernamen nicht enthalten",
"wordTwoCharacterClasses": "Bitte Buchstaben und Ziffern verwenden",
"wordRepetitions": "Zu viele Wiederholungen",
"wordSequences": "Das Passwort enthält Buchstabensequenzen",
"wordLowercase": "Bitte mindestens einen Kleinbuchstaben verwenden",
"wordUppercase": "Bitte mindestens einen Großbuchstaben verwenden",
"wordOneNumber": "Bitte mindestens eine Ziffern verwenden",
"wordOneSpecialChar": "Bitte mindestens ein Sonderzeichen verwenden",
"errorList": "Fehler:",
"veryWeak": "Sehr schwach",
"weak": "Schwach",
"normal": "Normal",
"medium": "Mittel",
"strong": "Stark",
"veryStrong": "Sehr stark"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Ο κωδικός πρόσβασης δεν έχει τον ελάχιστο αριθμό χαρακτήρων",
"wordMaxLength": "Ο κωδικός πρόσβασής σας είναι πολύ μεγάλος",
"wordInvalidChar": "Ο κωδικός πρόσβασής σας περιέχει έναν μη έγκυρο χαρακτήρα",
"wordNotEmail": "Μη χρησιμοποιείτε το email ως κωδικό",
"wordSimilarToUsername": "Ο κωδικός πρόσβασης δεν πρέπει να περιέχει το username",
"wordTwoCharacterClasses": "Χρησιμοποιήστε διαφορετικές κλάσεις χαρακτήρων",
"wordRepetitions": "Πολλές επαναλήψεις",
"wordSequences": "Ο κωδικός πρόσβασης περιέχει επαναλήψεις",
"errorList": "Σφάλματα:",
"veryWeak": "Πολύ Αδύνατος",
"weak": "Αδύνατος",
"normal": "Κανονικός",
"medium": "Μέτριος",
"strong": "Δυνατός",
"veryStrong": "Πολύ Δυνατός"
}

View File

@ -0,0 +1,21 @@
{
"wordMinLength": "Your password is too short",
"wordMaxLength": "Your password is too long",
"wordInvalidChar": "Your password contains an invalid character",
"wordNotEmail": "Do not use your email as your password",
"wordSimilarToUsername": "Your password cannot contain your username",
"wordTwoCharacterClasses": "Use different character classes",
"wordRepetitions": "Too many repetitions",
"wordSequences": "Your password contains sequences",
"wordLowercase": "Use at least one lowercase character",
"wordUppercase": "Use at least one uppercase character",
"wordOneNumber": "Use at least one number",
"wordOneSpecialChar": "Use at least one special character",
"errorList": "Errors:",
"veryWeak": "Very Weak",
"weak": "Weak",
"normal": "Normal",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Via pasvorto estas tro mallonga",
"wordMaxLength": "Via pasvorto estas tro longa",
"wordInvalidChar": "Via pasvorto enhavas nevalidan karaktero",
"wordNotEmail": "Ne uzu vian retpoŝtadreson kiel la pasvorton",
"wordSimilarToUsername": "Via pasvorto enhavas vian uzanto-nomon",
"wordTwoCharacterClasses": "Uzu signojn de diversaj tipoj (ekz., literoj kaj ciferoj)",
"wordRepetitions": "Tro multaj ripetiĝantaj signoj",
"wordSequences": "Via pasvorto enhavas simplan sinsekvon de signoj",
"errorList": "Eraroj:",
"veryWeak": "Trosimpla",
"weak": "Malforta",
"normal": "Mezforta",
"medium": "Akceptebla",
"strong": "Forta",
"veryStrong": "Elstare Forta"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Tu contraseña es demasiado corta",
"wordMaxLength": "Tu contraseña es muy larga",
"wordInvalidChar": "Tu contraseña contiene un carácter no válido",
"wordNotEmail": "No uses tu email como tu contraseña",
"wordSimilarToUsername": "Tu contraseña no puede contener tu nombre de usuario",
"wordTwoCharacterClasses": "Mezcla diferentes clases de caracteres",
"wordRepetitions": "Demasiadas repeticiones",
"wordSequences": "Tu contraseña contiene secuencias",
"errorList": "Errores:",
"veryWeak": "Muy Débil",
"weak": "Débil",
"normal": "Normal",
"medium": "Media",
"strong": "Fuerte",
"veryStrong": "Muy Fuerte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Votre mot de passe est trop court",
"wordMaxLength": "Votre mot de passe est trop long",
"wordInvalidChar": "Votre mot de passe contient un caractère invalide",
"wordNotEmail": "Ne pas utiliser votre adresse e-mail comme mot de passe",
"wordSimilarToUsername": "Votre mot de passe ne peut pas contenir votre nom d'utilisateur",
"wordTwoCharacterClasses": "Utilisez différents type de caractères",
"wordRepetitions": "Trop de répétitions",
"wordSequences": "Votre mot de passe contient des séquences",
"errorList": "Erreurs:",
"veryWeak": "Très Faible",
"weak": "Faible",
"normal": "Normal",
"medium": "Moyen",
"strong": "Fort",
"veryStrong": "Très Fort"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "La tua password è troppo corta",
"wordMaxLength": "La tua password è troppo lunga",
"wordInvalidChar": "La tua password contiene un carattere non valido",
"wordNotEmail": "Non usare la tua e-mail come password",
"wordSimilarToUsername": "La tua password non può contenere il tuo nome",
"wordTwoCharacterClasses": "Usa classi di caratteri diversi",
"wordRepetitions": "Troppe ripetizioni",
"wordSequences": "La tua password contiene sequenze",
"errorList": "Errori:",
"veryWeak": "Molto debole",
"weak": "Debole",
"normal": "Normale",
"medium": "Media",
"strong": "Forte",
"veryStrong": "Molto forte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Ditt passord er for kort",
"wordMaxLength": "Ditt passord er for langt",
"wordInvalidChar": "Ditt passord inneholder et ugyldig tegn",
"wordNotEmail": "Ikke bruk din epost som ditt passord",
"wordSimilarToUsername": "Ditt passord er for likt ditt brukernavn",
"wordTwoCharacterClasses": "Bruk en kombinasjon av bokstaver, tall og andre tegn",
"wordRepetitions": "For mange repitisjoner",
"wordSequences": "Ditt passord inneholder repeterende tegn",
"errorList": "Feil:",
"veryWeak": "Veldig Svakt",
"weak": "Svakt",
"normal": "Normal",
"medium": "Medium",
"strong": "Sterkt",
"veryStrong": "Veldig Sterkt"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Hasło jest zbyt krótkie",
"wordMaxLength": "Hasło jest za długie",
"wordInvalidChar": "Hasło zawiera nieprawidłowy znak",
"wordNotEmail": "Hasło nie może być Twoim emailem",
"wordSimilarToUsername": "Hasło nie może zawierać nazwy użytkownika",
"wordTwoCharacterClasses": "Użyj innych klas znaków",
"wordRepetitions": "Zbyt wiele powtórzeń",
"wordSequences": "Hasło zawiera sekwencje",
"errorList": "Błędy:",
"veryWeak": "Bardzo słabe",
"weak": "Słabe",
"normal": "Normalne",
"medium": "Średnie",
"strong": "Silne",
"veryStrong": "Bardzo silne"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Sua senha é muito curta",
"wordMaxLength": "Sua senha é muito longa",
"wordInvalidChar": "Sua senha contém um caractere inválido",
"wordNotEmail": "Não use seu e-mail como senha",
"wordSimilarToUsername": "Sua senha não pode conter o seu nome de usuário",
"wordTwoCharacterClasses": "Use diferentes classes de caracteres",
"wordRepetitions": "Muitas repetições",
"wordSequences": "Sua senha contém sequências",
"errorList": "Erros:",
"veryWeak": "Muito Fraca",
"weak": "Fraca",
"normal": "Normal",
"medium": "Média",
"strong": "Forte",
"veryStrong": "Muito Forte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Слишком короткий пароль",
"wordMaxLength": "Ваш пароль слишком длинный",
"wordInvalidChar": "Ваш пароль содержит недопустимый символ",
"wordNotEmail": "Не используйте e-mail в качестве пароля",
"wordSimilarToUsername": "Пароль не должен содержать логин",
"wordTwoCharacterClasses": "Используйте разные классы символов",
"wordRepetitions": "Слишком много повторений",
"wordSequences": "Пароль содержит последовательности",
"errorList": "Ошибки:",
"veryWeak": "Очень слабый",
"weak": "Слабый",
"normal": "Нормальный",
"medium": "Средний",
"strong": "Сильный",
"veryStrong": "Очень сильный"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Vaše heslo je príliž krátke",
"wordMaxLength": "Vaše heslo je príliš dlhé",
"wordInvalidChar": "Vaše heslo obsahuje neplatný znak",
"wordNotEmail": "Nepoužívajte Váš email ako Vaše heslo",
"wordSimilarToUsername": "Vaše heslo nesmie obsahovať prihlasovacie meno",
"wordTwoCharacterClasses": "Použite rôzne druhy znakov",
"wordRepetitions": "Príliš veľa opakovaní",
"wordSequences": "Vaše heslo obsahuje postupnosť",
"errorList": "Chyby:",
"veryWeak": "Veľmi slabé",
"weak": "Slabé",
"normal": "Normálne",
"medium": "Stredne silné",
"strong": "Silné",
"veryStrong": "Veľmi silné"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "รหัสผ่านของคุณสั้นเกินไป",
"wordMaxLength": "รหัสผ่านของคุณยาวเกินไป",
"wordInvalidChar": "รหัสผ่านของคุณมีอักษรที่ไม่ถูกต้อง",
"wordNotEmail": "คุณไม่สามารถใช้รหัสผ่านเหมือนกับอีเมล์ของคุณได้",
"wordSimilarToUsername": "รหัสผ่านไม่ควรประกอบด้วยคำที่เป็น username",
"wordTwoCharacterClasses": "ลองเป็นกลุ่มคำใหม่",
"wordRepetitions": "มีอักษรซ้ำเยอะเกินไป",
"wordSequences": "รหัสผ่านของคุณเดาง่ายเกินไป",
"errorList": "Errors:",
"veryWeak": "เดาง่ายมาก",
"weak": "เดาง่าย",
"normal": "พอใช้",
"medium": "กำลังดี",
"strong": "ค่อนข้างดี",
"veryStrong": "ดีมาก"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Girdiğiniz şifre çok Kısa",
"wordMaxLength": "Parolanız çok uzun",
"wordInvalidChar": "Şifreniz geçersiz bir karakter içeriyor",
"wordNotEmail": "E-mail adresinizi şifreniz içerisinde kullanmayınız",
"wordSimilarToUsername": "Kullanıcı Adınızı şifreniz içerisinde kullanmayınız",
"wordTwoCharacterClasses": "Başka karakter sınıfı kullanınız",
"wordRepetitions": "Çok fazla tekrar var",
"wordSequences": "Şifreniz Dizi içermektedir",
"errorList": "Hatalar:",
"veryWeak": "Çok Zayıf",
"weak": "Zayıf",
"normal": "Normal",
"medium": "Orta",
"strong": "Güçlü",
"veryStrong": "Çok Güçlü"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "您的密碼太短",
"wordMaxLength": "您的密碼太長",
"wordInvalidChar": "您的密碼包含無效字符",
"wordNotEmail": "不要使用電子郵件作為密碼",
"wordSimilarToUsername": "您的密碼不能包含您的用戶名",
"wordTwoCharacterClasses": "使用不同的字元類型 例如: 大小寫混合",
"wordRepetitions": "太多的重複。例如:1111",
"wordSequences": "你的密碼包含連續英/數字 例如:123 or abc",
"errorList": "錯誤:",
"veryWeak": "非常弱",
"weak": "弱",
"normal": "普通",
"medium": "中等",
"strong": "強",
"veryStrong": "非常強"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,412 @@
/*!
* TinyMCE Language Pack
*
* Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
* Licensed under the Tiny commercial license. See https://www.tiny.cloud/legal/
*/
tinymce.addI18n('nb_NO', {
"Redo": "Gjør om",
"Undo": "Angre",
"Cut": "Klipp ut",
"Copy": "Kopier",
"Paste": "Lim inn",
"Select all": "Marker alt",
"New document": "Nytt dokument",
"Ok": "",
"Cancel": "Avbryt",
"Visual aids": "Visuelle hjelpemidler",
"Bold": "Fet",
"Italic": "Kursiv",
"Underline": "Understreking",
"Strikethrough": "Gjennomstreking",
"Superscript": "Hevet skrift",
"Subscript": "Senket skrift",
"Clear formatting": "Fjern formateringer",
"Remove": "",
"Align left": "Venstrejuster",
"Align center": "Midtstill",
"Align right": "Høyrejuster",
"No alignment": "",
"Justify": "Blokkjuster",
"Bullet list": "Punktliste",
"Numbered list": "Nummerliste",
"Decrease indent": "Reduser innrykk",
"Increase indent": "Øk innrykk",
"Close": "Lukk",
"Formats": "Stiler",
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.": "Nettleseren din støtter ikke direkte tilgang til utklippsboken. Bruk istedet tastatursnarveiene Ctrl+X/C/V.",
"Headings": "Overskrifter",
"Heading 1": "Overskrift 1",
"Heading 2": "Overskrift 2",
"Heading 3": "Overskrift 3",
"Heading 4": "Overskrift 4",
"Heading 5": "Overskrift 5",
"Heading 6": "Overskrift 6",
"Preformatted": "Forhåndsformatert",
"Div": "",
"Pre": "",
"Code": "Kode",
"Paragraph": "Avsnitt",
"Blockquote": "",
"Inline": "Innkapslet",
"Blocks": "Blokker",
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Lim inn er nå i ren tekst-modus. Kopiert innhold vil bli limt inn som ren tekst inntil du slår av dette valget.",
"Fonts": "Fonter",
"Font sizes": "",
"Class": "Klasse",
"Browse for an image": "Søk etter bilde",
"OR": "",
"Drop an image here": "Slipp et bilde her",
"Upload": "Last opp",
"Uploading image": "",
"Block": "Blokk",
"Align": "Juster",
"Default": "Standard",
"Circle": "Sirkel",
"Disc": "Disk",
"Square": "Firkant",
"Lower Alpha": "Små bokstaver",
"Lower Greek": "Greske minuskler",
"Lower Roman": "Små romertall",
"Upper Alpha": "Store bokstaver",
"Upper Roman": "Store romertall",
"Anchor...": "Lenke",
"Anchor": "",
"Name": "Navn",
"ID": "",
"ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "",
"You have unsaved changes are you sure you want to navigate away?": "Du har ikke arkivert endringene. Vil du fortsette uten å arkivere?",
"Restore last draft": "Gjenopprett siste utkast",
"Special character...": "Spesialtegn...",
"Special Character": "",
"Source code": "Kildekode",
"Insert/Edit code sample": "Sett inn / endre kodeeksempel",
"Language": "Språk",
"Code sample...": "Kodeeksempel",
"Left to right": "Venstre til høyre",
"Right to left": "Høyre til venstre",
"Title": "Tittel",
"Fullscreen": "Fullskjerm",
"Action": "Handling",
"Shortcut": "Snarvei",
"Help": "Hjelp",
"Address": "Adresse",
"Focus to menubar": "Fokus på menylinje",
"Focus to toolbar": "Fokus på verktøylinje",
"Focus to element path": "Fokus på elementsti",
"Focus to contextual toolbar": "Fokus på kontekstuell verktøylinje",
"Insert link (if link plugin activated)": "Sett inn lenke (dersom lenketillegg er aktivert)",
"Save (if save plugin activated)": "Lagre (dersom lagretillegg er aktivert)",
"Find (if searchreplace plugin activated)": "Finn (dersom tillegg for søk og erstatt er aktivert)",
"Plugins installed ({0}):": "Installerte tillegg ({0}):",
"Premium plugins:": "Premiumtillegg:",
"Learn more...": "Les mer ...",
"You are using {0}": "Du bruker {0}",
"Plugins": "Programtillegg",
"Handy Shortcuts": "Nyttige snarveier",
"Horizontal line": "Horisontal linje",
"Insert/edit image": "Sett inn / rediger bilde",
"Alternative description": "Alternativ beskrivelse",
"Accessibility": "Tilgjengelighet",
"Image is decorative": "Bilde er dekorasjon",
"Source": "Kilde",
"Dimensions": "Størrelser",
"Constrain proportions": "Begrens proporsjoner",
"General": "Generelt",
"Advanced": "Avansert",
"Style": "Stil",
"Vertical space": "Vertikal avstand",
"Horizontal space": "Horisontal avstand",
"Border": "Ramme",
"Insert image": "Sett inn bilde",
"Image...": "Bilde...",
"Image list": "Bildeliste",
"Resize": "Skaler",
"Insert date/time": "Sett inn dato/tid",
"Date/time": "Dato/tid",
"Insert/edit link": "Sett inn / rediger lenke",
"Text to display": "Tekst som skal vises",
"Url": "",
"Open link in...": "Åpne lenke i..",
"Current window": "Nåværende vindu",
"None": "Ingen",
"New window": "Nytt vindu",
"Open link": "Åpne lenke",
"Remove link": "Fjern lenke",
"Anchors": "Forankringspunkter",
"Link...": "Lenke...",
"Paste or type a link": "Lim inn eller skriv en lenke",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Oppgitt URL ser ut til å være en e-postadresse. Ønsker du å sette inn påkrevet mailto: prefiks foran e-postadressen?",
"The URL you entered seems to be an external link. Do you want to add the required http:// prefix?": "URL du skrev inn ser ut som en ekstern adresse. Vil du legge til det obligatoriske prefikset http://?",
"The URL you entered seems to be an external link. Do you want to add the required https:// prefix?": "Nettadressen du fylte inn ser ut til å være en ekstern. Ønsker du å legge til påkrevd 'https://'-prefiks?",
"Link list": "Liste over lenker",
"Insert video": "Sett inn video",
"Insert/edit video": "Sett inn / rediger video",
"Insert/edit media": "Sett inn / endre media",
"Alternative source": "Alternativ kilde",
"Alternative source URL": "Alternativ kilde URL",
"Media poster (Image URL)": "Mediaposter (bilde-URL)",
"Paste your embed code below:": "Lim inn inkluderingskoden nedenfor:",
"Embed": "Inkluder",
"Media...": "Media..",
"Nonbreaking space": "Hardt mellomrom",
"Page break": "Sideskifte",
"Paste as text": "Lim inn som tekst",
"Preview": "Forhåndsvis",
"Print": "",
"Print...": "Skriv ut...",
"Save": "Lagre",
"Find": "Søk etter",
"Replace with": "Erstatt med",
"Replace": "Erstatt",
"Replace all": "Erstatt alle",
"Previous": "Forrige",
"Next": "Neste",
"Find and Replace": "Finn og erstatt",
"Find and replace...": "Finn og erstatt...",
"Could not find the specified string.": "Kunne ikke finne den spesifiserte teksten",
"Match case": "Skill mellom store / små bokstaver",
"Find whole words only": "Finn kun hele ord",
"Find in selection": "Finn i utvalg",
"Insert table": "Sett inn tabell",
"Table properties": "Tabellegenskaper",
"Delete table": "Slett tabell",
"Cell": "Celle",
"Row": "Rad",
"Column": "Kolonne",
"Cell properties": "Celleegenskaper",
"Merge cells": "Slå sammen celler",
"Split cell": "Splitt celle",
"Insert row before": "Sett inn rad før",
"Insert row after": "Sett inn rad etter",
"Delete row": "Slett rad",
"Row properties": "Radegenskaper",
"Cut row": "Klipp ut rad",
"Cut column": "",
"Copy row": "Kopier rad",
"Copy column": "",
"Paste row before": "Lim inn rad før",
"Paste column before": "",
"Paste row after": "Lim inn rad etter",
"Paste column after": "",
"Insert column before": "Sett inn kolonne før",
"Insert column after": "Sett inn kolonne etter",
"Delete column": "Slett kolonne",
"Cols": "Kolonner",
"Rows": "Rader",
"Width": "Bredde",
"Height": "Høyde",
"Cell spacing": "Celleavstand",
"Cell padding": "Cellemarg",
"Row clipboard actions": "",
"Column clipboard actions": "",
"Table styles": "",
"Cell styles": "",
"Column header": "",
"Row header": "",
"Table caption": "",
"Caption": "Bildetekst",
"Show caption": "Vis bildetekst",
"Left": "Venstre",
"Center": "Senter",
"Right": "Høyre",
"Cell type": "Celletype",
"Scope": "Omfang",
"Alignment": "Justering",
"Horizontal align": "",
"Vertical align": "",
"Top": "Topp",
"Middle": "Sentrert",
"Bottom": "Bunn",
"Header cell": "Overskriftscelle",
"Row group": "Radgruppe",
"Column group": "Kolonnegruppe",
"Row type": "Radtype",
"Header": "",
"Body": "Brødtekst",
"Footer": "Bunntekst",
"Border color": "Rammefarge",
"Solid": "",
"Dotted": "",
"Dashed": "",
"Double": "",
"Groove": "",
"Ridge": "",
"Inset": "",
"Outset": "",
"Hidden": "",
"Insert template...": "Sett inn mal..",
"Templates": "Maler",
"Template": "Mal",
"Insert Template": "",
"Text color": "Tekstfarge",
"Background color": "Bakgrunnsfarge",
"Custom...": "Tilpasset...",
"Custom color": "Tilpasset farge",
"No color": "Ingen farge",
"Remove color": "Fjern farge",
"Show blocks": "Vis blokker",
"Show invisible characters": "Vis skjulte tegn",
"Word count": "Ordtelling",
"Count": "Opptelling",
"Document": "Dokument",
"Selection": "Utvalg",
"Words": "Ord",
"Words: {0}": "Ord: {0}",
"{0} words": "{0} ord",
"File": "Fil",
"Edit": "Rediger",
"Insert": "Sett inn",
"View": "Vis",
"Format": "",
"Table": "Tabell",
"Tools": "Verktøy",
"Powered by {0}": "Drevet av {0}",
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Tekstredigering. Tast ALT-F9 for meny. Tast ALT-F10 for verktøylinje. Tast ALT-0 for hjelp.",
"Image title": "Bildetittel",
"Border width": "Bordbredde",
"Border style": "Bordstil",
"Error": "Feil",
"Warn": "Advarsel",
"Valid": "Gyldig",
"To open the popup, press Shift+Enter": "For å åpne popup, trykk Shift+Enter",
"Rich Text Area": "",
"Rich Text Area. Press ALT-0 for help.": "Rik-tekstområde. Trykk ALT-0 for hjelp.",
"System Font": "Systemfont",
"Failed to upload image: {0}": "Opplasting av bilde feilet: {0}",
"Failed to load plugin: {0} from url {1}": "Kunne ikke laste tillegg: {0} from url {1}",
"Failed to load plugin url: {0}": "Kunne ikke laste tillegg url: {0}",
"Failed to initialize plugin: {0}": "Kunne ikke initialisere tillegg: {0}",
"example": "eksempel",
"Search": "Søk",
"All": "Alle",
"Currency": "Valuta",
"Text": "Tekst",
"Quotations": "Sitater",
"Mathematical": "Matematisk",
"Extended Latin": "Utvidet latin",
"Symbols": "Symboler",
"Arrows": "Piler",
"User Defined": "Brukerdefinert",
"dollar sign": "dollartegn",
"currency sign": "valutasymbol",
"euro-currency sign": "Euro-valutasymbol",
"colon sign": "kolon-symbol",
"cruzeiro sign": "cruzeiro-symbol",
"french franc sign": "franske franc-symbol",
"lira sign": "lire-symbol",
"mill sign": "mill-symbol",
"naira sign": "naira-symbol",
"peseta sign": "peseta-symbol",
"rupee sign": "rupee-symbol",
"won sign": "won-symbol",
"new sheqel sign": "Ny sheqel-symbol",
"dong sign": "dong-symbol",
"kip sign": "kip-symbol",
"tugrik sign": "tugrik-symbol",
"drachma sign": "drachma-symbol",
"german penny symbol": "tysk penny-symbol",
"peso sign": "peso-symbol",
"guarani sign": "quarani-symbol",
"austral sign": "austral-symbol",
"hryvnia sign": "hryvina-symbol",
"cedi sign": "credi-symbol",
"livre tournois sign": "livre tournois-symbol",
"spesmilo sign": "spesmilo-symbol",
"tenge sign": "tenge-symbol",
"indian rupee sign": "indisk rupee-symbol",
"turkish lira sign": "tyrkisk lire-symbol",
"nordic mark sign": "nordisk mark-symbol",
"manat sign": "manat-symbol",
"ruble sign": "ruble-symbol",
"yen character": "yen-symbol",
"yuan character": "yuan-symbol",
"yuan character, in hong kong and taiwan": "yuan-symbol, i Hongkong og Taiwan",
"yen/yuan character variant one": "yen/yuan-symbol variant en",
"Emojis": "",
"Emojis...": "",
"Loading emojis...": "",
"Could not load emojis": "",
"People": "Mennesker",
"Animals and Nature": "Dyr og natur",
"Food and Drink": "Mat og drikke",
"Activity": "Aktivitet",
"Travel and Places": "Reise og steder",
"Objects": "Objekter",
"Flags": "Flagg",
"Characters": "Tegn",
"Characters (no spaces)": "Tegn (uten mellomrom)",
"{0} characters": "{0} tegn",
"Error: Form submit field collision.": "Feil: Skjemafelt innsendingskollisjon.",
"Error: No form element found.": "Feil: Intet skjemafelt funnet.",
"Color swatch": "Fargepalett",
"Color Picker": "Fargevelger",
"Invalid hex color code: {0}": "",
"Invalid input": "",
"R": "",
"Red component": "",
"G": "",
"Green component": "",
"B": "",
"Blue component": "",
"#": "",
"Hex color code": "",
"Range 0 to 255": "",
"Turquoise": "Turkis",
"Green": "Grønn",
"Blue": "Blå",
"Purple": "Lilla",
"Navy Blue": "Marineblå",
"Dark Turquoise": "Mørk turkis",
"Dark Green": "Mørkegrønn",
"Medium Blue": "Mellomblå",
"Medium Purple": "Medium lilla",
"Midnight Blue": "Midnattblå",
"Yellow": "Gul",
"Orange": "Oransje",
"Red": "Rød",
"Light Gray": "Lys grå",
"Gray": "Grå",
"Dark Yellow": "Mørk gul",
"Dark Orange": "Mørk oransje",
"Dark Red": "Mørkerød",
"Medium Gray": "Medium grå",
"Dark Gray": "Mørk grå",
"Light Green": "Lys grønn",
"Light Yellow": "Lys gul",
"Light Red": "Lys rød",
"Light Purple": "Lys lilla",
"Light Blue": "Lys blå",
"Dark Purple": "Mørk lilla",
"Dark Blue": "Mørk blå",
"Black": "Svart",
"White": "Hvit",
"Switch to or from fullscreen mode": "Bytt til eller fra fullskjermmodus",
"Open help dialog": "Åpne hjelp-dialog",
"history": "historikk",
"styles": "stiler",
"formatting": "formatering",
"alignment": "justering",
"indentation": "innrykk",
"Font": "Skrift",
"Size": "Størrelse",
"More...": "Mer...",
"Select...": "Velg...",
"Preferences": "Innstillinger",
"Yes": "Ja",
"No": "Nei",
"Keyboard Navigation": "Navigering med tastaturet",
"Version": "Versjon",
"Code view": "Kodevisning",
"Open popup menu for split buttons": "Åpne sprettoppmeny for splitt-knapper",
"List Properties": "Listeegenskaper",
"List properties...": "Listeegenskaper ...",
"Start list at number": "Start liste på nummer",
"Line height": "Linjehøyde",
"Dropped file type is not supported": "",
"Loading...": "",
"ImageProxy HTTP error: Rejected request": "",
"ImageProxy HTTP error: Could not find Image Proxy": "",
"ImageProxy HTTP error: Incorrect Image Proxy URL": "",
"ImageProxy HTTP error: Unknown ImageProxy error": ""
});

View File

@ -0,0 +1,462 @@
tinymce.addI18n('pt_PT',{
"Redo": "Refazer",
"Undo": "Anular",
"Cut": "Cortar",
"Copy": "Copiar",
"Paste": "Colar",
"Select all": "Selecionar tudo",
"New document": "Novo documento",
"Ok": "Ok",
"Cancel": "Cancelar",
"Visual aids": "Ajuda visual",
"Bold": "Negrito",
"Italic": "It\u00e1lico",
"Underline": "Sublinhado",
"Strikethrough": "Rasurado",
"Superscript": "Superior \u00e0 linha",
"Subscript": "Inferior \u00e0 linha",
"Clear formatting": "Limpar formata\u00e7\u00e3o",
"Align left": "Alinhar \u00e0 esquerda",
"Align center": "Alinhar ao centro",
"Align right": "Alinhar \u00e0 direita",
"Justify": "Justificar",
"Bullet list": "Lista com marcas",
"Numbered list": "Lista numerada",
"Decrease indent": "Diminuir avan\u00e7o",
"Increase indent": "Aumentar avan\u00e7o",
"Close": "Fechar",
"Formats": "Formatos",
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "O seu navegador n\u00e3o suporta acesso direto \u00e0 \u00e1rea de transfer\u00eancia. Por favor, use os atalhos Ctrl+X\/C\/V do seu teclado.",
"Headers": "Cabe\u00e7alhos",
"Header 1": "Cabe\u00e7alho 1",
"Header 2": "Cabe\u00e7alho 2",
"Header 3": "Cabe\u00e7alho 3",
"Header 4": "Cabe\u00e7alho 4",
"Header 5": "Cabe\u00e7alho 5",
"Header 6": "Cabe\u00e7alho 6",
"Headings": "T\u00edtulos",
"Heading 1": "T\u00edtulo 1",
"Heading 2": "T\u00edtulo 2",
"Heading 3": "T\u00edtulo 3",
"Heading 4": "T\u00edtulo 4",
"Heading 5": "T\u00edtulo 5",
"Heading 6": "T\u00edtulo 6",
"Preformatted": "Pr\u00e9-formatado",
"Div": "Div",
"Pre": "Pre",
"Code": "C\u00f3digo",
"Paragraph": "Par\u00e1grafo",
"Blockquote": "Blockquote",
"Inline": "Inline",
"Blocks": "Blocos",
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "O comando colar est\u00e1 em modo de texto simples. O conte\u00fado ser\u00e1 colado como texto simples at\u00e9 desativar esta op\u00e7\u00e3o.",
"Fonts": "Tipos de letra",
"Font Sizes": "Tamanhos dos tipos de letra",
"Class": "Classe",
"Browse for an image": "Procurar uma imagem",
"OR": "OU",
"Drop an image here": "Largar aqui uma imagem",
"Upload": "Carregar",
"Block": "Bloco",
"Align": "Alinhar",
"Default": "Padr\u00e3o",
"Circle": "C\u00edrculo",
"Disc": "Disco",
"Square": "Quadrado",
"Lower Alpha": "a. b. c. ...",
"Lower Greek": "\\u03b1. \\u03b2. \\u03b3. ...",
"Lower Roman": "i. ii. iii. ...",
"Upper Alpha": "A. B. C. ...",
"Upper Roman": "I. II. III. ...",
"Anchor...": "\u00c2ncora...",
"Name": "Nome",
"Id": "ID",
"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "O ID deve come\u00e7ar com uma letra, seguido apenas por letras, n\u00fameros, pontos, dois pontos, tra\u00e7os ou sobtra\u00e7os.",
"You have unsaved changes are you sure you want to navigate away?": "Existem altera\u00e7\u00f5es que ainda n\u00e3o foram guardadas. Tem a certeza que pretende sair?",
"Restore last draft": "Restaurar o \u00faltimo rascunho",
"Special character...": "Car\u00e1ter especial...",
"Source code": "C\u00f3digo fonte",
"Insert\/Edit code sample": "Inserir\/editar amostra de c\u00f3digo",
"Language": "Idioma",
"Code sample...": "Amostra de c\u00f3digo...",
"Color Picker": "Seletor de cores",
"R": "R",
"G": "G",
"B": "B",
"Left to right": "Da esquerda para a direita",
"Right to left": "Da direita para a esquerda",
"Emoticons": "Emo\u00e7\u00f5es",
"Emoticons...": "\u00cdcones expressivos...",
"Metadata and Document Properties": "Metadados e propriedades do documento",
"Title": "T\u00edtulo",
"Keywords": "Palavras-chave",
"Description": "Descri\u00e7\u00e3o",
"Robots": "Rob\u00f4s",
"Author": "Autor",
"Encoding": "Codifica\u00e7\u00e3o",
"Fullscreen": "Ecr\u00e3 completo",
"Action": "A\u00e7\u00e3o",
"Shortcut": "Atalho",
"Help": "Ajuda",
"Address": "Endere\u00e7o",
"Focus to menubar": "Foco na barra de menu",
"Focus to toolbar": "Foco na barra de ferramentas",
"Focus to element path": "Foco no caminho do elemento",
"Focus to contextual toolbar": "Foco na barra de contexto",
"Insert link (if link plugin activated)": "Inserir hiperliga\u00e7\u00e3o (se o plugin de liga\u00e7\u00f5es estiver ativado)",
"Save (if save plugin activated)": "Guardar (se o plugin de guardar estiver ativado)",
"Find (if searchreplace plugin activated)": "Pesquisar (se o plugin pesquisar e substituir estiver ativado)",
"Plugins installed ({0}):": "Plugins instalados ({0}):",
"Premium plugins:": "Plugins comerciais:",
"Learn more...": "Saiba mais...",
"You are using {0}": "Est\u00e1 a usar {0}",
"Plugins": "Plugins",
"Handy Shortcuts": "Atalhos \u00fateis",
"Horizontal line": "Linha horizontal",
"Insert\/edit image": "Inserir\/editar imagem",
"Alternative description": "Descri\u00e7\u00e3o alternativa",
"Accessibility": "Acessibilidade",
"Image is decorative": "Imagem \u00e9 decorativa",
"Source": "Localiza\u00e7\u00e3o",
"Dimensions": "Dimens\u00f5es",
"Constrain proportions": "Manter propor\u00e7\u00f5es",
"General": "Geral",
"Advanced": "Avan\u00e7ado",
"Style": "Estilo",
"Vertical space": "Espa\u00e7amento vertical",
"Horizontal space": "Espa\u00e7amento horizontal",
"Border": "Contorno",
"Insert image": "Inserir imagem",
"Image...": "Imagem...",
"Image list": "Lista de imagens",
"Rotate counterclockwise": "Rota\u00e7\u00e3o anti-hor\u00e1ria",
"Rotate clockwise": "Rota\u00e7\u00e3o hor\u00e1ria",
"Flip vertically": "Inverter verticalmente",
"Flip horizontally": "Inverter horizontalmente",
"Edit image": "Editar imagem",
"Image options": "Op\u00e7\u00f5es de imagem",
"Zoom in": "Mais zoom",
"Zoom out": "Menos zoom",
"Crop": "Recortar",
"Resize": "Redimensionar",
"Orientation": "Orienta\u00e7\u00e3o",
"Brightness": "Brilho",
"Sharpen": "Mais nitidez",
"Contrast": "Contraste",
"Color levels": "N\u00edveis de cor",
"Gamma": "Gama",
"Invert": "Inverter",
"Apply": "Aplicar",
"Back": "Voltar",
"Insert date\/time": "Inserir data\/hora",
"Date\/time": "Data\/hora",
"Insert\/edit link": "Inserir\/editar liga\u00e7\u00e3o",
"Text to display": "Texto a exibir",
"Url": "URL",
"Open link in...": "Abrir liga\u00e7\u00e3o em...",
"Current window": "Janela atual",
"None": "Nenhum",
"New window": "Nova janela",
"Open link": "Abrir liga\u00e7\u00e3o",
"Remove link": "Remover liga\u00e7\u00e3o",
"Anchors": "\u00c2ncora",
"Link...": "Liga\u00e7\u00e3o...",
"Paste or type a link": "Copiar ou escrever uma hiperliga\u00e7\u00e3o",
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "O URL que indicou parece ser um endere\u00e7o de email. Quer adicionar o prefixo mailto: tal como necess\u00e1rio?",
"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "O URL que indicou parece ser um endere\u00e7o web. Quer adicionar o prefixo http:\/\/ tal como necess\u00e1rio?",
"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "O URL que introduziu parece ser uma liga\u00e7\u00e3o externa. Deseja adicionar-lhe o prefixo https:\/\/ ?",
"Link list": "Lista de liga\u00e7\u00f5es",
"Insert video": "Inserir v\u00eddeo",
"Insert\/edit video": "Inserir\/editar v\u00eddeo",
"Insert\/edit media": "Inserir\/editar media",
"Alternative source": "Localiza\u00e7\u00e3o alternativa",
"Alternative source URL": "URL da origem alternativa",
"Media poster (Image URL)": "Publicador de media (URL da imagem)",
"Paste your embed code below:": "Colar c\u00f3digo para embeber:",
"Embed": "Embeber",
"Media...": "Media...",
"Nonbreaking space": "Espa\u00e7o n\u00e3o quebr\u00e1vel",
"Page break": "Quebra de p\u00e1gina",
"Paste as text": "Colar como texto",
"Preview": "Pr\u00e9-visualizar",
"Print...": "Imprimir...",
"Save": "Guardar",
"Find": "Pesquisar",
"Replace with": "Substituir por",
"Replace": "Substituir",
"Replace all": "Substituir tudo",
"Previous": "Anterior",
"Next": "Pr\u00f3ximo",
"Find and Replace": "Pesquisar e substituir",
"Find and replace...": "Localizar e substituir...",
"Could not find the specified string.": "N\u00e3o foi poss\u00edvel localizar o termo especificado.",
"Match case": "Diferenciar mai\u00fasculas e min\u00fasculas",
"Find whole words only": "Localizar apenas palavras inteiras",
"Find in selection": "Pesquisar na selec\u00e7\u00e3o",
"Spellcheck": "Corretor ortogr\u00e1fico",
"Spellcheck Language": "Idioma de verifica\u00e7\u00e3o lingu\u00edstica",
"No misspellings found.": "N\u00e3o foram encontrados erros ortogr\u00e1ficos.",
"Ignore": "Ignorar",
"Ignore all": "Ignorar tudo",
"Finish": "Concluir",
"Add to Dictionary": "Adicionar ao dicion\u00e1rio",
"Insert table": "Inserir tabela",
"Table properties": "Propriedades da tabela",
"Delete table": "Eliminar tabela",
"Cell": "C\u00e9lula",
"Row": "Linha",
"Column": "Coluna",
"Cell properties": "Propriedades da c\u00e9lula",
"Merge cells": "Unir c\u00e9lulas",
"Split cell": "Dividir c\u00e9lula",
"Insert row before": "Inserir linha antes",
"Insert row after": "Inserir linha depois",
"Delete row": "Eliminar linha",
"Row properties": "Propriedades da linha",
"Cut row": "Cortar linha",
"Copy row": "Copiar linha",
"Paste row before": "Colar linha antes",
"Paste row after": "Colar linha depois",
"Insert column before": "Inserir coluna antes",
"Insert column after": "Inserir coluna depois",
"Delete column": "Eliminar coluna",
"Cols": "Colunas",
"Rows": "Linhas",
"Width": "Largura",
"Height": "Altura",
"Cell spacing": "Espa\u00e7amento entre c\u00e9lulas",
"Cell padding": "Espa\u00e7amento interno da c\u00e9lula",
"Caption": "Legenda",
"Show caption": "Mostrar legenda",
"Left": "Esquerda",
"Center": "Centro",
"Right": "Direita",
"Cell type": "Tipo de c\u00e9lula",
"Scope": "Escopo",
"Alignment": "Alinhamento",
"H Align": "Alinhamento H",
"V Align": "Alinhamento V",
"Top": "Superior",
"Middle": "Meio",
"Bottom": "Inferior",
"Header cell": "C\u00e9lula de cabe\u00e7alho",
"Row group": "Agrupar linha",
"Column group": "Agrupar coluna",
"Row type": "Tipo de linha",
"Header": "Cabe\u00e7alho",
"Body": "Corpo",
"Footer": "Rodap\u00e9",
"Border color": "Cor de contorno",
"Insert template...": "Inserir modelo...",
"Templates": "Modelos",
"Template": "Tema",
"Text color": "Cor do texto",
"Background color": "Cor de fundo",
"Custom...": "Personalizada...",
"Custom color": "Cor personalizada",
"No color": "Sem cor",
"Remove color": "Remover cor",
"Table of Contents": "\u00cdndice",
"Show blocks": "Mostrar blocos",
"Show invisible characters": "Mostrar caracteres invis\u00edveis",
"Word count": "Contagem de palavras",
"Count": "Contagem",
"Document": "Documento",
"Selection": "Sele\u00e7\u00e3o",
"Words": "Palavras",
"Words: {0}": "Palavras: {0}",
"{0} words": "{0} palavras",
"File": "Ficheiro",
"Edit": "Editar",
"Insert": "Inserir",
"View": "Ver",
"Format": "Formatar",
"Table": "Tabela",
"Tools": "Ferramentas",
"Powered by {0}": "Criado em {0}",
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Caixa de texto formatado. Pressione ALT-F9 para exibir o menu. Pressione ALT-F10 para exibir a barra de ferramentas. Pressione ALT-0 para exibir a ajuda",
"Image title": "T\u00edtulo da imagem",
"Border width": "Largura do limite",
"Border style": "Estilo do limite",
"Error": "Erro",
"Warn": "Aviso",
"Valid": "V\u00e1lido",
"To open the popup, press Shift+Enter": "Para abrir o pop-up, prima Shift+Enter",
"Rich Text Area. Press ALT-0 for help.": "\u00c1rea de texto formatado. Prima ALT-0 para exibir a ajuda.",
"System Font": "Tipo de letra do sistema",
"Failed to upload image: {0}": "Falha ao carregar imagem: {0}",
"Failed to load plugin: {0} from url {1}": "Falha ao carregar plugin: {0} do URL {1}",
"Failed to load plugin url: {0}": "Falha ao carregar o URL do plugin: {0}",
"Failed to initialize plugin: {0}": "Falha ao inicializar plugin: {0}",
"example": "exemplo",
"Search": "Pesquisar",
"All": "Tudo",
"Currency": "Moeda",
"Text": "Texto",
"Quotations": "Aspas",
"Mathematical": "Matem\u00e1tico",
"Extended Latin": "Carateres latinos estendidos",
"Symbols": "S\u00edmbolos",
"Arrows": "Setas",
"User Defined": "Definido pelo utilizador",
"dollar sign": "cifr\u00e3o",
"currency sign": "sinal monet\u00e1rio",
"euro-currency sign": "sinal monet\u00e1rio do euro",
"colon sign": "sinal de dois pontos",
"cruzeiro sign": "sinal de cruzeiro",
"french franc sign": "sinal de franco franc\u00eas",
"lira sign": "sinal de lira",
"mill sign": "sinal de por mil",
"naira sign": "sinal de naira",
"peseta sign": "sinal de peseta",
"rupee sign": "sinal de r\u00fapia",
"won sign": "sinal de won",
"new sheqel sign": "sinal de novo sheqel",
"dong sign": "sinal de dong",
"kip sign": "sinal kip",
"tugrik sign": "sinal tugrik",
"drachma sign": "sinal drachma",
"german penny symbol": "sinal de penny alem\u00e3o",
"peso sign": "sinal de peso",
"guarani sign": "sinal de guarani",
"austral sign": "sinal de austral",
"hryvnia sign": "sinal hryvnia",
"cedi sign": "sinal de cedi",
"livre tournois sign": "sinal de libra de tours",
"spesmilo sign": "sinal de spesmilo",
"tenge sign": "sinal de tengue",
"indian rupee sign": "sinal de rupia indiana",
"turkish lira sign": "sinal de lira turca",
"nordic mark sign": "sinal de marca n\u00f3rdica",
"manat sign": "sinal manat",
"ruble sign": "sinal de rublo",
"yen character": "sinal de iene",
"yuan character": "sinal de iuane",
"yuan character, in hong kong and taiwan": "sinal de iuane, em Hong Kong e Taiwan",
"yen\/yuan character variant one": "variante um de sinal de iene\/iuane",
"Loading emoticons...": "A carregar \u00edcones expressivos...",
"Could not load emoticons": "N\u00e3o foi poss\u00edvel carregar \u00edcones expressivos",
"People": "Pessoas",
"Animals and Nature": "Animais e natureza",
"Food and Drink": "Comida e bebida",
"Activity": "Atividade",
"Travel and Places": "Viagens e lugares",
"Objects": "Objetos",
"Flags": "Bandeiras",
"Characters": "Carateres",
"Characters (no spaces)": "Carateres (sem espa\u00e7os)",
"{0} characters": "{0} carateres",
"Error: Form submit field collision.": "Erro: conflito no campo de submiss\u00e3o de formul\u00e1rio.",
"Error: No form element found.": "Erro: nenhum elemento de formul\u00e1rio encontrado.",
"Update": "Atualizar",
"Color swatch": "Cole\u00e7\u00e3o de cores",
"Turquoise": "Turquesa",
"Green": "Verde",
"Blue": "Azul",
"Purple": "P\u00farpura",
"Navy Blue": "Azul-atl\u00e2ntico",
"Dark Turquoise": "Turquesa escuro",
"Dark Green": "Verde escuro",
"Medium Blue": "Azul interm\u00e9dio",
"Medium Purple": "P\u00farpura interm\u00e9dio",
"Midnight Blue": "Azul muito escuro",
"Yellow": "Amarelo",
"Orange": "Laranja",
"Red": "Vermelho",
"Light Gray": "Cinzento claro",
"Gray": "Cinzento",
"Dark Yellow": "Amarelo escuro",
"Dark Orange": "Laranja escuro",
"Dark Red": "Vermelho escuro",
"Medium Gray": "Cinzento m\u00e9dio",
"Dark Gray": "Cinzento escuro",
"Light Green": "Verde claro",
"Light Yellow": "Amarelo claro",
"Light Red": "Vermelho claro",
"Light Purple": "P\u00farpura claro",
"Light Blue": "Azul claro",
"Dark Purple": "P\u00farpura escuro",
"Dark Blue": "Azul escuro",
"Black": "Preto",
"White": "Branco",
"Switch to or from fullscreen mode": "Entrar ou sair do modo de ecr\u00e3 inteiro",
"Open help dialog": "Abrir caixa de di\u00e1logo Ajuda",
"history": "hist\u00f3rico",
"styles": "estilos",
"formatting": "formata\u00e7\u00e3o",
"alignment": "alinhamento",
"indentation": "avan\u00e7o",
"Font": "Tipo de letra",
"Size": "Tamanho",
"More...": "Mais...",
"Select...": "Selecionar...",
"Preferences": "Prefer\u00eancias",
"Yes": "Sim",
"No": "N\u00e3o",
"Keyboard Navigation": "Navega\u00e7\u00e3o com teclado",
"Version": "Vers\u00e3o",
"Code view": "Vista do c\u00f3digo-fonte",
"Open popup menu for split buttons": "Abrir o menu popup para bot\u00f5es divididos",
"List Properties": "Propriedades da lista",
"List properties...": "Propriedades da lista\u2026",
"Start list at number": "Come\u00e7ar a lista pelo n\u00famero",
"Line height": "Altura da linha",
"comments": "coment\u00e1rios",
"Format Painter": "Pincel de formata\u00e7\u00e3o",
"Insert\/edit iframe": "Inserir\/editar iframe",
"Capitalization": "Capitaliza\u00e7\u00e3o",
"lowercase": "min\u00fasculas",
"UPPERCASE": "MAI\u00daSCULAS",
"Title Case": "Iniciais mai\u00fasculas",
"permanent pen": "caneta permanente",
"Permanent Pen Properties": "Propriedades da Caneta Permanente",
"Permanent pen properties...": "Propriedades da caneta permanente...",
"case change": "mudan\u00e7a de capitaliza\u00e7\u00e3o",
"page embed": "incorporar p\u00e1gina",
"Advanced sort...": "Ordena\u00e7\u00e3o avan\u00e7ada\u2026",
"Advanced Sort": "Ordena\u00e7\u00e3o avan\u00e7ada",
"Sort table by column ascending": "Ordenar tabela por coluna ascendente",
"Sort table by column descending": "Ordenar tabela por coluna descendente",
"Sort": "Ordenar",
"Order": "Ordem",
"Sort by": "Ordenar por",
"Ascending": "Ascendente",
"Descending": "Descendente",
"Column {0}": "Coluna {0}",
"Row {0}": "Linha {0}",
"Spellcheck...": "Verifica\u00e7\u00e3o ortogr\u00e1fica...",
"Misspelled word": "Palavra mal escrita",
"Suggestions": "Sugest\u00f5es",
"Change": "Alterar",
"Finding word suggestions": "Encontrar sugest\u00f5es de palavras",
"Success": "Sucesso",
"Repair": "Reparar",
"Issue {0} of {1}": "Problema {0} de {1}",
"Images must be marked as decorative or have an alternative text description": "As imagens devem ser marcadas como decorativas ou ter uma descri\u00e7\u00e3o textual alternativa",
"Images must have an alternative text description. Decorative images are not allowed.": "As imagens devem ter uma descri\u00e7\u00e3o textual alternativa. N\u00e3o s\u00e3o permitidas imagens meramente decorativas.",
"Or provide alternative text:": "Ou forne\u00e7a um texto alternativo:",
"Make image decorative:": "Marque a imagem como decorativa:",
"ID attribute must be unique": "O atributo ID tem de ser \u00fanico",
"Make ID unique": "Tornar o ID \u00fanico",
"Keep this ID and remove all others": "Mantenha este ID e remova todos os outros",
"Remove this ID": "Remover este ID",
"Remove all IDs": "Remover todos os IDs",
"Checklist": "Lista de verifica\u00e7\u00e3o",
"Anchor": "\u00c2ncora",
"Special character": "Car\u00e1cter especial",
"Code sample": "Amostra de c\u00f3digo",
"Color": "Cor",
"Document properties": "Propriedades do documento",
"Image description": "Descri\u00e7\u00e3o da imagem",
"Image": "Imagem",
"Insert link": "Inserir liga\u00e7\u00e3o",
"Target": "Alvo",
"Link": "Liga\u00e7\u00e3o",
"Poster": "Autor",
"Media": "Media",
"Print": "Imprimir",
"Prev": "Anterior",
"Find and replace": "Pesquisar e substituir",
"Whole words": "Palavras completas",
"Insert template": "Inserir modelo"
});

View File

@ -36,7 +36,7 @@ function init(logType) {
d.innerHTML = "loading ..."; d.innerHTML = "loading ...";
$.ajax({ $.ajax({
url: window.location.pathname + "/../../ajax/log/" + logType, url: getPath() + "/ajax/log/" + logType,
datatype: "text", datatype: "text",
cache: false cache: false
}) })

View File

@ -85,14 +85,6 @@ $(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) { $(document).on("click", ".postAction", function (event) {
// $(".sendbutton").on("click", "body", function(event) { // $(".sendbutton").on("click", "body", function(event) {
@ -100,7 +92,6 @@ $(document).on("click", ".postAction", function (event) {
}); });
// 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')) {
@ -151,13 +142,13 @@ $("#form-upload").uploadprogress({
}); });
$(document).ready(function() { $(document).ready(function() {
var inp = $('#query').first() var inp = $('#query').first()
if (inp.length) { if (inp.length) {
var val = inp.val() var val = inp.val()
if (val.length) { if (val.length) {
inp.val('').blur().focus().val(val) inp.val('').blur().focus().val(val)
}
} }
}
}); });
$(".session").click(function() { $(".session").click(function() {
@ -313,7 +304,7 @@ $(function() {
} }
function fillFileTable(path, type, folder, filt) { function fillFileTable(path, type, folder, filt) {
var request_path = "/../../ajax/pathchooser/"; var request_path = "/ajax/pathchooser/";
$.ajax({ $.ajax({
dataType: "json", dataType: "json",
data: { data: {
@ -321,7 +312,7 @@ $(function() {
folder: folder, folder: folder,
filter: filt filter: filt
}, },
url: window.location.pathname + request_path, url: getPath() + request_path,
success: function success(data) { success: function success(data) {
if ($("#element_selected").text() ==="") { if ($("#element_selected").text() ==="") {
$("#element_selected").text(data.cwd); $("#element_selected").text(data.cwd);
@ -342,7 +333,6 @@ $(function() {
} else { } else {
$("#parent").addClass('hidden') $("#parent").addClass('hidden')
} }
// console.log(data);
data.files.forEach(function(entry) { data.files.forEach(function(entry) {
if(entry.type === "dir") { if(entry.type === "dir") {
var type = "<span class=\"glyphicon glyphicon-folder-close\"></span>"; var type = "<span class=\"glyphicon glyphicon-folder-close\"></span>";
@ -364,12 +354,6 @@ $(function() {
layoutMode : "fitRows" layoutMode : "fitRows"
}); });
$(".grid").isotope({
// options
itemSelector : ".grid-item",
layoutMode : "fitColumns"
});
if ($(".load-more").length && $(".next").length) { if ($(".load-more").length && $(".next").length) {
var $loadMore = $(".load-more .row").infiniteScroll({ var $loadMore = $(".load-more .row").infiniteScroll({
debug: false, debug: false,
@ -440,7 +424,7 @@ $(function() {
} }
$.ajax({ $.ajax({
dataType: "json", dataType: "json",
url: window.location.pathname + "/../../get_update_status", url: getPath() + "/get_update_status",
success: function success(data) { success: function success(data) {
$this.html(buttonText); $this.html(buttonText);
@ -544,6 +528,7 @@ $(function() {
$("#bookDetailsModal") $("#bookDetailsModal")
.on("show.bs.modal", function(e) { .on("show.bs.modal", function(e) {
$("#flash_danger").remove(); $("#flash_danger").remove();
$("#flash_success").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
@ -656,7 +641,6 @@ $(function() {
); );
}); });
$("#user_submit").click(function() { $("#user_submit").click(function() {
this.closest("form").submit(); this.closest("form").submit();
}); });
@ -688,7 +672,7 @@ $(function() {
$.ajax({ $.ajax({
method:"post", method:"post",
dataType: "json", dataType: "json",
url: window.location.pathname + "/../../ajax/simulatedbchange", url: getPath() + "/ajax/simulatedbchange",
data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()}, data: {config_calibre_dir: $("#config_calibre_dir").val(), csrf_token: $("input[name='csrf_token']").val()},
success: function success(data) { success: function success(data) {
if ( data.change ) { if ( data.change ) {
@ -715,17 +699,16 @@ $(function() {
e.stopPropagation(); e.stopPropagation();
this.blur(); this.blur();
window.scrollTo({top: 0, behavior: 'smooth'}); window.scrollTo({top: 0, behavior: 'smooth'});
var request_path = "/../../admin/ajaxconfig"; var request_path = "/admin/ajaxconfig";
var loader = "/../..";
$("#flash_success").remove(); $("#flash_success").remove();
$("#flash_danger").remove(); $("#flash_danger").remove();
$.post(window.location.pathname + request_path, $(this).closest("form").serialize(), function(data) { $.post(getPath() + request_path, $(this).closest("form").serialize(), function(data) {
$('#config_upload_formats').val(data.config_upload); $('#config_upload_formats').val(data.config_upload);
if(data.reboot) { if(data.reboot) {
$("#spinning_success").show(); $("#spinning_success").show();
var rebootInterval = setInterval(function(){ var rebootInterval = setInterval(function(){
$.get({ $.get({
url:window.location.pathname + "/../../admin/alive", url:getPath() + "/admin/alive",
success: function (d, statusText, xhr) { success: function (d, statusText, xhr) {
if (xhr.status < 400) { if (xhr.status < 400) {
$("#spinning_success").hide(); $("#spinning_success").hide();
@ -751,7 +734,6 @@ $(function() {
$(this).data('value'), $(this).data('value'),
function(value){ function(value){
postButton(event, $("#delete_shelf").data("action")); postButton(event, $("#delete_shelf").data("action"));
// $("#delete_shelf").closest("form").submit()
} }
); );

65
cps/static/js/password.js Normal file
View File

@ -0,0 +1,65 @@
/* 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/>.
*/
$(document).ready(function() {
i18next.use(i18nextHttpBackend).init({
lng: $('#password').data("lang"),
debug: false,
fallbackLng: 'en',
backend: {
loadPath: getPath() + "/static/js/libs/pwstrength/locales/{{lng}}.json",
},
}, function () {
if ($('#password').data("verify")) {
// Initialized and ready to go
var options = {};
options.common = {
minChar: $('#password').data("min"),
maxChar: -1
}
options.ui = {
bootstrap3: true,
showProgressBar: false,
showErrors: true,
showVerdicts: false,
}
options.rules= {
specialCharClass: "(?=.*?[^A-Za-z\\s0-9])",
activated: {
wordNotEmail: false,
wordMinLength: $('#password').data("min"),
// wordMaxLength: false,
// wordInvalidChar: true,
wordSimilarToUsername: false,
wordSequences: false,
wordTwoCharacterClasses: false,
wordRepetitions: false,
wordLowercase: $('#password').data("lower") === "True" ? true : false,
wordUppercase: $('#password').data("upper") === "True" ? true : false,
wordOneNumber: $('#password').data("number") === "True" ? true : false,
wordThreeNumbers: false,
wordOneSpecialChar: $('#password').data("special") === "True" ? true : false,
// wordTwoSpecialChar: true,
wordUpperLowerCombo: false,
wordLetterNumberCombo: false,
wordLetterNumberCharCombo: false
}
}
$('#password').pwstrength(options);
}
});
});

View File

@ -49,7 +49,7 @@ $(function() {
method: "post", method: "post",
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
url: window.location.pathname + "/../ajax/canceltask", url: getPath() + "/ajax/canceltask",
data: JSON.stringify({"task_id": taskId}), data: JSON.stringify({"task_id": taskId}),
}); });
}); });
@ -634,7 +634,7 @@ function UserActions (value, row) {
/* Function for cancelling tasks */ /* Function for cancelling tasks */
function TaskActions (value, row) { function TaskActions (value, row) {
var cancellableStats = [0, 1, 2]; var cancellableStats = [0, 2];
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) { if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
return [ return [
"<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">", "<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">",

View File

@ -64,13 +64,13 @@ class TaskConvert(CalibreTask):
if df: if df:
datafile = os.path.join(config.config_calibre_dir, datafile = os.path.join(config.config_calibre_dir,
cur_book.path, cur_book.path,
data.name + u"." + self.settings['old_book_format'].lower()) data.name + "." + self.settings['old_book_format'].lower())
if not os.path.exists(os.path.join(config.config_calibre_dir, cur_book.path)): if not os.path.exists(os.path.join(config.config_calibre_dir, cur_book.path)):
os.makedirs(os.path.join(config.config_calibre_dir, cur_book.path)) os.makedirs(os.path.join(config.config_calibre_dir, cur_book.path))
df.GetContentFile(datafile) df.GetContentFile(datafile)
worker_db.session.close() worker_db.session.close()
else: else:
error_message = _(u"%(format)s not found on Google Drive: %(fn)s", error_message = _("%(format)s not found on Google Drive: %(fn)s",
format=self.settings['old_book_format'], format=self.settings['old_book_format'],
fn=data.name + "." + self.settings['old_book_format'].lower()) fn=data.name + "." + self.settings['old_book_format'].lower())
worker_db.session.close() worker_db.session.close()
@ -78,7 +78,7 @@ class TaskConvert(CalibreTask):
filename = self._convert_ebook_format() filename = self._convert_ebook_format()
if config.config_use_google_drive: if config.config_use_google_drive:
os.remove(self.file_path + u'.' + self.settings['old_book_format'].lower()) os.remove(self.file_path + '.' + self.settings['old_book_format'].lower())
if filename: if filename:
if config.config_use_google_drive: if config.config_use_google_drive:
@ -89,7 +89,7 @@ class TaskConvert(CalibreTask):
# if we're sending to E-Reader after converting, create a one-off task and run it immediately # if we're sending to E-Reader 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 = N_(u"%(book)s send to E-Reader", book=escape(self.title)) EmailText = N_("%(book)s send to E-Reader", 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,
@ -107,8 +107,8 @@ class TaskConvert(CalibreTask):
local_db = db.CalibreDB(expire_on_commit=False, init=True) local_db = db.CalibreDB(expire_on_commit=False, init=True)
file_path = self.file_path file_path = self.file_path
book_id = self.book_id book_id = self.book_id
format_old_ext = u'.' + self.settings['old_book_format'].lower() format_old_ext = '.' + self.settings['old_book_format'].lower()
format_new_ext = u'.' + self.settings['new_book_format'].lower() format_new_ext = '.' + self.settings['new_book_format'].lower()
# check to see if destination format already exists - or if book is in database # check to see if destination format already exists - or if book is in database
# 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
@ -133,7 +133,7 @@ class TaskConvert(CalibreTask):
local_db.session.rollback() local_db.session.rollback()
log.error("Database error: %s", e) log.error("Database error: %s", e)
local_db.session.close() local_db.session.close()
self._handleError(N_("Database error: %(error)s.", error=e)) self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
return return
self._handleSuccess() self._handleSuccess()
local_db.session.close() local_db.session.close()
@ -150,7 +150,7 @@ 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):
self._handleError(N_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath)) self._handleError(N_("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)
@ -199,7 +199,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, N_(u"Kepubify-converter failed: %(error)s", error=e) return 1, N_("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()
@ -220,7 +220,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, N_(u"Converted file not found or more than one file in folder %(folder)s", return 1, N_("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
@ -244,7 +244,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, N_(u"Ebook-converter failed: %(error)s", error=e) return 1, N_("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()

View File

@ -18,6 +18,7 @@
import os import os
import smtplib import smtplib
import ssl
import threading import threading
import socket import socket
import mimetypes import mimetypes
@ -152,7 +153,7 @@ class TaskEmail(CalibreTask):
main_type, sub_type = content_type.split('/', 1) main_type, sub_type = content_type.split('/', 1)
message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=self.attachment) message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=self.attachment)
else: else:
self._handleError(u"Attachment not found") self._handleError("Attachment not found")
return return
return message return message
@ -166,7 +167,7 @@ class TaskEmail(CalibreTask):
self.send_gmail_email(msg) self.send_gmail_email(msg)
except MemoryError as e: except MemoryError as e:
log.error_or_exception(e, stacklevel=3) log.error_or_exception(e, stacklevel=3)
self._handleError(u'MemoryError sending e-mail: {}'.format(str(e))) self._handleError('MemoryError sending e-mail: {}'.format(str(e)))
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e: except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e:
log.error_or_exception(e, stacklevel=3) log.error_or_exception(e, stacklevel=3)
if hasattr(e, "smtp_error"): if hasattr(e, "smtp_error"):
@ -177,13 +178,13 @@ class TaskEmail(CalibreTask):
text = '\n'.join(e.args) text = '\n'.join(e.args)
else: else:
text = '' text = ''
self._handleError(u'Smtplib Error sending e-mail: {}'.format(text)) self._handleError('Smtplib Error sending e-mail: {}'.format(text))
except (socket.error) as e: except (socket.error) as e:
log.error_or_exception(e, stacklevel=3) log.error_or_exception(e, stacklevel=3)
self._handleError(u'Socket Error sending e-mail: {}'.format(e.strerror)) self._handleError('Socket Error sending e-mail: {}'.format(e.strerror))
except Exception as ex: except Exception as ex:
log.error_or_exception(ex, stacklevel=3) log.error_or_exception(ex, stacklevel=3)
self._handleError(u'Error sending e-mail: {}'.format(ex)) self._handleError('Error sending e-mail: {}'.format(ex))
def send_standard_email(self, msg): def send_standard_email(self, msg):
use_ssl = int(self.settings.get('mail_use_ssl', 0)) use_ssl = int(self.settings.get('mail_use_ssl', 0))
@ -192,8 +193,9 @@ class TaskEmail(CalibreTask):
# on python3 debugoutput is caught with overwritten _print_debug function # on python3 debugoutput is caught with overwritten _print_debug function
log.debug("Start sending e-mail") log.debug("Start sending e-mail")
if use_ssl == 2: if use_ssl == 2:
context = ssl.create_default_context()
self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"], self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"],
timeout=timeout) timeout=timeout, context=context)
else: else:
self.asyncSMTP = Email(self.settings["mail_server"], self.settings["mail_port"], timeout=timeout) self.asyncSMTP = Email(self.settings["mail_server"], self.settings["mail_port"], timeout=timeout)
@ -201,9 +203,10 @@ class TaskEmail(CalibreTask):
if logger.is_debug_enabled(): if logger.is_debug_enabled():
self.asyncSMTP.set_debuglevel(1) self.asyncSMTP.set_debuglevel(1)
if use_ssl == 1: if use_ssl == 1:
self.asyncSMTP.starttls() context = ssl.create_default_context()
if self.settings["mail_password"]: self.asyncSMTP.starttls(context=context)
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"])) if self.settings["mail_password_e"]:
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password_e"]))
# Convert message to something to send # Convert message to something to send
fp = StringIO() fp = StringIO()
@ -257,7 +260,7 @@ class TaskEmail(CalibreTask):
file_.close() file_.close()
except IOError as e: except IOError as e:
log.error_or_exception(e, stacklevel=3) log.error_or_exception(e, stacklevel=3)
log.error(u'The requested file could not be read. Maybe wrong permissions?') log.error('The requested file could not be read. Maybe wrong permissions?')
return None return None
return data return data

View File

@ -17,13 +17,12 @@
# 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 os
from lxml import objectify
from urllib.request import urlopen from urllib.request import urlopen
from lxml import etree from lxml import etree
from html import escape
from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED from cps import config, db, gdriveutils, logger
from cps.services.worker import CalibreTask
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
OPF_NAMESPACE = "http://www.idpf.org/2007/opf" OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
@ -74,7 +73,10 @@ class TaskBackupMetadata(CalibreTask):
def backup_metadata(self): def backup_metadata(self):
try: try:
metadata_backup = self.calibre_db.session.query(db.Metadata_Dirtied).all() metadata_backup = self.calibre_db.session.query(db.Metadata_Dirtied).all()
custom_columns = self.calibre_db.session.query(db.CustomColumns).order_by(db.CustomColumns.label).all() custom_columns = (self.calibre_db.session.query(db.CustomColumns)
.filter(db.CustomColumns.mark_for_delete == 0)
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
.order_by(db.CustomColumns.label).all())
count = len(metadata_backup) count = len(metadata_backup)
i = 0 i = 0
for backup in metadata_backup: for backup in metadata_backup:
@ -86,7 +88,6 @@ class TaskBackupMetadata(CalibreTask):
self.open_metadata(book, custom_columns) self.open_metadata(book, custom_columns)
else: else:
self.log.error("Book {} not found in database".format(backup.book)) self.log.error("Book {} not found in database".format(backup.book))
# self._handleError("Book {} not found in database".format(backup.book))
i += 1 i += 1
self.progress = (1.0 / count) * i self.progress = (1.0 / count) * i
self._handleSuccess() self._handleSuccess()
@ -100,56 +101,35 @@ class TaskBackupMetadata(CalibreTask):
self.calibre_db.session.close() self.calibre_db.session.close()
def open_metadata(self, book, custom_columns): def open_metadata(self, book, custom_columns):
package = self.create_new_metadata_backup(book, custom_columns)
if config.config_use_google_drive: if config.config_use_google_drive:
if not gdriveutils.is_gdrive_ready(): if not gdriveutils.is_gdrive_ready():
raise Exception('Google Drive is configured but not ready') raise Exception('Google Drive is configured but not ready')
web_content_link = gdriveutils.get_metadata_backup_via_gdrive(book.path) gdriveutils.uploadFileToEbooksFolder(os.path.join(book.path, 'metadata.opf').replace("\\", "/"),
if not web_content_link: etree.tostring(package,
raise Exception('Google Drive cover url not found') xml_declaration=True,
encoding='utf-8',
stream = None pretty_print=True).decode('utf-8'),
try: True)
stream = urlopen(web_content_link)
except Exception as ex:
# Bubble exception to calling function
self.log.debug('Error reading metadata.opf: ' + str(ex)) # ToDo Check whats going on
raise ex
finally:
if stream is not None:
stream.close()
else: else:
# ToDo: Handle book folder not found or not readable # ToDo: Handle book folder not found or not readable
book_metadata_filepath = os.path.join(config.config_calibre_dir, book.path, 'metadata.opf') book_metadata_filepath = os.path.join(config.config_calibre_dir, book.path, 'metadata.opf')
#if not os.path.isfile(book_metadata_filepath): # prepare finalize everything and output
self.create_new_metadata_backup(book, custom_columns, book_metadata_filepath) doc = etree.ElementTree(package)
# else: try:
'''namespaces = {'dc': PURL_NAMESPACE, 'opf': OPF_NAMESPACE} with open(book_metadata_filepath, 'wb') as f:
test = etree.parse(book_metadata_filepath) doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True)
root = test.getroot() except Exception as ex:
for i in root.iter(): raise Exception('Writing Metadata failed with error: {} '.format(ex))
self.log.info(i)
title = root.find("dc:metadata", namespaces)
pass
with open(book_metadata_filepath, "rb") as f:
xml = f.read()
root = objectify.fromstring(xml) def create_new_metadata_backup(self, book, custom_columns):
# root.metadata['{http://purl.org/dc/elements/1.1/}title']
# root.metadata[PURL + 'title']
# getattr(root.metadata, PURL +'title')
# test = objectify.parse()
pass
# backup not found has to be created
#raise Exception('Book cover file not found')'''
def create_new_metadata_backup(self, book, custom_columns, book_metadata_filepath):
# generate root package element # generate root package element
package = etree.Element(OPF + "package", nsmap=OPF_NS) package = etree.Element(OPF + "package", nsmap=OPF_NS)
package.set("unique-identifier", "uuid_id") package.set("unique-identifier", "uuid_id")
package.set("version", "2.0") package.set("version", "2.0")
# generate metadata element and all subelements of it # generate metadata element and all sub elements of it
metadata = etree.SubElement(package, "metadata", nsmap=NSMAP) metadata = etree.SubElement(package, "metadata", nsmap=NSMAP)
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP) identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
identifier.set(OPF + "scheme", "calibre") identifier.set(OPF + "scheme", "calibre")
@ -171,10 +151,13 @@ class TaskBackupMetadata(CalibreTask):
date = etree.SubElement(metadata, PURL + "date", nsmap=NSMAP) date = etree.SubElement(metadata, PURL + "date", nsmap=NSMAP)
date.text = '{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(d=book.pubdate) date.text = '{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(d=book.pubdate)
if book.comments: if book.comments and book.comments[0].text:
for b in book.comments: for b in book.comments:
description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP) description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP)
description.text = b.text description.text = b.text
for b in book.publishers:
publisher = etree.SubElement(metadata, PURL + "publisher", nsmap=NSMAP)
publisher.text = str(b.name)
if not book.languages: if not book.languages:
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP) language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
language.text = self.export_language language.text = self.export_language
@ -196,6 +179,10 @@ class TaskBackupMetadata(CalibreTask):
etree.SubElement(metadata, "meta", name="calibre:series_index", etree.SubElement(metadata, "meta", name="calibre:series_index",
content=str(book.series_index), content=str(book.series_index),
nsmap=NSMAP) nsmap=NSMAP)
if len(book.ratings) and book.ratings[0].rating > 0:
etree.SubElement(metadata, "meta", name="calibre:rating",
content=str(book.ratings[0].rating),
nsmap=NSMAP)
etree.SubElement(metadata, "meta", name="calibre:timestamp", etree.SubElement(metadata, "meta", name="calibre:timestamp",
content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format( content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(
d=book.timestamp), d=book.timestamp),
@ -209,8 +196,8 @@ class TaskBackupMetadata(CalibreTask):
extra = None extra = None
cc_entry = getattr(book, "custom_column_" + str(cc.id)) cc_entry = getattr(book, "custom_column_" + str(cc.id))
if cc_entry.__len__(): if cc_entry.__len__():
value = cc_entry[0].get("value") value = [c.value for c in cc_entry] if cc.is_multiple else cc_entry[0].value
extra = cc_entry[0].get("extra") extra = cc_entry[0].extra if hasattr(cc_entry[0], "extra") else None
etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label), etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label),
content=cc.to_json(value, extra, sequence), content=cc.to_json(value, extra, sequence),
nsmap=NSMAP) nsmap=NSMAP)
@ -221,16 +208,8 @@ class TaskBackupMetadata(CalibreTask):
guide = etree.SubElement(package, "guide") guide = etree.SubElement(package, "guide")
etree.SubElement(guide, "reference", type="cover", title=self.translated_title, href="cover.jpg") etree.SubElement(guide, "reference", type="cover", title=self.translated_title, href="cover.jpg")
# prepare finalize everything and output return package
doc = etree.ElementTree(package)
# doc = etree.tostring(package, xml_declaration=True, encoding='utf-8', pretty_print=True) # .replace(b"&amp;quot;", b"&quot;")
try:
with open(book_metadata_filepath, 'wb') as f:
# f.write(doc)
doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True)
except Exception:
# ToDo: Folder not writeable error
pass
@property @property
def name(self): def name(self):
return "Metadata backup" return "Metadata backup"

View File

@ -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 os import os
from shutil import copyfile, copyfileobj
from urllib.request import urlopen from urllib.request import urlopen
from .. import constants from .. import constants
@ -92,7 +93,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
if generated > 0: if generated > 0:
total_generated += generated total_generated += generated
self.message = N_(u'Generated %(count)s cover thumbnails', count=total_generated) self.message = N_('Generated %(count)s cover thumbnails', count=total_generated)
# Check if job has been cancelled or ended # Check if job has been cancelled or ended
if self.stat == STAT_CANCELLED: if self.stat == STAT_CANCELLED:
@ -137,7 +138,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
# Replace outdated or missing thumbnails # Replace outdated or missing thumbnails
for thumbnail in book_cover_thumbnails: for thumbnail in book_cover_thumbnails:
if book.last_modified > thumbnail.generated_at: if book.last_modified.replace(tzinfo=None) > thumbnail.generated_at:
generated += 1 generated += 1
self.update_book_cover_thumbnail(book, thumbnail) self.update_book_cover_thumbnail(book, thumbnail)
@ -188,14 +189,18 @@ class TaskGenerateCoverThumbnails(CalibreTask):
try: try:
stream = urlopen(web_content_link) stream = urlopen(web_content_link)
with Image(file=stream) as img: with Image(file=stream) as img:
filename = self.cache.get_cache_file_path(thumbnail.filename,
constants.CACHE_TYPE_THUMBNAILS)
height = get_resize_height(thumbnail.resolution) height = get_resize_height(thumbnail.resolution)
if img.height > height: if img.height > height:
width = get_resize_width(thumbnail.resolution, img.width, img.height) width = get_resize_width(thumbnail.resolution, img.width, img.height)
img.resize(width=width, height=height, filter='lanczos') img.resize(width=width, height=height, filter='lanczos')
img.format = thumbnail.format img.format = thumbnail.format
filename = self.cache.get_cache_file_path(thumbnail.filename,
constants.CACHE_TYPE_THUMBNAILS)
img.save(filename=filename) img.save(filename=filename)
else:
with open(filename, 'rb') as fd:
copyfileobj(stream, fd)
except Exception as ex: except Exception as ex:
# Bubble exception to calling function # Bubble exception to calling function
self.log.debug('Error generating thumbnail file: ' + str(ex)) self.log.debug('Error generating thumbnail file: ' + str(ex))
@ -210,12 +215,15 @@ class TaskGenerateCoverThumbnails(CalibreTask):
with Image(filename=book_cover_filepath) as img: with Image(filename=book_cover_filepath) as img:
height = get_resize_height(thumbnail.resolution) height = get_resize_height(thumbnail.resolution)
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
if img.height > height: if img.height > height:
width = get_resize_width(thumbnail.resolution, img.width, img.height) width = get_resize_width(thumbnail.resolution, img.width, img.height)
img.resize(width=width, height=height, filter='lanczos') img.resize(width=width, height=height, filter='lanczos')
img.format = thumbnail.format img.format = thumbnail.format
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
img.save(filename=filename) img.save(filename=filename)
else:
# take cover as is
copyfile(book_cover_filepath, filename)
@property @property
def name(self): def name(self):

View File

@ -43,9 +43,7 @@ def get_email_status_json():
@login_required @login_required
def get_tasks_status(): def get_tasks_status():
# if current user admin, show all email, otherwise only own emails # if current user admin, show all email, otherwise only own emails
tasks = WorkerThread.get_instance().tasks return render_title_template('tasks.html', title=_("Tasks"), page="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 # helper function to apply localize status information in tasklist entries
@ -61,19 +59,19 @@ def render_task_status(tasklist):
# localize the task status # localize the task status
if isinstance(task.stat, int): if isinstance(task.stat, int):
if task.stat == STAT_WAITING: if task.stat == STAT_WAITING:
ret['status'] = _(u'Waiting') ret['status'] = _('Waiting')
elif task.stat == STAT_FAIL: elif task.stat == STAT_FAIL:
ret['status'] = _(u'Failed') ret['status'] = _('Failed')
elif task.stat == STAT_STARTED: elif task.stat == STAT_STARTED:
ret['status'] = _(u'Started') ret['status'] = _('Started')
elif task.stat == STAT_FINISH_SUCCESS: elif task.stat == STAT_FINISH_SUCCESS:
ret['status'] = _(u'Finished') ret['status'] = _('Finished')
elif task.stat == STAT_ENDED: elif task.stat == STAT_ENDED:
ret['status'] = _(u'Ended') ret['status'] = _('Ended')
elif task.stat == STAT_CANCELLED: elif task.stat == STAT_CANCELLED:
ret['status'] = _(u'Cancelled') ret['status'] = _('Cancelled')
else: else:
ret['status'] = _(u'Unknown Status') ret['status'] = _('Unknown Status')
ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name
ret['progress'] = "{} %".format(int(task.progress * 100)) ret['progress'] = "{} %".format(int(task.progress * 100))

47
cps/templates/admin.html Executable file → Normal file
View File

@ -11,8 +11,8 @@
<table class="table table-striped" id="table_user"> <table class="table table-striped" id="table_user">
<tr> <tr>
<th>{{_('Username')}}</th> <th>{{_('Username')}}</th>
<th>{{_('E-mail Address')}}</th> <th>{{_('Email')}}</th>
<th>{{_('Send to E-Reader E-mail Address')}}</th> <th>{{_('Send to eReader Email')}}</th>
<th>{{_('Downloads')}}</th> <th>{{_('Downloads')}}</th>
<th class="hidden-xs ">{{_('Admin')}}</th> <th class="hidden-xs ">{{_('Admin')}}</th>
<th class="hidden-xs hidden-sm">{{_('Password')}}</th> <th class="hidden-xs hidden-sm">{{_('Password')}}</th>
@ -59,45 +59,45 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2>{{_('E-mail Server Settings')}}</h2> <h2>{{_('Email Server Settings')}}</h2>
{% if config.get_mail_server_configured() %} {% if config.get_mail_server_configured() %}
{% if email.mail_server_type == 0 %} {% if config.mail_server_type == 0 %}
<div class="col-xs-12 col-sm-12"> <div class="col-xs-12 col-sm-12">
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_server}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_server}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Port')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Port')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_port}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_port}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('Encryption')}}</div> <div class="col-xs-6 col-sm-3">{{_('Encryption')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(email.mail_use_ssl) }}</div> <div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.mail_use_ssl) }}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Login')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Login')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_login}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_login}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('From E-mail')}}</div> <div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_from}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_from}}</div>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="col-xs-12 col-sm-12"> <div class="col-xs-12 col-sm-12">
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('E-Mail Service')}}</div> <div class="col-xs-6 col-sm-3">{{_('Email Service')}}</div>
<div class="col-xs-6 col-sm-3">{{_('Gmail via Oauth2')}}</div> <div class="col-xs-6 col-sm-3">{{_('Gmail via Oauth2')}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('From E-mail')}}</div> <div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_gmail_token['email']}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_gmail_token['email']}}</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a class="btn btn-default emailconfig" id="admin_edit_email" href="{{url_for('admin.edit_mailsettings')}}">{{_('Edit E-mail Server Settings')}}</a> <a class="btn btn-default emailconfig" id="admin_edit_email" href="{{url_for('admin.edit_mailsettings')}}">{{_('Edit Email Server Settings')}}</a>
</div> </div>
</div> </div>
@ -167,15 +167,15 @@
<h2>{{_('Scheduled Tasks')}}</h2> <h2>{{_('Scheduled Tasks')}}</h2>
<div class="col-xs-12 col-sm-12 scheduled_tasks_details"> <div class="col-xs-12 col-sm-12 scheduled_tasks_details">
<div class="row"> <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">{{_('Start Time')}}</div>
<div class="col-xs-6 col-sm-3">{{schedule_time}}</div> <div class="col-xs-6 col-sm-3">{{schedule_time}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('Maximum tasks duration')}}</div> <div class="col-xs-6 col-sm-3">{{_('Maximum Duration')}}</div>
<div class="col-xs-6 col-sm-3">{{schedule_duration}}</div> <div class="col-xs-6 col-sm-3">{{schedule_duration}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('Generate book cover thumbnails')}}</div> <div class="col-xs-6 col-sm-3">{{_('Generate Thumbnails')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div> <div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
</div> </div>
<!--div class="row"> <!--div class="row">
@ -183,14 +183,18 @@
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_series_covers) }}</div> <div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_series_covers) }}</div>
</div--> </div-->
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('Reconnect to Calibre Library')}}</div> <div class="col-xs-6 col-sm-3">{{_('Reconnect Calibre Database')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_reconnect) }}</div> <div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_reconnect) }}</div>
</div> </div>
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('Generate Metadata Backup Files')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_metadata_backup) }}</div>
</div>
</div> </div>
<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 scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
{% if config.schedule_generate_book_covers %} {% if config.schedule_generate_book_covers %}
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cover Cache')}}</a> <a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cache')}}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -207,10 +211,11 @@
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div> <div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div> <div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
</div> </div>
{% if config.schedule_metadata_backup %}
<div class="row form-group"> <div class="row form-group">
<div class="btn btn-default" id="metadata_backup" data-toggle="modal" data-target="#StatusDialog">{{_('Queue all books for metadata backup')}}</div> <div class="btn btn-default" id="metadata_backup" data-toggle="modal" data-target="#StatusDialog">{{_('Queue all books for metadata backup')}}</div>
</div> </div>
{% endif %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2>{{_('Version Information')}}</h2> <h2>{{_('Version Information')}}</h2>
@ -224,7 +229,7 @@
<tbody> <tbody>
<tr id="current_version"> <tr id="current_version">
<td>{{commit}} </td> <td>{{commit}} </td>
<td><i>{{_('Current version')}}</i></td> <td><i>{{_('Current Version')}}</i></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

Some files were not shown because too many files have changed in this diff Show More