mirror of
https://github.com/janeczku/calibre-web
synced 2024-11-16 23:04:54 +00:00
Merge branch 'master' into master
This commit is contained in:
commit
1f0b569cf7
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1,4 +1,5 @@
|
||||
constants.py ident export-subst
|
||||
/test export-ignore
|
||||
/library export-ignore
|
||||
cps/static/css/libs/* linguist-vendored
|
||||
cps/static/js/libs/* linguist-vendored
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -28,8 +28,10 @@ cps/cache
|
||||
.idea/
|
||||
*.bak
|
||||
*.log.*
|
||||
.key
|
||||
|
||||
settings.yaml
|
||||
gdrive_credentials
|
||||
client_secrets.json
|
||||
gmail.json
|
||||
/.key
|
||||
|
145
README.md
145
README.md
@ -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)
|
||||
[![GitHub 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)
|
||||
[![License](https://img.shields.io/github/license/janeczku/calibre-web?style=flat-square)](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
||||
![Commit Activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)
|
||||
[![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 - 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)
|
||||
|
||||
<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.*
|
||||
|
||||
![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png)
|
||||
|
||||
## Features
|
||||
|
||||
- Bootstrap 3 HTML5 interface
|
||||
- full graphical setup
|
||||
- User management with fine-grained per-user permissions
|
||||
- Modern and responsive Bootstrap 3 HTML5 interface
|
||||
- Full graphical setup
|
||||
- Comprehensive user management with fine-grained per-user permissions
|
||||
- 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
|
||||
- OPDS feed for eBook reader apps
|
||||
- Filter and search by titles, authors, tags, series, book format and language
|
||||
- Create a custom book collection (shelves)
|
||||
- Support for editing eBook metadata and deleting eBooks from Calibre library
|
||||
- Support for downloading eBook metadata from various sources, sources can be extended via external plugins
|
||||
- Support for converting eBooks through Calibre binaries
|
||||
- Restrict eBook download to logged-in users
|
||||
- Support for public user registration
|
||||
- Send eBooks to E-Readers with the click of a button
|
||||
- Sync your Kobo devices through Calibre-Web with your Calibre library
|
||||
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz, .djvu)
|
||||
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
|
||||
- Support for Calibre Custom Columns
|
||||
- Ability to hide content based on categories and Custom Column content per user
|
||||
- Multilingual user interface supporting 20+ languages ([supported languages](https://github.com/janeczku/calibre-web/wiki/Translation-Status))
|
||||
- OPDS feed for eBook reader apps
|
||||
- Advanced search and filtering options
|
||||
- Custom book collection (shelves) creation
|
||||
- eBook metadata editing and deletion support
|
||||
- Metadata download from various sources (extensible via plugins)
|
||||
- eBook conversion through Calibre binaries
|
||||
- eBook download restriction to logged-in users
|
||||
- Public user registration support
|
||||
- Send eBooks to E-Readers with a single click
|
||||
- Sync Kobo devices with your Calibre library
|
||||
- In-browser eBook reading support for multiple formats
|
||||
- Upload new books in various formats, including audio formats
|
||||
- Calibre Custom Columns support
|
||||
- Content hiding based on categories and Custom Column content per user
|
||||
- Self-update capability
|
||||
- "Magic Link" login to make it easy to log on eReaders
|
||||
- Login via LDAP, google/github oauth and via proxy authentication
|
||||
- "Magic Link" login for easy access on eReaders
|
||||
- LDAP, Google/GitHub OAuth, and proxy authentication support
|
||||
|
||||
## Installation
|
||||
|
||||
#### Installation via pip (recommended)
|
||||
1. To avoid problems with already installed python dependencies, it's recommended to create a virtual environment for Calibre-Web
|
||||
2. Install Calibre-Web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`).
|
||||
3. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
|
||||
4. Calibre-Web can be started afterwards by typing `cps`
|
||||
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
|
||||
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
|
||||
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. 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 \
|
||||
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)
|
||||
## Quick Start
|
||||
|
||||
#### Default admin login:
|
||||
*Username:* admin\
|
||||
*Password:* admin123
|
||||
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||
2. Log in with the default admin credentials
|
||||
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
|
||||
|
||||
python 3.5+
|
||||
|
||||
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:
|
||||
|
||||
[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`.
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
## 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**
|
||||
+ Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||
+ Github - [https://github.com/linuxserver/docker-calibre-web](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)
|
||||
#### **LinuxServer - x64, aarch64**
|
||||
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
|
||||
- [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)**
|
||||
|
||||
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`
|
||||
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.
|
||||
|
||||
# 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)
|
||||
|
@ -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 | 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.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)
|
||||
|
@ -2,4 +2,3 @@
|
||||
|
||||
# has to be executed with jinja2 >=2.9 to have autoescape enabled automatically
|
||||
[jinja2: **/templates/**.*ml]
|
||||
extensions=jinja2.ext.with_
|
||||
|
2
cps.py
2
cps.py
@ -21,7 +21,7 @@ import os
|
||||
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__))
|
||||
sys.path.insert(0, path)
|
||||
|
||||
|
@ -21,15 +21,32 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask_login import LoginManager
|
||||
from flask import session
|
||||
|
||||
from flask_login import LoginManager, confirm_login
|
||||
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):
|
||||
def _session_protection_failed(self):
|
||||
_session = session._get_current_object()
|
||||
sess = session._get_current_object()
|
||||
ident = self._session_identifier_generator()
|
||||
if(_session and not (len(_session) == 1
|
||||
and _session.get('csrf_token', None))) and ident != _session.get('_id', None):
|
||||
if(sess and not (len(sess) == 1
|
||||
and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
||||
return super(). _session_protection_failed()
|
||||
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
|
||||
|
@ -36,11 +36,16 @@ from .reverseproxy import ReverseProxied
|
||||
from .server import WebServer
|
||||
from .dep_check import dependency_check
|
||||
from .updater import Updater
|
||||
from .babel import babel
|
||||
from .babel import babel, get_locale
|
||||
from . import config_sql
|
||||
from . import cache_buster
|
||||
from . import ub, db
|
||||
|
||||
try:
|
||||
from flask_limiter import Limiter
|
||||
limiter_present = True
|
||||
except ImportError:
|
||||
limiter_present = False
|
||||
try:
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
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-cbz', '.cbz')
|
||||
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', '.mp3')
|
||||
mimetypes.add_type('application/mp4', '.m4a')
|
||||
@ -81,10 +87,10 @@ app.config.update(
|
||||
|
||||
lm = MyLoginManager()
|
||||
|
||||
config = config_sql._ConfigSQL()
|
||||
|
||||
cli_param = CliParameter()
|
||||
|
||||
config = config_sql.ConfigSQL()
|
||||
|
||||
if wtf_present:
|
||||
csrf = CSRFProtect()
|
||||
else:
|
||||
@ -96,33 +102,36 @@ web_server = WebServer()
|
||||
|
||||
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():
|
||||
lm.login_view = 'web.login'
|
||||
lm.anonymous_user = ub.Anonymous
|
||||
lm.session_protection = 'strong'
|
||||
|
||||
if csrf:
|
||||
csrf.init_app(app)
|
||||
|
||||
cli_param.init()
|
||||
|
||||
ub.init_db(cli_param.settings_path, cli_param.user_credentials)
|
||||
|
||||
ub.init_db(cli_param.settings_path)
|
||||
# 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)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||
calibre_db.init_db()
|
||||
config_sql.load_configuration(ub.session, encrypt_key)
|
||||
config.init_config(ub.session, encrypt_key, cli_param)
|
||||
|
||||
updater_thread.init_updater(config, web_server)
|
||||
# Perform dry run of updater and exit afterwards
|
||||
if cli_param.dry_run:
|
||||
updater_thread.dry_run()
|
||||
sys.exit(0)
|
||||
updater_thread.start()
|
||||
if error:
|
||||
log.error(error)
|
||||
|
||||
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):
|
||||
log.info(
|
||||
'*** 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" ***')
|
||||
web_server.stop(True)
|
||||
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):
|
||||
log.info('*** "{}" version does not fit the requirements. '
|
||||
log.info('*** "{}" version does not meet the requirements. '
|
||||
'Should: {}, Found: {}, please consider installing required version ***'
|
||||
.format(res['name'],
|
||||
res['target'],
|
||||
@ -150,14 +175,16 @@ def create_app():
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
cache_buster.init_cache_busting(app)
|
||||
log.info('Starting Calibre Web...')
|
||||
|
||||
Principal(app)
|
||||
lm.init_app(app)
|
||||
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
||||
|
||||
web_server.init_app(app, config)
|
||||
|
||||
babel.init_app(app)
|
||||
if hasattr(babel, "localeselector"):
|
||||
babel.init_app(app)
|
||||
babel.localeselector(get_locale)
|
||||
else:
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
from . import services
|
||||
|
||||
@ -165,9 +192,13 @@ def create_app():
|
||||
services.ldap.init_app(app, config)
|
||||
if services.goodreads_support:
|
||||
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.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
|
||||
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||
register_scheduled_tasks(config.schedule_reconnect)
|
||||
|
@ -81,4 +81,4 @@ def stats():
|
||||
categories = calibre_db.session.query(db.Tags).count()
|
||||
series = calibre_db.session.query(db.Series).count()
|
||||
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")
|
||||
|
202
cps/admin.py
202
cps/admin.py
@ -22,7 +22,6 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import json
|
||||
import operator
|
||||
import time
|
||||
@ -31,9 +30,11 @@ import string
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import time as datetime_time
|
||||
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_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 get_locale, format_time, format_datetime, format_timedelta
|
||||
from flask import session as flask_session
|
||||
@ -101,25 +102,26 @@ def admin_required(f):
|
||||
|
||||
@admi.before_app_request
|
||||
def before_request():
|
||||
# make remember me function work
|
||||
if current_user.is_authenticated:
|
||||
confirm_login()
|
||||
if not ub.check_user_session(current_user.id, flask_session.get('_id')) and 'opds' not in request.path:
|
||||
logout_user()
|
||||
try:
|
||||
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()
|
||||
except AttributeError:
|
||||
pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||
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_anonymous = config.config_anonbrowse
|
||||
g.allow_upload = config.config_uploading
|
||||
g.current_theme = config.config_theme
|
||||
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 \
|
||||
request.endpoint not in ('admin.ajax_db_config',
|
||||
'admin.simulatedbchange',
|
||||
'admin.db_configuration',
|
||||
'web.login',
|
||||
'web.login_post',
|
||||
'web.logout',
|
||||
'admin.load_dialogtexts',
|
||||
'admin.ajax_pathchooser'):
|
||||
@ -144,9 +146,9 @@ def shutdown():
|
||||
ub.dispose()
|
||||
|
||||
if task == 0:
|
||||
show_text['text'] = _(u'Server restarted, please reload page')
|
||||
show_text['text'] = _('Server restarted, please reload page.')
|
||||
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
|
||||
web_server.stop(task == 0)
|
||||
return json.dumps(show_text)
|
||||
@ -154,10 +156,10 @@ def shutdown():
|
||||
if task == 2:
|
||||
log.warning("reconnecting to calibre database")
|
||||
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)
|
||||
|
||||
show_text['text'] = _(u'Unknown command')
|
||||
show_text['text'] = _('Unknown command')
|
||||
return json.dumps(show_text), 400
|
||||
|
||||
|
||||
@ -168,7 +170,7 @@ def queue_metadata_backup():
|
||||
show_text = {}
|
||||
log.warning("Queuing all books for metadata backup")
|
||||
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)
|
||||
|
||||
|
||||
@ -201,7 +203,7 @@ def update_thumbnails():
|
||||
def admin():
|
||||
version = updater_thread.get_current_version_info()
|
||||
if version is False:
|
||||
commit = _(u'Unknown')
|
||||
commit = _('Unknown')
|
||||
else:
|
||||
if 'datetime' in version:
|
||||
commit = version['datetime']
|
||||
@ -218,15 +220,15 @@ def admin():
|
||||
commit = version['version']
|
||||
|
||||
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")
|
||||
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
|
||||
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,
|
||||
schedule_duration=schedule_duration,
|
||||
title=_(u"Admin page"), page="admin")
|
||||
title=_("Admin page"), page="admin")
|
||||
|
||||
|
||||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||||
@ -246,7 +248,7 @@ def configuration():
|
||||
config=config,
|
||||
provider=oauthblueprints,
|
||||
feature_support=feature_support,
|
||||
title=_(u"Basic Configuration"), page="config")
|
||||
title=_("Basic Configuration"), page="config")
|
||||
|
||||
|
||||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||||
@ -284,7 +286,7 @@ def view_configuration():
|
||||
restrictColumns=restrict_columns,
|
||||
languages=languages,
|
||||
translations=translations,
|
||||
title=_(u"UI Configuration"), page="uiconfig")
|
||||
title=_("UI Configuration"), page="uiconfig")
|
||||
|
||||
|
||||
@admi.route("/admin/usertable")
|
||||
@ -318,7 +320,7 @@ def edit_user_table():
|
||||
all_roles=constants.ALL_ROLES,
|
||||
kobo_support=kobo_support,
|
||||
sidebar_settings=constants.sidebar_settings,
|
||||
title=_(u"Edit Users"),
|
||||
title=_("Edit Users"),
|
||||
page="usertable")
|
||||
|
||||
|
||||
@ -489,7 +491,7 @@ def edit_list_user(param):
|
||||
ub.User.id != user.id).count():
|
||||
return Response(
|
||||
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')
|
||||
user.role &= ~value
|
||||
else:
|
||||
@ -566,13 +568,13 @@ def update_view_configuration():
|
||||
calibre_db.update_title_sort(config)
|
||||
|
||||
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")
|
||||
return view_configuration()
|
||||
_config_int(to_save, "config_read_column")
|
||||
|
||||
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")
|
||||
return view_configuration()
|
||||
_config_int(to_save, "config_restricted_column")
|
||||
@ -592,7 +594,7 @@ def update_view_configuration():
|
||||
config.config_default_show |= constants.DETAIL_RANDOM
|
||||
|
||||
config.save()
|
||||
flash(_(u"Calibre-Web configuration updated"), category="success")
|
||||
flash(_("Calibre-Web configuration updated"), category="success")
|
||||
log.debug("Calibre-Web configuration updated")
|
||||
before_request()
|
||||
|
||||
@ -1037,7 +1039,8 @@ def pathchooser():
|
||||
|
||||
for f in folders:
|
||||
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()
|
||||
except Exception:
|
||||
continue
|
||||
@ -1088,7 +1091,7 @@ def _config_checkbox_int(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):
|
||||
@ -1162,7 +1165,6 @@ def _configuration_logfile_helper(to_save):
|
||||
|
||||
def _configuration_ldap_helper(to_save):
|
||||
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_authentication")
|
||||
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")
|
||||
_config_string(to_save, "config_ldap_group_name")
|
||||
_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
|
||||
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()
|
||||
|
||||
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_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'))
|
||||
else:
|
||||
if not config.config_ldap_serv_username:
|
||||
@ -1256,16 +1263,16 @@ def new_user():
|
||||
content.default_language = config.config_default_language
|
||||
return render_title_template("user_edit.html", new_user=1, content=content,
|
||||
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)
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings")
|
||||
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_mailsettings():
|
||||
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)
|
||||
|
||||
|
||||
@ -1284,7 +1291,7 @@ def update_mailsettings():
|
||||
elif to_save.get("gmail"):
|
||||
try:
|
||||
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:
|
||||
flash(str(ex), category="error")
|
||||
log.error(ex)
|
||||
@ -1293,7 +1300,8 @@ def update_mailsettings():
|
||||
else:
|
||||
_config_int(to_save, "mail_port")
|
||||
_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.mail_server = to_save.get('mail_server', "").strip()
|
||||
config.mail_from = to_save.get('mail_from', "").strip()
|
||||
@ -1303,24 +1311,24 @@ def update_mailsettings():
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
return edit_mailsettings()
|
||||
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()
|
||||
|
||||
if to_save.get("test"):
|
||||
if current_user.email:
|
||||
result = send_test_mail(current_user.email, current_user.name)
|
||||
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")
|
||||
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:
|
||||
flash(_(u"Please configure your e-mail address first..."), category="error")
|
||||
flash(_("Please configure your e-mail address first..."), category="error")
|
||||
else:
|
||||
flash(_(u"E-mail server settings updated"), category="success")
|
||||
flash(_("Email Server Settings updated"), category="success")
|
||||
|
||||
return edit_mailsettings()
|
||||
|
||||
@ -1343,7 +1351,7 @@ def edit_scheduledtasks():
|
||||
config=content,
|
||||
starttime=time_field,
|
||||
duration=duration_field,
|
||||
title=_(u"Edit Scheduled Tasks Settings"))
|
||||
title=_("Edit Scheduled Tasks Settings"))
|
||||
|
||||
|
||||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||||
@ -1353,23 +1361,24 @@ def update_scheduledtasks():
|
||||
error = False
|
||||
to_save = request.form.to_dict()
|
||||
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:
|
||||
flash(_(u"Invalid start time for task specified"), category="error")
|
||||
flash(_("Invalid start time for task specified"), category="error")
|
||||
error = True
|
||||
if 0 < int(to_save.get("schedule_duration")) <= 60:
|
||||
_config_int(to_save, "schedule_duration")
|
||||
else:
|
||||
flash(_(u"Invalid duration for task specified"), category="error")
|
||||
flash(_("Invalid duration for task specified"), category="error")
|
||||
error = True
|
||||
_config_checkbox(to_save, "schedule_generate_book_covers")
|
||||
_config_checkbox(to_save, "schedule_generate_series_covers")
|
||||
_config_checkbox(to_save, "schedule_metadata_backup")
|
||||
_config_checkbox(to_save, "schedule_reconnect")
|
||||
|
||||
if not error:
|
||||
try:
|
||||
config.save()
|
||||
flash(_(u"Scheduled tasks settings updated"), category="success")
|
||||
flash(_("Scheduled tasks settings updated"), category="success")
|
||||
|
||||
# Cancel any running tasks
|
||||
schedule.end_scheduled_tasks()
|
||||
@ -1379,7 +1388,7 @@ def update_scheduledtasks():
|
||||
except IntegrityError:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
@ -1394,7 +1403,7 @@ def update_scheduledtasks():
|
||||
def edit_user(user_id):
|
||||
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"):
|
||||
flash(_(u"User not found"), category="error")
|
||||
flash(_("User not found"), category="error")
|
||||
return redirect(url_for('admin.admin'))
|
||||
languages = calibre_db.speaking_language(return_all_languages=True)
|
||||
translations = get_available_locale()
|
||||
@ -1413,7 +1422,7 @@ def edit_user(user_id):
|
||||
registered_oauth=oauth_check,
|
||||
mail_configured=config.get_mail_server_configured(),
|
||||
kobo_support=kobo_support,
|
||||
title=_(u"Edit User %(nick)s", nick=content.name),
|
||||
title=_("Edit User %(nick)s", nick=content.name),
|
||||
page="edituser")
|
||||
|
||||
|
||||
@ -1424,14 +1433,14 @@ def reset_user_password(user_id):
|
||||
if current_user is not None and current_user.is_authenticated:
|
||||
ret, message = reset_password(user_id)
|
||||
if ret == 1:
|
||||
log.debug(u"Password for user %s reset", message)
|
||||
flash(_(u"Password for user %(user)s reset", user=message), category="success")
|
||||
log.debug("Password for user %s reset", message)
|
||||
flash(_("Success! Password for user %(user)s reset", user=message), category="success")
|
||||
elif ret == 0:
|
||||
log.error(u"An unknown error occurred. Please try again later.")
|
||||
flash(_(u"An unknown error occurred. Please try again later."), category="error")
|
||||
log.error("An unknown error occurred. Please try again later.")
|
||||
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||||
else:
|
||||
log.error(u"Please configure the SMTP mail settings first...")
|
||||
flash(_(u"Please configure the SMTP mail settings first..."), category="error")
|
||||
log.error("Please configure the SMTP mail settings.")
|
||||
flash(_("Oops! Please configure the SMTP mail settings."), category="error")
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
|
||||
@ -1442,7 +1451,7 @@ def view_logfile():
|
||||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||||
1: logger.get_accesslogfile(config.config_access_logfile)}
|
||||
return render_title_template("logviewer.html",
|
||||
title=_(u"Logfile viewer"),
|
||||
title=_("Logfile viewer"),
|
||||
accesslog_enable=config.config_access_log,
|
||||
log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
|
||||
logfiles=logfiles,
|
||||
@ -1492,7 +1501,7 @@ def download_debug():
|
||||
@admin_required
|
||||
def get_update_status():
|
||||
if feature_support['updater']:
|
||||
log.info(u"Update status requested")
|
||||
log.info("Update status requested")
|
||||
return updater_thread.get_available_updates(request.method)
|
||||
else:
|
||||
return ''
|
||||
@ -1687,7 +1696,7 @@ def _db_configuration_update_helper():
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
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):
|
||||
@ -1719,7 +1728,7 @@ def _db_configuration_update_helper():
|
||||
_config_string(to_save, "config_calibre_dir")
|
||||
calibre_db.update_config(config)
|
||||
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()
|
||||
return _db_configuration_result(None, gdrive_error)
|
||||
|
||||
@ -1776,10 +1785,11 @@ def _configuration_update_helper():
|
||||
# Goodreads configuration
|
||||
_config_checkbox(to_save, "config_use_goodreads")
|
||||
_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:
|
||||
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_int(to_save, "config_updatechannel")
|
||||
@ -1792,10 +1802,25 @@ def _configuration_update_helper():
|
||||
if config.config_login_type == constants.LOGIN_OAUTH:
|
||||
reboot_required |= _configuration_oauth_helper(to_save)
|
||||
|
||||
# logfile configuration
|
||||
reboot, message = _configuration_logfile_helper(to_save)
|
||||
if message:
|
||||
return message
|
||||
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
|
||||
_config_string(to_save, "config_rarfile_location")
|
||||
if "config_rarfile_location" in to_save:
|
||||
@ -1805,7 +1830,7 @@ def _configuration_update_helper():
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
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()
|
||||
if reboot_required:
|
||||
@ -1821,7 +1846,7 @@ def _configuration_result(error_flash=None, reboot=False):
|
||||
config.load()
|
||||
resp['result'] = [{'type': "danger", 'message': error_flash}]
|
||||
else:
|
||||
resp['result'] = [{'type': "success", 'message': _(u"Calibre-Web configuration updated")}]
|
||||
resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
|
||||
resp['reboot'] = reboot
|
||||
resp['config_upload'] = config.config_upload_formats
|
||||
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,
|
||||
gdrivefolders=gdrivefolders,
|
||||
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):
|
||||
@ -1864,11 +1889,11 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
||||
content.sidebar_view |= constants.DETAIL_RANDOM
|
||||
|
||||
content.role = constants.selected_roles(to_save)
|
||||
content.password = generate_password_hash(to_save["password"])
|
||||
try:
|
||||
if not to_save["name"] or not to_save["email"] or not to_save["password"]:
|
||||
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"])
|
||||
# Query username, if not existing, change
|
||||
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"])
|
||||
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))
|
||||
raise Exception(_(u"E-mail is not from valid domain"))
|
||||
raise Exception(_("E-mail is not from valid domain"))
|
||||
except Exception as ex:
|
||||
flash(str(ex), category="error")
|
||||
return render_title_template("user_edit.html", new_user=1, content=content,
|
||||
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)
|
||||
try:
|
||||
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"
|
||||
ub.session.add(content)
|
||||
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))
|
||||
return redirect(url_for('admin.admin'))
|
||||
except IntegrityError:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
ub.session.rollback()
|
||||
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):
|
||||
@ -1931,10 +1956,10 @@ def _delete_user(content):
|
||||
log.info("User {} deleted".format(content.name))
|
||||
return _("User '%(nick)s' deleted", nick=content.name)
|
||||
else:
|
||||
log.warning(_("Can't delete Guest User"))
|
||||
# log.warning(_("Can't delete Guest User"))
|
||||
raise Exception(_("Can't delete Guest User"))
|
||||
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"))
|
||||
|
||||
|
||||
@ -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))
|
||||
flash(_("No admin user remaining, can't remove admin role"), category="error")
|
||||
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_')]
|
||||
sidebar, __ = get_sidebar_config()
|
||||
@ -1987,9 +2004,18 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
if to_save.get("locale"):
|
||||
content.locale = to_save["locale"]
|
||||
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))
|
||||
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:
|
||||
content.email = check_email(new_email)
|
||||
# Query username, if not existing, change
|
||||
@ -2011,19 +2037,19 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
content=content,
|
||||
config=config,
|
||||
registered_oauth=oauth_check,
|
||||
title=_(u"Edit User %(nick)s", nick=content.name),
|
||||
title=_("Edit User %(nick)s", nick=content.name),
|
||||
page="edituser")
|
||||
try:
|
||||
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:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
ub.session.rollback()
|
||||
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 ""
|
||||
|
||||
|
||||
|
13
cps/babel.py
13
cps/babel.py
@ -1,7 +1,8 @@
|
||||
from babel import negotiate_locale
|
||||
from flask_babel import Babel, Locale
|
||||
from babel.core import UnknownLocaleError
|
||||
from flask import request, g
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
|
||||
from . import logger
|
||||
|
||||
@ -9,14 +10,12 @@ log = logger.create()
|
||||
|
||||
babel = Babel()
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
# if a user is logged in, use the locale from the user settings
|
||||
user = getattr(g, 'user', None)
|
||||
if user is not None and hasattr(user, "locale"):
|
||||
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
||||
return user.locale
|
||||
if current_user is not None and hasattr(current_user, "locale"):
|
||||
# if the account is the guest account bypass the config lang settings
|
||||
if current_user.name != 'Guest':
|
||||
return current_user.locale
|
||||
|
||||
preferred = list()
|
||||
if request.accept_languages:
|
||||
|
@ -48,6 +48,7 @@ class CliParameter(object):
|
||||
'works only in combination with keyfile')
|
||||
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
|
||||
'works only in combination with certfile')
|
||||
parser.add_argument('-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',
|
||||
version=version_info())
|
||||
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')
|
||||
args = parser.parse_args()
|
||||
|
||||
self.logpath = args.o or ""
|
||||
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)
|
||||
|
||||
|
62
cps/comic.py
62
cps/comic.py
@ -36,6 +36,12 @@ try:
|
||||
from comicapi import __version__ as comic_version
|
||||
except ImportError:
|
||||
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:
|
||||
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
|
||||
import zipfile
|
||||
@ -46,6 +52,12 @@ except (ImportError, LookupError) as e:
|
||||
except (ImportError, SyntaxError) as e:
|
||||
log.debug('Cannot import rarfile, extracting cover files from rar files will not work: %s', e)
|
||||
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
|
||||
|
||||
|
||||
@ -78,23 +90,40 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = cf.read(name)
|
||||
cover_data = cf.read([name])
|
||||
break
|
||||
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
|
||||
|
||||
|
||||
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||
cover_data = extension = None
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||
for index, name in enumerate(archive.getPageNameList()):
|
||||
try:
|
||||
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)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
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
|
||||
else:
|
||||
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):
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||
if archive.seemsToBeAComicArchive():
|
||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
||||
try:
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||
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
|
||||
elif archive.hasMetadata(MetaDataStyle.CBI):
|
||||
elif has_metadata(MetaDataStyle.CBI):
|
||||
style = MetaDataStyle.CBI
|
||||
else:
|
||||
style = None
|
||||
|
||||
# if style is not None:
|
||||
loaded_metadata = archive.readMetadata(style)
|
||||
read_metadata = archive.readMetadata if hasattr(archive, "readMetadata") else archive.read_metadata
|
||||
loaded_metadata = read_metadata(style)
|
||||
|
||||
lang = loaded_metadata.language or ""
|
||||
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,
|
||||
extension=original_file_extension,
|
||||
title=original_file_name,
|
||||
author=u'Unknown',
|
||||
author='Unknown',
|
||||
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||
description="",
|
||||
tags="",
|
||||
|
@ -23,6 +23,10 @@ import json
|
||||
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.sql.expression import text
|
||||
from sqlalchemy import exists
|
||||
from cryptography.fernet import Fernet
|
||||
import cryptography.exceptions
|
||||
from base64 import urlsafe_b64decode
|
||||
try:
|
||||
# Compatibility with sqlalchemy 2.0
|
||||
from sqlalchemy.orm import declarative_base
|
||||
@ -56,7 +60,8 @@ class _Settings(_Base):
|
||||
mail_port = Column(Integer, default=25)
|
||||
mail_use_ssl = Column(SmallInteger, default=0)
|
||||
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_size = Column(Integer, default=25*1024*1024)
|
||||
mail_server_type = Column(SmallInteger, default=0)
|
||||
@ -69,19 +74,18 @@ class _Settings(_Base):
|
||||
config_certfile = Column(String)
|
||||
config_keyfile = Column(String)
|
||||
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_random_books = Column(Integer, default=4)
|
||||
config_authors_max = Column(Integer, default=0)
|
||||
config_read_column = Column(Integer, default=0)
|
||||
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
|
||||
# config_mature_content_tags = Column(String, default='')
|
||||
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_theme = Column(Integer, default=0)
|
||||
|
||||
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_logfile = Column(String)
|
||||
config_access_logfile = Column(String, default=logger.DEFAULT_ACCESS_LOG)
|
||||
|
||||
config_uploading = 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_goodreads_api_key = Column(String)
|
||||
config_goodreads_api_secret_e = Column(String)
|
||||
config_goodreads_api_secret = Column(String)
|
||||
config_register_email = Column(Boolean, default=False)
|
||||
config_login_type = Column(Integer, default=0)
|
||||
@ -117,7 +122,8 @@ class _Settings(_Base):
|
||||
config_ldap_port = Column(SmallInteger, default=389)
|
||||
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
||||
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
||||
config_ldap_serv_password = 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_cacert_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_series_covers = Column(Boolean, default=False)
|
||||
schedule_reconnect = Column(Boolean, default=False)
|
||||
schedule_metadata_backup = Column(Boolean, default=False)
|
||||
|
||||
config_password_policy = Column(Boolean, default=True)
|
||||
config_password_min_length = Column(Integer, default=8)
|
||||
config_password_number = Column(Boolean, default=True)
|
||||
config_password_lower = Column(Boolean, default=True)
|
||||
config_password_upper = Column(Boolean, default=True)
|
||||
config_password_special = Column(Boolean, default=True)
|
||||
config_session = Column(Integer, default=1)
|
||||
config_ratelimiter = Column(Boolean, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
# Class holds all application specific settings in calibre-web
|
||||
class _ConfigSQL(object):
|
||||
class ConfigSQL(object):
|
||||
# pylint: disable=no-member
|
||||
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._settings = None
|
||||
self.db_configured = None
|
||||
self.config_calibre_dir = None
|
||||
self.load()
|
||||
self._fernet = Fernet(secret_key)
|
||||
self.cli = cli
|
||||
self.load()
|
||||
|
||||
change = False
|
||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||
@ -294,10 +311,10 @@ class _ConfigSQL(object):
|
||||
setattr(self, field, new_value)
|
||||
return True
|
||||
|
||||
def toDict(self):
|
||||
def to_dict(self):
|
||||
storage = {}
|
||||
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
|
||||
return storage
|
||||
|
||||
@ -311,7 +328,13 @@ class _ConfigSQL(object):
|
||||
column = s.__class__.__dict__.get(k)
|
||||
if column.default is not None:
|
||||
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)
|
||||
if have_metadata_db:
|
||||
@ -319,30 +342,37 @@ class _ConfigSQL(object):
|
||||
have_metadata_db = os.path.isfile(db_file)
|
||||
self.db_configured = have_metadata_db
|
||||
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||
from . import cli_param
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||
else:
|
||||
# pylint: disable=access-member-before-definition
|
||||
logfile = logger.setup(self.config_logfile, self.config_log_level)
|
||||
if logfile != self.config_logfile:
|
||||
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
|
||||
logfile = logger.setup(cli_param.logpath or self.config_logfile, self.config_log_level)
|
||||
if logfile != os.path.abspath(self.config_logfile):
|
||||
if logfile != os.path.abspath(cli_param.logpath):
|
||||
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
|
||||
self.config_logfile = logfile
|
||||
s.config_logfile = logfile
|
||||
self._session.merge(s)
|
||||
try:
|
||||
self._session.commit()
|
||||
except OperationalError as e:
|
||||
log.error('Database error: %s', e)
|
||||
self._session.rollback()
|
||||
self.__dict__["dirty"] = list()
|
||||
|
||||
def save(self):
|
||||
"""Apply all configuration values to the underlying storage."""
|
||||
s = self._read_from_storage() # type: _Settings
|
||||
|
||||
for k, v in self.__dict__.items():
|
||||
for k in self.dirty:
|
||||
if k[0] == '_':
|
||||
continue
|
||||
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")
|
||||
self._session.merge(s)
|
||||
@ -358,7 +388,6 @@ class _ConfigSQL(object):
|
||||
log.error(error)
|
||||
log.warning("invalidating configuration")
|
||||
self.db_configured = False
|
||||
# self.config_calibre_dir = None
|
||||
self.save()
|
||||
|
||||
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||
@ -370,8 +399,40 @@ class _ConfigSQL(object):
|
||||
except AttributeError:
|
||||
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
|
||||
|
||||
for column_name, column in orm_class.__dict__.items():
|
||||
@ -447,22 +508,18 @@ def autodetect_kepubify_binary():
|
||||
return ""
|
||||
|
||||
|
||||
def _migrate_database(session):
|
||||
def _migrate_database(session, secret_key):
|
||||
# make sure the table is created, if it does not exist
|
||||
_Base.metadata.create_all(session.bind)
|
||||
_migrate_table(session, _Settings)
|
||||
_migrate_table(session, _Settings, secret_key)
|
||||
_migrate_table(session, _Flask_Settings)
|
||||
|
||||
|
||||
def load_configuration(conf, session, cli):
|
||||
_migrate_database(session)
|
||||
|
||||
def load_configuration(session, secret_key):
|
||||
_migrate_database(session, secret_key)
|
||||
if not session.query(_Settings).count():
|
||||
session.add(_Settings())
|
||||
session.commit()
|
||||
# conf = _ConfigSQL()
|
||||
conf.init_config(session, cli)
|
||||
# return conf
|
||||
|
||||
|
||||
def get_flask_session_key(_session):
|
||||
@ -472,3 +529,25 @@ def get_flask_session_key(_session):
|
||||
_session.add(flask_settings)
|
||||
_session.commit()
|
||||
return flask_settings.flask_session_key
|
||||
|
||||
|
||||
def get_encryption_key(key_path):
|
||||
key_file = os.path.join(key_path, ".key")
|
||||
generate = True
|
||||
error = ""
|
||||
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
|
||||
|
@ -34,6 +34,8 @@ UPDATER_AVAILABLE = True
|
||||
|
||||
# Base dir is parent of current file, necessary if called from different folder
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
||||
# 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')
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||
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)
|
||||
else:
|
||||
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_GDRIVE_FILE = "gdrive.db"
|
||||
@ -144,10 +149,10 @@ del env_CALIBRE_PORT
|
||||
|
||||
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr']
|
||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
|
||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu',
|
||||
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',
|
||||
'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, '
|
||||
'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[0] = '$Format:%H$'
|
||||
|
121
cps/db.py
121
cps/db.py
@ -111,66 +111,73 @@ class Identifiers(Base):
|
||||
def format_type(self):
|
||||
format_type = self.type.lower()
|
||||
if format_type == 'amazon':
|
||||
return u"Amazon"
|
||||
return "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":
|
||||
return u"ISBN"
|
||||
return "ISBN"
|
||||
elif format_type == "doi":
|
||||
return u"DOI"
|
||||
return "DOI"
|
||||
elif format_type == "douban":
|
||||
return u"Douban"
|
||||
return "Douban"
|
||||
elif format_type == "goodreads":
|
||||
return u"Goodreads"
|
||||
return "Goodreads"
|
||||
elif format_type == "babelio":
|
||||
return u"Babelio"
|
||||
return "Babelio"
|
||||
elif format_type == "google":
|
||||
return u"Google Books"
|
||||
return "Google Books"
|
||||
elif format_type == "kobo":
|
||||
return u"Kobo"
|
||||
return "Kobo"
|
||||
elif format_type == "litres":
|
||||
return u"ЛитРес"
|
||||
return "ЛитРес"
|
||||
elif format_type == "issn":
|
||||
return u"ISSN"
|
||||
return "ISSN"
|
||||
elif format_type == "isfdb":
|
||||
return u"ISFDB"
|
||||
return "ISFDB"
|
||||
if format_type == "lubimyczytac":
|
||||
return u"Lubimyczytac"
|
||||
return "Lubimyczytac"
|
||||
if format_type == "databazeknih":
|
||||
return "Databáze knih"
|
||||
else:
|
||||
return self.type
|
||||
|
||||
def __repr__(self):
|
||||
format_type = self.type.lower()
|
||||
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_'):
|
||||
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":
|
||||
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":
|
||||
return u"https://dx.doi.org/{0}".format(self.val)
|
||||
return "https://dx.doi.org/{0}".format(self.val)
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return u"https://www.litres.ru/{0}".format(self.val)
|
||||
return "https://www.litres.ru/{0}".format(self.val)
|
||||
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":
|
||||
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:"):
|
||||
return quote(self.val)
|
||||
elif self.val.lower().startswith("data:"):
|
||||
link , __, __ = str.partition(self.val, ",")
|
||||
return link
|
||||
else:
|
||||
return u"{0}".format(self.val)
|
||||
return "{0}".format(self.val)
|
||||
|
||||
|
||||
class Comments(Base):
|
||||
@ -188,7 +195,7 @@ class Comments(Base):
|
||||
return self.text
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Comments({0})>".format(self.text)
|
||||
return "<Comments({0})>".format(self.text)
|
||||
|
||||
|
||||
class Tags(Base):
|
||||
@ -203,8 +210,11 @@ class Tags(Base):
|
||||
def get(self):
|
||||
return self.name
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Tags('{0})>".format(self.name)
|
||||
return "<Tags('{0})>".format(self.name)
|
||||
|
||||
|
||||
class Authors(Base):
|
||||
@ -215,7 +225,7 @@ class Authors(Base):
|
||||
sort = Column(String(collation='NOCASE'))
|
||||
link = Column(String, nullable=False, default="")
|
||||
|
||||
def __init__(self, name, sort, link):
|
||||
def __init__(self, name, sort, link=""):
|
||||
self.name = name
|
||||
self.sort = sort
|
||||
self.link = link
|
||||
@ -223,8 +233,11 @@ class Authors(Base):
|
||||
def get(self):
|
||||
return self.name
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other
|
||||
|
||||
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):
|
||||
@ -241,8 +254,11 @@ class Series(Base):
|
||||
def get(self):
|
||||
return self.name
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other
|
||||
|
||||
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):
|
||||
@ -257,8 +273,11 @@ class Ratings(Base):
|
||||
def get(self):
|
||||
return self.rating
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.rating == other
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Ratings('{0}')>".format(self.rating)
|
||||
return "<Ratings('{0}')>".format(self.rating)
|
||||
|
||||
|
||||
class Languages(Base):
|
||||
@ -271,13 +290,16 @@ class Languages(Base):
|
||||
self.lang_code = lang_code
|
||||
|
||||
def get(self):
|
||||
if self.language_name:
|
||||
if hasattr(self, "language_name"):
|
||||
return self.language_name
|
||||
else:
|
||||
return self.lang_code
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.lang_code == other
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Languages('{0}')>".format(self.lang_code)
|
||||
return "<Languages('{0}')>".format(self.lang_code)
|
||||
|
||||
|
||||
class Publishers(Base):
|
||||
@ -294,8 +316,11 @@ class Publishers(Base):
|
||||
def get(self):
|
||||
return self.name
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other
|
||||
|
||||
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):
|
||||
@ -319,7 +344,7 @@ class Data(Base):
|
||||
return self.name
|
||||
|
||||
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):
|
||||
@ -373,7 +398,7 @@ class Books(Base):
|
||||
self.has_cover = (has_cover != None)
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
||||
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.last_modified, self.path, self.has_cover)
|
||||
|
||||
@ -404,7 +429,7 @@ class CustomColumns(Base):
|
||||
content['table'] = "custom_column_" + str(self.id)
|
||||
content['column'] = "value"
|
||||
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['name'] = self.name
|
||||
content['search_terms'] = ['#' + self.label]
|
||||
@ -418,9 +443,12 @@ class CustomColumns(Base):
|
||||
content['is_csp'] = False
|
||||
content['is_editable'] = self.editable
|
||||
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['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)
|
||||
|
||||
|
||||
@ -635,7 +663,7 @@ class CalibreDB:
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine))
|
||||
bind=cls.engine, future=True))
|
||||
for inst in cls.instances:
|
||||
inst.init_session()
|
||||
|
||||
@ -822,8 +850,6 @@ class CalibreDB:
|
||||
|
||||
# Orders all Authors in the list according to authors sort
|
||||
def order_authors(self, entries, list_return=False, combined=False):
|
||||
# entries_copy = copy.deepcopy(entries)
|
||||
# entries_copy =[]
|
||||
for entry in entries:
|
||||
if combined:
|
||||
sort_authors = entry.Books.author_sort.split('&')
|
||||
@ -988,7 +1014,12 @@ class CalibreDB:
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
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:
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
except sqliteOperationalError:
|
||||
|
@ -65,7 +65,7 @@ def send_debug():
|
||||
file_list.remove(element)
|
||||
memory_zip = BytesIO()
|
||||
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))
|
||||
for fp in file_list:
|
||||
zf.write(fp, os.path.basename(fp))
|
||||
|
@ -61,7 +61,7 @@ def dependency_check(optional=False):
|
||||
deps = load_dependencies(optional)
|
||||
for dep in deps:
|
||||
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('.')]
|
||||
high_check = [int(x) for x in dep[5].split('.')]
|
||||
except AttributeError:
|
||||
|
171
cps/editbooks.py
Executable file → Normal file
171
cps/editbooks.py
Executable file → Normal file
@ -25,21 +25,31 @@ from datetime import datetime
|
||||
import json
|
||||
from shutil import copyfile
|
||||
from uuid import uuid4
|
||||
from markupsafe import escape # dependency of flask
|
||||
from markupsafe import escape, Markup # dependency of flask
|
||||
from functools import wraps
|
||||
|
||||
try:
|
||||
from lxml.html.clean import clean_html
|
||||
from bleach import clean_text as clean_html
|
||||
BLEACH = True
|
||||
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 lazy_gettext as N_
|
||||
from flask_babel import get_locale
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
||||
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 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 not found
|
||||
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")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
@ -151,7 +161,7 @@ def edit_book(book_id):
|
||||
if to_save.get("cover_url", None):
|
||||
if not current_user.role_upload():
|
||||
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'):
|
||||
book.has_cover = 0
|
||||
else:
|
||||
@ -226,7 +236,7 @@ def edit_book(book_id):
|
||||
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
|
||||
log.error_or_exception("Database error: {}".format(e))
|
||||
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))
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
@ -288,7 +298,7 @@ def upload():
|
||||
if error:
|
||||
flash(error, category="error")
|
||||
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)))
|
||||
helper.add_book_to_thumbnail_cache(book_id)
|
||||
|
||||
@ -302,7 +312,8 @@ def upload():
|
||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||
calibre_db.session.rollback()
|
||||
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')
|
||||
|
||||
|
||||
@ -315,7 +326,7 @@ def convert_bookformat(book_id):
|
||||
book_format_to = request.form.get('book_format_to', 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))
|
||||
|
||||
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)
|
||||
|
||||
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),
|
||||
category="success")
|
||||
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))
|
||||
|
||||
|
||||
@ -451,7 +462,7 @@ def edit_list_book(param):
|
||||
calibre_db.session.rollback()
|
||||
log.error_or_exception("Database error: {}".format(e))
|
||||
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')
|
||||
return ret
|
||||
|
||||
@ -469,7 +480,7 @@ def get_sorted_entry(field, bookid):
|
||||
if field == 'sort':
|
||||
return json.dumps({'sort': book.title})
|
||||
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 ""
|
||||
|
||||
|
||||
@ -563,7 +574,7 @@ def table_xchange_author_title():
|
||||
calibre_db.session.commit()
|
||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||
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})
|
||||
|
||||
if config.config_use_google_drive:
|
||||
@ -573,9 +584,9 @@ def table_xchange_author_title():
|
||||
|
||||
|
||||
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'] = ''
|
||||
if to_save.get('book_title', "") == _(u'Unknown'):
|
||||
if to_save.get('book_title', "") == _('Unknown'):
|
||||
to_save['book_title'] = ''
|
||||
for s_field, m_field in [
|
||||
('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):]
|
||||
if val_key not in to_save.keys():
|
||||
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))
|
||||
return result
|
||||
|
||||
@ -611,7 +624,7 @@ def prepare_authors(authr):
|
||||
|
||||
# we have all author names now
|
||||
if input_authors == ['']:
|
||||
input_authors = [_(u'Unknown')] # prevent empty Author
|
||||
input_authors = [_('Unknown')] # prevent empty Author
|
||||
|
||||
renamed = list()
|
||||
for in_aut in input_authors:
|
||||
@ -628,11 +641,11 @@ def prepare_authors(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)
|
||||
if entry:
|
||||
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")
|
||||
|
||||
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)
|
||||
if 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
|
||||
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)
|
||||
except (IOError, OSError):
|
||||
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")
|
||||
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||
return meta, None
|
||||
@ -757,7 +770,7 @@ def move_coverfile(meta, db_book):
|
||||
os.unlink(meta.cover)
|
||||
except OSError as 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),
|
||||
category="error")
|
||||
|
||||
@ -771,7 +784,7 @@ def delete_whole_book(book_id, book):
|
||||
|
||||
# check if only this book links to:
|
||||
# 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.series, db.Series, calibre_db.session, 'series')
|
||||
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()
|
||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||
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")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
@ -927,7 +940,7 @@ def render_edit_book(book_id):
|
||||
if kepub_possible:
|
||||
allowed_conversion_formats.append('kepub')
|
||||
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,
|
||||
config=config,
|
||||
source_formats=valid_source_formats)
|
||||
@ -988,7 +1001,10 @@ def edit_book_series_index(series_index, book):
|
||||
def edit_book_comments(comments, book):
|
||||
modify_date = False
|
||||
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 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):
|
||||
invalid.append(lang)
|
||||
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
|
||||
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
|
||||
@ -1123,9 +1139,10 @@ def edit_cc_data(book_id, book, to_save, cc):
|
||||
cc_db_value = None
|
||||
if to_save[cc_string].strip():
|
||||
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:
|
||||
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:
|
||||
if cc_db_value is not None:
|
||||
# remove old cc_val
|
||||
@ -1154,7 +1171,7 @@ def upload_single_file(file_request, book, book_id):
|
||||
# check for empty request
|
||||
if requested_file.filename != '':
|
||||
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
|
||||
if '.' in requested_file.filename:
|
||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
||||
@ -1175,12 +1192,12 @@ def upload_single_file(file_request, book, book_id):
|
||||
try:
|
||||
os.makedirs(filepath)
|
||||
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
|
||||
try:
|
||||
requested_file.save(saved_filename)
|
||||
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
|
||||
|
||||
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:
|
||||
calibre_db.session.rollback()
|
||||
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))
|
||||
|
||||
# Queue uploader info
|
||||
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)))
|
||||
|
||||
return uploader.process(
|
||||
saved_filename, *os.path.splitext(requested_file.filename),
|
||||
rarExecutable=config.config_rarfile_location)
|
||||
rar_executable=config.config_rarfile_location)
|
||||
return None
|
||||
|
||||
|
||||
@ -1218,7 +1236,7 @@ def upload_cover(cover_request, book):
|
||||
# check for empty request
|
||||
if requested_file.filename != '':
|
||||
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
|
||||
ret, message = helper.save_cover(requested_file, book.path)
|
||||
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):
|
||||
change = False
|
||||
# handle author(s)
|
||||
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
|
||||
# everything then is assembled for sorted author field in database
|
||||
sort_authors_list = list()
|
||||
for inp in input_authors:
|
||||
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
||||
if not stored_author:
|
||||
stored_author = helper.get_sorted_author(inp)
|
||||
stored_author = helper.get_sorted_author(inp.replace('|', ','))
|
||||
else:
|
||||
stored_author = stored_author.sort
|
||||
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:
|
||||
book.author_sort = sort_authors
|
||||
change = True
|
||||
|
||||
change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
||||
|
||||
return input_authors, change, renamed
|
||||
|
||||
|
||||
@ -1268,14 +1289,15 @@ def search_objects_remove(db_book_object, db_type, input_elements):
|
||||
del_elements = []
|
||||
for c_elements in db_book_object:
|
||||
found = False
|
||||
if db_type == 'languages':
|
||||
type_elements = c_elements.lang_code
|
||||
elif db_type == 'custom':
|
||||
#if db_type == 'languages':
|
||||
# type_elements = c_elements.lang_code
|
||||
if db_type == 'custom':
|
||||
type_elements = c_elements.value
|
||||
else:
|
||||
type_elements = c_elements.name
|
||||
# type_elements = c_elements.name
|
||||
type_elements = c_elements
|
||||
for inp_element in input_elements:
|
||||
if inp_element.lower() == type_elements.lower():
|
||||
if type_elements == inp_element:
|
||||
found = True
|
||||
break
|
||||
# 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:
|
||||
found = False
|
||||
for c_elements in db_book_object:
|
||||
if db_type == 'languages':
|
||||
type_elements = c_elements.lang_code
|
||||
elif db_type == 'custom':
|
||||
if db_type == 'custom':
|
||||
type_elements = c_elements.value
|
||||
else:
|
||||
type_elements = c_elements.name
|
||||
if inp_element == type_elements:
|
||||
type_elements = c_elements
|
||||
if type_elements == inp_element:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
@ -1311,6 +1331,7 @@ def remove_objects(db_book_object, db_session, del_elements):
|
||||
changed = True
|
||||
if len(del_element.books) == 0:
|
||||
db_session.delete(del_element)
|
||||
db_session.flush()
|
||||
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
|
||||
for add_element in add_elements:
|
||||
# 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 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:
|
||||
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_book_object.append(new_element)
|
||||
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
|
||||
changed = True
|
||||
db_book_object.append(db_element)
|
||||
|
||||
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):
|
||||
raise TypeError(str(input_elements) + " should be passed as a list")
|
||||
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
|
||||
del_elements = search_objects_remove(db_book_object, db_type, input_elements)
|
||||
# 2. search for elements that need to be added
|
||||
add_elements = search_objects_add(db_book_object, db_type, input_elements)
|
||||
|
||||
# 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 len(add_elements) > 0:
|
||||
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)
|
||||
|
120
cps/epub.py
120
cps/epub.py
@ -21,25 +21,54 @@ import zipfile
|
||||
from lxml import etree
|
||||
|
||||
from . import isoLanguages, cover
|
||||
from . import config, logger
|
||||
from .helper import split_authors
|
||||
from .constants import BookMeta
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||
if cover_file is 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]
|
||||
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||
ext = os.path.splitext(tmp_cover_name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cf = zip_file.read(zip_cover_path)
|
||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||
cf = extension = None
|
||||
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
||||
|
||||
prefix = os.path.splitext(tmp_file_name)[0]
|
||||
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||
ext = os.path.splitext(tmp_cover_name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cf = zip_file.read(zip_cover_path)
|
||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||
|
||||
def get_epub_layout(book, book_data):
|
||||
ns = {
|
||||
'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):
|
||||
@ -80,13 +109,13 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
if epub_metadata['subject'] == 'Unknown':
|
||||
epub_metadata['subject'] = ''
|
||||
|
||||
if epub_metadata['publisher'] == u'Unknown':
|
||||
if epub_metadata['publisher'] == 'Unknown':
|
||||
epub_metadata['publisher'] = ''
|
||||
|
||||
if epub_metadata['date'] == u'Unknown':
|
||||
if epub_metadata['date'] == 'Unknown':
|
||||
epub_metadata['date'] = ''
|
||||
|
||||
if epub_metadata['description'] == u'Unknown':
|
||||
if epub_metadata['description'] == 'Unknown':
|
||||
description = tree.xpath("//*[local-name() = 'description']/text()")
|
||||
if len(description) > 0:
|
||||
epub_metadata['description'] = description
|
||||
@ -102,7 +131,10 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
|
||||
identifiers = []
|
||||
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
|
||||
if identifier_name in ('uuid', 'calibre') or identifier_value is None:
|
||||
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):
|
||||
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||
cover_file = None
|
||||
# if len(cover_section) > 0:
|
||||
for cs in cover_section:
|
||||
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||
if cover_file:
|
||||
break
|
||||
if not cover_file:
|
||||
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||
if len(meta_cover) > 0:
|
||||
return cover_file
|
||||
|
||||
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||
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(
|
||||
"/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
||||
if not cover_section:
|
||||
cover_section = tree.xpath(
|
||||
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
|
||||
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
|
||||
else:
|
||||
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@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:
|
||||
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||
for cs in cover_section:
|
||||
filetype = cs.rsplit('.', 1)[-1]
|
||||
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
||||
markup = 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
|
||||
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||
if cover_file:
|
||||
break
|
||||
return cover_file
|
||||
|
||||
|
||||
|
14
cps/fb2.py
14
cps/fb2.py
@ -38,19 +38,19 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
||||
if len(last_name):
|
||||
last_name = last_name[0]
|
||||
else:
|
||||
last_name = u''
|
||||
last_name = ''
|
||||
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
|
||||
if len(middle_name):
|
||||
middle_name = middle_name[0]
|
||||
else:
|
||||
middle_name = u''
|
||||
middle_name = ''
|
||||
first_name = element.xpath('fb:first-name/text()', namespaces=ns)
|
||||
if len(first_name):
|
||||
first_name = first_name[0]
|
||||
else:
|
||||
first_name = u''
|
||||
return (first_name + u' '
|
||||
+ middle_name + u' '
|
||||
first_name = ''
|
||||
return (first_name + ' '
|
||||
+ middle_name + ' '
|
||||
+ last_name)
|
||||
|
||||
author = str(", ".join(map(get_author, authors)))
|
||||
@ -59,12 +59,12 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
||||
if len(title):
|
||||
title = str(title[0])
|
||||
else:
|
||||
title = u''
|
||||
title = ''
|
||||
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
|
||||
if len(description):
|
||||
description = str(description[0])
|
||||
else:
|
||||
description = u''
|
||||
description = ''
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
|
@ -55,7 +55,7 @@ def authenticate_google_drive():
|
||||
try:
|
||||
authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl()
|
||||
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")
|
||||
return redirect(url_for('web.index'))
|
||||
return redirect(authUrl)
|
||||
@ -91,9 +91,9 @@ def watch_gdrive():
|
||||
config.save()
|
||||
except HttpError as e:
|
||||
reason=json.loads(e.content)['error']['errors'][0]
|
||||
if reason['reason'] == u'push.webhookUrlUnauthorized':
|
||||
flash(_(u'Callback domain is not verified, '
|
||||
u'please follow steps to verify domain in google developer console'), category="error")
|
||||
if reason['reason'] == 'push.webhookUrlUnauthorized':
|
||||
flash(_('Callback domain is not verified, '
|
||||
'please follow steps to verify domain in google developer console'), category="error")
|
||||
else:
|
||||
flash(reason['message'], category="error")
|
||||
|
||||
|
@ -147,7 +147,7 @@ engine = create_engine('sqlite:///{0}'.format(cli_param.gd_path), echo=False)
|
||||
Base = declarative_base()
|
||||
|
||||
# Open session for database connection
|
||||
Session = sessionmaker()
|
||||
Session = sessionmaker(autoflush=False)
|
||||
Session.configure(bind=engine)
|
||||
session = scoped_session(Session)
|
||||
|
||||
@ -174,30 +174,12 @@ class PermissionAdded(Base):
|
||||
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):
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
except Exception as ex:
|
||||
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||
raise
|
||||
migrate()
|
||||
|
||||
|
||||
def getDrive(drive=None, gauth=None):
|
||||
@ -344,7 +326,7 @@ def getFileFromEbooksFolder(path, fileName):
|
||||
|
||||
|
||||
def moveGdriveFileRemote(origin_file_id, new_title):
|
||||
origin_file_id['title']= new_title
|
||||
origin_file_id['title'] = new_title
|
||||
origin_file_id.Upload()
|
||||
|
||||
|
||||
@ -422,7 +404,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
driveFile.Upload()
|
||||
|
||||
|
||||
def uploadFileToEbooksFolder(destFile, f):
|
||||
def uploadFileToEbooksFolder(destFile, f, string=False):
|
||||
drive = getDrive(Gdrive.Instance().drive)
|
||||
parent = getEbooksFolder(drive)
|
||||
splitDir = destFile.split('/')
|
||||
@ -435,7 +417,10 @@ def uploadFileToEbooksFolder(destFile, f):
|
||||
else:
|
||||
driveFile = drive.CreateFile({'title': x,
|
||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||
driveFile.SetContentFile(f)
|
||||
if not string:
|
||||
driveFile.SetContentFile(f)
|
||||
else:
|
||||
driveFile.SetContentString(f)
|
||||
driveFile.Upload()
|
||||
else:
|
||||
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
|
||||
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()
|
||||
if storedPathName:
|
||||
storedPathName.path = sqlCheckPath
|
||||
|
207
cps/helper.py
Executable file → Normal file
207
cps/helper.py
Executable file → Normal file
@ -18,6 +18,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import random
|
||||
import io
|
||||
import mimetypes
|
||||
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)
|
||||
data = calibre_db.get_book_format(book.id, old_book_format)
|
||||
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)
|
||||
return error_message
|
||||
file_path = os.path.join(calibre_path, book.path, data.name)
|
||||
if config.config_use_google_drive:
|
||||
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())
|
||||
return error_message
|
||||
else:
|
||||
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())
|
||||
return error_message
|
||||
# read settings and append converter task to queue
|
||||
if ereader_mail:
|
||||
settings = config.get_mail_settings()
|
||||
settings['subject'] = _('Send to E-Reader') # pretranslate Subject for e-mail
|
||||
settings['body'] = _(u'This e-mail has been sent via Calibre-Web.')
|
||||
settings['subject'] = _('Send to eReader') # pretranslate Subject for Email
|
||||
settings['body'] = _('This Email has been sent via Calibre-Web.')
|
||||
else:
|
||||
settings = dict()
|
||||
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(),
|
||||
new_book_format.upper(),
|
||||
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
|
||||
def send_test_mail(ereader_mail, user_name):
|
||||
WorkerThread.add(user_name, TaskEmail(_(u'Calibre-Web test e-mail'), None, None,
|
||||
config.get_mail_settings(), ereader_mail, N_(u"Test e-mail"),
|
||||
_(u'This e-mail has been sent via Calibre-Web.')))
|
||||
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
||||
config.get_mail_settings(), ereader_mail, N_("Test Email"),
|
||||
_('This Email has been sent via Calibre-Web.')))
|
||||
return
|
||||
|
||||
|
||||
# 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):
|
||||
txt = "Hello %s!\r\n" % user_name
|
||||
txt = "Hi %s!\r\n" % user_name
|
||||
if not resend:
|
||||
txt += "Your new account at Calibre-Web has been created. Thanks for joining us!\r\n"
|
||||
txt += "Please log in to your account using the following information:\r\n"
|
||||
txt += "User name: %s\r\n" % user_name
|
||||
txt += "Your account at Calibre-Web has been created.\r\n"
|
||||
txt += "Please log in using the following information:\r\n"
|
||||
txt += "Username: %s\r\n" % user_name
|
||||
txt += "Password: %s\r\n" % default_password
|
||||
txt += "Don't forget to change your password after first login.\r\n"
|
||||
txt += "Sincerely\r\n\r\n"
|
||||
txt += "Your Calibre-Web team"
|
||||
txt += "Don't forget to change your password after your first login.\r\n"
|
||||
txt += "Regards,\r\n\r\n"
|
||||
txt += "Calibre-Web"
|
||||
WorkerThread.add(None, TaskEmail(
|
||||
subject=_(u'Get Started with Calibre-Web'),
|
||||
subject=_('Get Started with Calibre-Web'),
|
||||
filepath=None,
|
||||
attachment=None,
|
||||
settings=config.get_mail_settings(),
|
||||
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
|
||||
))
|
||||
return
|
||||
@ -145,13 +146,13 @@ def check_send_to_ereader_with_converter(formats):
|
||||
if 'MOBI' in formats and 'EPUB' not in formats:
|
||||
book_formats.append({'format': 'Epub',
|
||||
'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',
|
||||
format='Epub')})
|
||||
if 'AZW3' in formats and 'EPUB' not in formats:
|
||||
book_formats.append({'format': 'Epub',
|
||||
'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',
|
||||
format='Epub')})
|
||||
return book_formats
|
||||
@ -159,7 +160,7 @@ def check_send_to_ereader_with_converter(formats):
|
||||
|
||||
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()
|
||||
book_formats = list()
|
||||
@ -170,31 +171,27 @@ def check_send_to_ereader(entry):
|
||||
if 'EPUB' in formats:
|
||||
book_formats.append({'format': 'Epub',
|
||||
'convert': 0,
|
||||
'text': _('Send %(format)s to E-Reader', format='Epub')})
|
||||
if 'MOBI' in formats:
|
||||
book_formats.append({'format': 'Mobi',
|
||||
'convert': 0,
|
||||
'text': _('Send %(format)s to E-Reader', format='Mobi')})
|
||||
'text': _('Send %(format)s to eReader', format='Epub')})
|
||||
if 'PDF' in formats:
|
||||
book_formats.append({'format': 'Pdf',
|
||||
'convert': 0,
|
||||
'text': _('Send %(format)s to E-Reader', format='Pdf')})
|
||||
'text': _('Send %(format)s to eReader', format='Pdf')})
|
||||
if 'AZW' in formats:
|
||||
book_formats.append({'format': 'Azw',
|
||||
'convert': 0,
|
||||
'text': _('Send %(format)s to E-Reader', format='Azw')})
|
||||
'text': _('Send %(format)s to eReader', format='Azw')})
|
||||
if config.config_converterpath:
|
||||
book_formats.extend(check_send_to_ereader_with_converter(formats))
|
||||
return book_formats
|
||||
else:
|
||||
log.error(u'Cannot find book entry %d', entry.id)
|
||||
log.error('Cannot find book entry %d', entry.id)
|
||||
return None
|
||||
|
||||
|
||||
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
|
||||
# list with supported formats
|
||||
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()
|
||||
if len(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:
|
||||
# 1: If Mobi file is existing, it's directly send to E-Reader email,
|
||||
# 2: If Epub file is existing, it's converted and send to E-Reader email,
|
||||
# 3: If Pdf 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 mobi file is existing, it's converted and send to eReader 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):
|
||||
"""Send email with attachments"""
|
||||
book = calibre_db.get_book(book_id)
|
||||
|
||||
if convert == 1:
|
||||
# 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:
|
||||
# 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):
|
||||
if entry.format.upper() == book_format.upper():
|
||||
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))
|
||||
email_text = N_(u"%(book)s send to E-Reader", book=link)
|
||||
WorkerThread.add(user_id, TaskEmail(_(u"Send to E-Reader"), book.path, converted_file_name,
|
||||
email_text = N_("%(book)s send to eReader", book=link)
|
||||
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
||||
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 _(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):
|
||||
@ -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
|
||||
filename. Limits num characters to 128 max.
|
||||
"""
|
||||
if value[-1:] == u'.':
|
||||
value = value[:-1]+u'_'
|
||||
if value[-1:] == '.':
|
||||
value = value[:-1]+'_'
|
||||
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
||||
if config.config_unicode_filename:
|
||||
value = (unidecode.unidecode(value))
|
||||
if replace_whitespace:
|
||||
# *+:\"/<>? 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
|
||||
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()
|
||||
|
||||
@ -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)
|
||||
except (OperationalError, InvalidRequestError) as ex:
|
||||
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 ""
|
||||
|
||||
@ -416,8 +413,8 @@ def clean_author_database(renamed_author, calibre_path="", local_book=None, gdri
|
||||
g_file = gd.getFileFromEbooksFolder(all_new_path,
|
||||
file_format.name + '.' + file_format.format.lower())
|
||||
if g_file:
|
||||
gd.moveGdriveFileRemote(g_file, all_new_name + u'.' + file_format.format.lower())
|
||||
gd.updateDatabaseOnEdit(g_file['id'], 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 + '.' + file_format.format.lower())
|
||||
else:
|
||||
log.error("File {} not found on gdrive"
|
||||
.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]
|
||||
titledir = book.path.split('/')[1]
|
||||
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:
|
||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
|
||||
if g_file:
|
||||
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
|
||||
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:
|
||||
g_file = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
|
||||
if g_file:
|
||||
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)
|
||||
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
|
||||
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'])
|
||||
g_file.Trash()
|
||||
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
|
||||
|
||||
@ -612,7 +609,7 @@ def reset_password(user_id):
|
||||
if not config.get_mail_server_configured():
|
||||
return 2, None
|
||||
try:
|
||||
password = generate_random_password()
|
||||
password = generate_random_password(config.config_password_min_length)
|
||||
existing_user.password = generate_password_hash(password)
|
||||
ub.session.commit()
|
||||
send_registration_mail(existing_user.email, existing_user.name, password, True)
|
||||
@ -621,11 +618,35 @@ def reset_password(user_id):
|
||||
ub.session.rollback()
|
||||
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!@#$%&*()?"
|
||||
passlen = 8
|
||||
return "".join(s[c % len(s)] for c in os.urandom(passlen))
|
||||
passlen = min_length
|
||||
return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
|
||||
|
||||
|
||||
def uniq(inpt):
|
||||
@ -640,16 +661,16 @@ def uniq(inpt):
|
||||
def check_email(email):
|
||||
email = valid_email(email)
|
||||
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")
|
||||
raise Exception(_(u"Found an existing account for this e-mail address"))
|
||||
log.error("Found an existing account for this Email address")
|
||||
raise Exception(_("Found an existing account for this Email address"))
|
||||
return email
|
||||
|
||||
|
||||
def check_username(username):
|
||||
username = username.strip()
|
||||
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
|
||||
log.error(u"This username is already taken")
|
||||
raise Exception(_(u"This username is already taken"))
|
||||
log.error("This username is already taken")
|
||||
raise Exception(_("This username is already taken"))
|
||||
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
|
||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||
email):
|
||||
log.error(u"Invalid e-mail address format")
|
||||
raise Exception(_(u"Invalid e-mail address format"))
|
||||
log.error("Invalid Email address format")
|
||||
raise Exception(_("Invalid Email address format"))
|
||||
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 #################################
|
||||
|
||||
|
||||
@ -694,28 +732,27 @@ def delete_book(book, calibrepath, book_format):
|
||||
return delete_book_file(book, calibrepath, book_format)
|
||||
|
||||
|
||||
def get_cover_on_failure(use_generic_cover):
|
||||
if use_generic_cover:
|
||||
try:
|
||||
return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
|
||||
except PermissionError:
|
||||
log.error("No permission to access generic_cover.jpg file.")
|
||||
abort(403)
|
||||
abort(404)
|
||||
def get_cover_on_failure():
|
||||
try:
|
||||
return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
|
||||
except PermissionError:
|
||||
log.error("No permission to access generic_cover.jpg file.")
|
||||
abort(403)
|
||||
|
||||
|
||||
def get_book_cover(book_id, resolution=None):
|
||||
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):
|
||||
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:
|
||||
|
||||
# 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:
|
||||
try:
|
||||
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)
|
||||
if path:
|
||||
return redirect(path)
|
||||
else:
|
||||
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:
|
||||
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
|
||||
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")):
|
||||
return send_from_directory(cover_file_path, "cover.jpg")
|
||||
else:
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
else:
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
|
||||
|
||||
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) \
|
||||
.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):
|
||||
@ -837,8 +874,8 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
|
||||
try:
|
||||
os.makedirs(filepath)
|
||||
except OSError:
|
||||
log.error(u"Failed to create path for cover")
|
||||
return False, _(u"Failed to create path for cover")
|
||||
log.error("Failed to create path for cover")
|
||||
return False, _("Failed to create path for cover")
|
||||
try:
|
||||
# upload of jgp file without wand
|
||||
if isinstance(img, requests.Response):
|
||||
@ -853,8 +890,8 @@ def save_cover_from_filestorage(filepath, saved_filename, img):
|
||||
# upload of jpg/png... from hdd
|
||||
img.save(os.path.join(filepath, saved_filename))
|
||||
except (IOError, OSError):
|
||||
log.error(u"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")
|
||||
log.error("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
|
||||
|
||||
|
||||
@ -1004,7 +1041,7 @@ def get_download_link(book_id, book_format, client):
|
||||
headers = Headers()
|
||||
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
||||
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)
|
||||
else:
|
||||
abort(404)
|
||||
|
File diff suppressed because it is too large
Load Diff
158
cps/kobo.py
158
cps/kobo.py
@ -21,6 +21,7 @@ import base64
|
||||
import datetime
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from time import gmtime, strftime
|
||||
import json
|
||||
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 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 .services import SyncToken as SyncToken
|
||||
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_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
|
||||
|
||||
@ -140,6 +142,7 @@ def HandleSyncRequest():
|
||||
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
||||
log.info("Kobo library sync request received.")
|
||||
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:
|
||||
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
|
||||
|
||||
if only_kobo_shelves:
|
||||
if sqlalchemy_version2:
|
||||
changed_entries = select(db.Books,
|
||||
ub.ArchivedBook.last_modified,
|
||||
ub.BookShelf.date_added,
|
||||
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 = calibre_db.session.query(db.Books,
|
||||
ub.ArchivedBook.last_modified,
|
||||
ub.BookShelf.date_added,
|
||||
ub.ArchivedBook.is_archived)
|
||||
changed_entries = (changed_entries
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||
ub.ArchivedBook.user_id == current_user.id))
|
||||
@ -189,12 +186,9 @@ def HandleSyncRequest():
|
||||
.filter(ub.Shelf.kobo_sync)
|
||||
.distinct())
|
||||
else:
|
||||
if sqlalchemy_version2:
|
||||
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
||||
else:
|
||||
changed_entries = calibre_db.session.query(db.Books,
|
||||
ub.ArchivedBook.last_modified,
|
||||
ub.ArchivedBook.is_archived)
|
||||
changed_entries = calibre_db.session.query(db.Books,
|
||||
ub.ArchivedBook.last_modified,
|
||||
ub.ArchivedBook.is_archived)
|
||||
changed_entries = (changed_entries
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||
ub.ArchivedBook.user_id == current_user.id))
|
||||
@ -206,10 +200,7 @@ def HandleSyncRequest():
|
||||
.order_by(db.Books.id))
|
||||
|
||||
reading_states_in_new_entitlements = []
|
||||
if sqlalchemy_version2:
|
||||
books = calibre_db.session.execute(changed_entries.limit(SYNC_ITEM_LIMIT))
|
||||
else:
|
||||
books = changed_entries.limit(SYNC_ITEM_LIMIT)
|
||||
books = changed_entries.limit(SYNC_ITEM_LIMIT)
|
||||
log.debug("Books to Sync: {}".format(len(books.all())))
|
||||
for book in books:
|
||||
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)
|
||||
reading_states_in_new_entitlements.append(book.Books.id)
|
||||
|
||||
ts_created = book.Books.timestamp
|
||||
ts_created = book.Books.timestamp.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
ts_created = max(ts_created, book.date_added)
|
||||
@ -240,7 +231,7 @@ def HandleSyncRequest():
|
||||
sync_results.append({"ChangedEntitlement": entitlement})
|
||||
|
||||
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:
|
||||
new_books_last_modified = max(
|
||||
@ -252,27 +243,16 @@ def HandleSyncRequest():
|
||||
new_books_last_created = max(ts_created, new_books_last_created)
|
||||
kobo_sync_status.add_synced_books(book.Books.id)
|
||||
|
||||
if sqlalchemy_version2:
|
||||
max_change = calibre_db.session.execute(changed_entries
|
||||
.filter(ub.ArchivedBook.is_archived)
|
||||
.filter(ub.ArchivedBook.user_id == current_user.id)
|
||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
|
||||
.columns(db.Books).first()
|
||||
else:
|
||||
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
|
||||
.filter(ub.ArchivedBook.user_id == current_user.id) \
|
||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
||||
max_change = changed_entries.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
|
||||
|
||||
new_archived_last_modified = max(new_archived_last_modified, max_change)
|
||||
|
||||
# no. of books returned
|
||||
if sqlalchemy_version2:
|
||||
entries = calibre_db.session.execute(changed_entries).all()
|
||||
book_count = len(entries)
|
||||
else:
|
||||
book_count = changed_entries.count()
|
||||
book_count = changed_entries.count()
|
||||
# last entry:
|
||||
cont_sync = bool(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")
|
||||
|
||||
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:
|
||||
extra_headers["x-kobo-sync"] = "continue"
|
||||
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)
|
||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||
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()
|
||||
|
||||
metadata = get_metadata(book)
|
||||
@ -365,7 +345,7 @@ def HandleMetadataRequest(book_uuid):
|
||||
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 ':' in request.host and not request.host.endswith(']'):
|
||||
host = "".join(request.host.split(':')[:-1])
|
||||
@ -377,13 +357,13 @@ def get_download_url_for_book(book, book_format):
|
||||
url_base=host,
|
||||
url_port=config.config_external_port,
|
||||
auth_token=get_auth_token(),
|
||||
book_id=book.id,
|
||||
book_id=book_id,
|
||||
book_format=book_format.lower()
|
||||
)
|
||||
return url_for(
|
||||
"kobo.download_book",
|
||||
auth_token=kobo_auth.get_auth_token(),
|
||||
book_id=book.id,
|
||||
book_id=book_id,
|
||||
book_format=book_format.lower(),
|
||||
_external=True,
|
||||
)
|
||||
@ -459,16 +439,21 @@ def get_metadata(book):
|
||||
continue
|
||||
for kobo_format in KOBO_FORMATS[book_data.format]:
|
||||
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
|
||||
download_urls.append(
|
||||
{
|
||||
"Format": kobo_format,
|
||||
"Size": book_data.uncompressed_size,
|
||||
"Url": get_download_url_for_book(book, book_data.format),
|
||||
# The Kobo forma accepts platforms: (Generic, Android)
|
||||
"Platform": "Generic",
|
||||
# "DrmType": "None", # Not required
|
||||
}
|
||||
)
|
||||
try:
|
||||
if get_epub_layout(book, book_data) == 'pre-paginated':
|
||||
kobo_format = 'EPUB3FL'
|
||||
download_urls.append(
|
||||
{
|
||||
"Format": kobo_format,
|
||||
"Size": book_data.uncompressed_size,
|
||||
"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
|
||||
metadata = {
|
||||
@ -515,7 +500,7 @@ def get_metadata(book):
|
||||
@requires_kobo_auth
|
||||
# Creates a Shelf with the given items, and returns the shelf's uuid.
|
||||
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":
|
||||
abort(405)
|
||||
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)
|
||||
|
||||
if sqlalchemy_version2:
|
||||
shelflist = ub.session.execute(select(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())).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())
|
||||
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:
|
||||
if not shelf_lib.check_shelf_view_permissions(shelf):
|
||||
@ -759,7 +736,7 @@ def create_kobo_tag(shelf):
|
||||
for book_shelf in shelf.books:
|
||||
book = calibre_db.get_book(book_shelf.book_id)
|
||||
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
|
||||
tag["Items"].append(
|
||||
{
|
||||
@ -776,7 +753,7 @@ def create_kobo_tag(shelf):
|
||||
def HandleStateRequest(book_uuid):
|
||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||
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()
|
||||
|
||||
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")
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
||||
if not book_cover:
|
||||
if config.config_kobo_proxy:
|
||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
||||
return redirect(KOBO_IMAGEHOST_URL +
|
||||
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
||||
width=width,
|
||||
height=height), 307)
|
||||
else:
|
||||
log.debug("Cover for unknown book: %s requested" % book_uuid)
|
||||
# additional proxy request make no sense, -> direct return
|
||||
return make_response(jsonify({}))
|
||||
log.debug("Cover request received for book %s" % book_uuid)
|
||||
return book_cover
|
||||
try:
|
||||
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
|
||||
except ValueError:
|
||||
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=resolution)
|
||||
if book_cover:
|
||||
log.debug("Serving local cover image of book %s" % book_uuid)
|
||||
return book_cover
|
||||
|
||||
if not config.config_kobo_proxy:
|
||||
log.debug("Returning 404 for cover image of unknown book %s" % book_uuid)
|
||||
# additional proxy request make no sense, -> direct return
|
||||
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("")
|
||||
@ -951,7 +934,7 @@ def HandleBookDeletionRequest(book_uuid):
|
||||
log.info("Kobo book delete request received for book %s" % book_uuid)
|
||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||
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()
|
||||
|
||||
book_id = book.id
|
||||
@ -976,6 +959,7 @@ def HandleUnimplementedRequest(dummy=None):
|
||||
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
||||
@kobo.route("/v1/assets", methods=["GET"])
|
||||
def HandleUserRequest(dummy=None):
|
||||
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
||||
return redirect_or_proxy_request()
|
||||
@ -1034,7 +1018,7 @@ def make_calibre_web_auth_response():
|
||||
"RefreshToken": RefreshToken,
|
||||
"TokenType": "Bearer",
|
||||
"TrackingId": str(uuid.uuid4()),
|
||||
"UserKey": content['UserKey'],
|
||||
"UserKey": content.get('UserKey',""),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -64,11 +64,12 @@ from datetime import datetime
|
||||
from os import urandom
|
||||
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_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
|
||||
|
||||
log = logger.create()
|
||||
@ -112,7 +113,7 @@ def generate_auth_token(user_id):
|
||||
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Setup"),
|
||||
title=_("Kobo Setup"),
|
||||
auth_token=auth_token.auth_token,
|
||||
warning = warning
|
||||
)
|
||||
@ -151,6 +152,10 @@ def requires_kobo_auth(f):
|
||||
def inner(*args, **kwargs):
|
||||
auth_token = get_auth_token()
|
||||
if auth_token is not None:
|
||||
try:
|
||||
limiter.check()
|
||||
except RateLimitExceeded:
|
||||
return abort(429)
|
||||
user = (
|
||||
ub.session.query(ub.User)
|
||||
.join(ub.RemoteAuthToken)
|
||||
@ -159,7 +164,8 @@ def requires_kobo_auth(f):
|
||||
)
|
||||
if user is not None:
|
||||
login_user(user)
|
||||
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
|
||||
return f(*args, **kwargs)
|
||||
log.debug("Received Kobo request without a recognizable auth token.")
|
||||
return abort(401)
|
||||
log.debug("Received Kobo request without a recognizable auth token.")
|
||||
return abort(401)
|
||||
return inner
|
||||
|
@ -150,7 +150,7 @@ def setup(log_file, log_level=None):
|
||||
else:
|
||||
try:
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||
except IOError:
|
||||
except (IOError, PermissionError):
|
||||
if log_file == DEFAULT_LOG_FILE:
|
||||
raise
|
||||
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)
|
||||
try:
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||
except IOError:
|
||||
except (IOError, PermissionError):
|
||||
if log_file == DEFAULT_ACCESS_LOG:
|
||||
raise
|
||||
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||
|
10
cps/main.py
10
cps/main.py
@ -18,9 +18,14 @@
|
||||
|
||||
import sys
|
||||
|
||||
from . import create_app
|
||||
from . import create_app, limiter
|
||||
from .jinjia import jinjia
|
||||
from .remotelogin import remotelogin
|
||||
from flask import request
|
||||
|
||||
|
||||
def request_username():
|
||||
return request.authorization.username
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
@ -39,6 +44,7 @@ def main():
|
||||
try:
|
||||
from .kobo import kobo, get_kobo_activated
|
||||
from .kobo_auth import kobo_auth
|
||||
from flask_limiter.util import get_remote_address
|
||||
kobo_available = get_kobo_activated()
|
||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||
kobo_available = False
|
||||
@ -56,6 +62,7 @@ def main():
|
||||
app.register_blueprint(tasks)
|
||||
app.register_blueprint(web)
|
||||
app.register_blueprint(opds)
|
||||
limiter.limit("3/minute",key_func=request_username)(opds)
|
||||
app.register_blueprint(jinjia)
|
||||
app.register_blueprint(about)
|
||||
app.register_blueprint(shelf)
|
||||
@ -67,6 +74,7 @@ def main():
|
||||
if kobo_available:
|
||||
app.register_blueprint(kobo)
|
||||
app.register_blueprint(kobo_auth)
|
||||
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
|
||||
if oauth_available:
|
||||
app.register_blueprint(oauth)
|
||||
success = web_server.start()
|
||||
|
@ -63,11 +63,11 @@ class Amazon(Metadata):
|
||||
r.raise_for_status()
|
||||
except Exception as ex:
|
||||
log.warning(ex)
|
||||
return
|
||||
return None
|
||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||
if soup2 is None:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
match = MetaRecord(
|
||||
title = "",
|
||||
@ -98,7 +98,7 @@ class Amazon(Metadata):
|
||||
try:
|
||||
match.authors = [next(
|
||||
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"})]
|
||||
except (AttributeError, TypeError, StopIteration):
|
||||
match.authors = ""
|
||||
@ -115,7 +115,7 @@ class Amazon(Metadata):
|
||||
return match, index
|
||||
except Exception as e:
|
||||
log.error_or_exception(e)
|
||||
return
|
||||
return None
|
||||
|
||||
val = list()
|
||||
if self.active:
|
||||
@ -127,10 +127,10 @@ class Amazon(Metadata):
|
||||
results.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
return []
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
return []
|
||||
soup = BS(results.text, 'html.parser')
|
||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||
|
@ -43,7 +43,8 @@ class Douban(Metadata):
|
||||
__id__ = "douban"
|
||||
DESCRIPTION = "豆瓣"
|
||||
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+),")
|
||||
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
||||
@ -52,6 +53,7 @@ class Douban(Metadata):
|
||||
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
||||
SERIES_PATTERN = re.compile(r"丛书")
|
||||
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
||||
CRITERIA_PATTERN = re.compile("criteria = '(.+)'")
|
||||
|
||||
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
||||
COVER_XPATH = "//a[@class='nbg']"
|
||||
@ -63,56 +65,90 @@ class Douban(Metadata):
|
||||
session = requests.Session()
|
||||
session.headers = {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.56',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.56',
|
||||
}
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
def search(self,
|
||||
query: str,
|
||||
generic_cover: str = "",
|
||||
locale: str = "en") -> List[MetaRecord]:
|
||||
val = []
|
||||
if self.active:
|
||||
log.debug(f"starting search {query} on douban")
|
||||
log.debug(f"start searching {query} on douban")
|
||||
if title_tokens := list(
|
||||
self.get_title_tokens(query, strip_joiners=False)
|
||||
):
|
||||
self.get_title_tokens(query, strip_joiners=False)):
|
||||
query = "+".join(title_tokens)
|
||||
|
||||
try:
|
||||
r = self.session.get(
|
||||
self.SEARCH_URL, params={"cat": 1001, "q": query}
|
||||
)
|
||||
r.raise_for_status()
|
||||
book_id_list = self._get_book_id_list_from_html(query)
|
||||
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
|
||||
results = r.json()
|
||||
if results["total"] == 0:
|
||||
if not book_id_list:
|
||||
log.debug("No search results in Douban")
|
||||
return []
|
||||
|
||||
book_id_list = [
|
||||
self.ID_PATTERN.search(item).group("id")
|
||||
for item in results["items"][:10] if self.ID_PATTERN.search(item)
|
||||
]
|
||||
|
||||
with futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
with futures.ThreadPoolExecutor(
|
||||
max_workers=5, thread_name_prefix='douban') as executor:
|
||||
|
||||
fut = [
|
||||
executor.submit(self._parse_single_book, book_id, generic_cover)
|
||||
for book_id in book_id_list
|
||||
executor.submit(self._parse_single_book, book_id,
|
||||
generic_cover) for book_id in book_id_list
|
||||
]
|
||||
|
||||
|
||||
val = [
|
||||
future.result()
|
||||
for future in futures.as_completed(fut) if future.result()
|
||||
future.result() for future in futures.as_completed(fut)
|
||||
if future.result()
|
||||
]
|
||||
|
||||
return val
|
||||
|
||||
def _parse_single_book(
|
||||
self, id: str, generic_cover: str = ""
|
||||
) -> Optional[MetaRecord]:
|
||||
def _get_book_id_list_from_html(self, query: str) -> List[str]:
|
||||
try:
|
||||
r = self.session.get(self.SEARCH_URL,
|
||||
params={
|
||||
"cat": 1001,
|
||||
"q": query
|
||||
})
|
||||
r.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
|
||||
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}/"
|
||||
log.debug(f"start parsing {url}")
|
||||
|
||||
try:
|
||||
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.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:
|
||||
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
||||
except Exception:
|
||||
@ -146,35 +184,39 @@ class Douban(Metadata):
|
||||
tag_elements = html.xpath(self.TAGS_XPATH)
|
||||
if len(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)
|
||||
if len(description_element):
|
||||
match.description = html2text(etree.tostring(
|
||||
description_element[-1], encoding="utf8").decode("utf8"))
|
||||
match.description = html2text(
|
||||
etree.tostring(description_element[-1]).decode("utf8"))
|
||||
|
||||
info = html.xpath(self.INFO_XPATH)
|
||||
|
||||
for element in info:
|
||||
text = element.text
|
||||
if self.AUTHORS_PATTERN.search(text):
|
||||
next = element.getnext()
|
||||
while next is not None and next.tag != "br":
|
||||
match.authors.append(next.text)
|
||||
next = next.getnext()
|
||||
next_element = element.getnext()
|
||||
while next_element is not None and next_element.tag != "br":
|
||||
match.authors.append(next_element.text)
|
||||
next_element = next_element.getnext()
|
||||
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):
|
||||
match.title = f'{match.title}:' + element.tail.strip()
|
||||
match.title = f'{match.title}:{element.tail.strip()}'
|
||||
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
||||
match.publishedDate = self._clean_date(element.tail.strip())
|
||||
elif self.SUBTITLE_PATTERN.search(text):
|
||||
elif self.SERIES_PATTERN.search(text):
|
||||
match.series = element.getnext().text
|
||||
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
||||
match.identifiers[i_type.group()] = element.tail.strip()
|
||||
|
||||
return match
|
||||
|
||||
|
||||
def _clean_date(self, date: str) -> str:
|
||||
"""
|
||||
Clean up the date string to be in the format YYYY-MM-DD
|
||||
@ -194,13 +236,24 @@ class Douban(Metadata):
|
||||
if date[i].isdigit():
|
||||
digit.append(date[i])
|
||||
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 = []
|
||||
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]
|
||||
if len(ls)>1:
|
||||
day = ls[1]
|
||||
if len(ls) > 1:
|
||||
day = ls[1]
|
||||
|
||||
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
|
||||
|
@ -19,6 +19,7 @@
|
||||
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
@ -81,7 +82,11 @@ class Google(Metadata):
|
||||
match.description = result["volumeInfo"].get("description", "")
|
||||
match.languages = self._parse_languages(result=result, locale=locale)
|
||||
match.publisher = result["volumeInfo"].get("publisher", "")
|
||||
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
|
||||
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.series, match.series_index = "", 1
|
||||
match.tags = result["volumeInfo"].get("categories", [])
|
||||
@ -103,6 +108,13 @@ class Google(Metadata):
|
||||
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
||||
if result["volumeInfo"].get("imageLinks"):
|
||||
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 generic_cover
|
||||
|
||||
|
@ -102,7 +102,7 @@ class LubimyCzytac(Metadata):
|
||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||
TAGS = "//nav[@aria-label='breadcrumb']//a[contains(@href,'/ksiazki/k/')]/text()"
|
||||
TAGS = "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
||||
|
||||
RATING = "//meta[@property='books:rating:value']/@content"
|
||||
COVER = "//meta[@property='og:image']/@content"
|
||||
|
@ -74,7 +74,7 @@ def register_user_with_oauth(user=None):
|
||||
if len(all_oauth.keys()) == 0:
|
||||
return
|
||||
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:
|
||||
for oauth_key in all_oauth.keys():
|
||||
# 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
|
||||
if oauth_entry.user:
|
||||
login_user(oauth_entry.user)
|
||||
log.debug(u"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),
|
||||
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
||||
flash(_("Success! You are now logged in as: %(nickname)s", nickname= oauth_entry.user.name),
|
||||
category="success")
|
||||
return redirect(url_for('web.index'))
|
||||
else:
|
||||
@ -145,21 +145,21 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
||||
try:
|
||||
ub.session.add(oauth_entry)
|
||||
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))
|
||||
return redirect(url_for('web.profile'))
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
ub.session.rollback()
|
||||
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')
|
||||
return redirect(url_for('web.login'))
|
||||
# return redirect(url_for('web.login'))
|
||||
# if config.config_public_reg:
|
||||
# return redirect(url_for('web.register'))
|
||||
# else:
|
||||
# flash(_(u"Public registration is not enabled"), category="error")
|
||||
# flash(_("Public registration is not enabled"), category="error")
|
||||
# return redirect(url_for(redirect_url))
|
||||
except (NoResultFound, AttributeError):
|
||||
return redirect(url_for(redirect_url))
|
||||
@ -194,15 +194,15 @@ def unlink_oauth(provider):
|
||||
ub.session.delete(oauth_entry)
|
||||
ub.session.commit()
|
||||
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]))
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
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:
|
||||
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'))
|
||||
|
||||
def generate_oauth_blueprints():
|
||||
@ -258,13 +258,13 @@ if ub.oauth_support:
|
||||
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
||||
def github_logged_in(blueprint, 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")
|
||||
return False
|
||||
|
||||
resp = blueprint.session.get("/user")
|
||||
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")
|
||||
return False
|
||||
|
||||
@ -276,13 +276,13 @@ if ub.oauth_support:
|
||||
@oauth_authorized.connect_via(oauthblueprints[1]['blueprint'])
|
||||
def google_logged_in(blueprint, 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")
|
||||
return False
|
||||
|
||||
resp = blueprint.session.get("/oauth2/v2/userinfo")
|
||||
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")
|
||||
return False
|
||||
|
||||
@ -295,8 +295,8 @@ if ub.oauth_support:
|
||||
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
||||
def github_error(blueprint, error, error_description=None, error_uri=None):
|
||||
msg = (
|
||||
u"OAuth error from {name}! "
|
||||
u"error={error} description={description} uri={uri}"
|
||||
"OAuth error from {name}! "
|
||||
"error={error} description={description} uri={uri}"
|
||||
).format(
|
||||
name=blueprint.name,
|
||||
error=error,
|
||||
@ -308,8 +308,8 @@ if ub.oauth_support:
|
||||
@oauth_error.connect_via(oauthblueprints[1]['blueprint'])
|
||||
def google_error(blueprint, error, error_description=None, error_uri=None):
|
||||
msg = (
|
||||
u"OAuth error from {name}! "
|
||||
u"error={error} description={description} uri={uri}"
|
||||
"OAuth error from {name}! "
|
||||
"error={error} description={description} uri={uri}"
|
||||
).format(
|
||||
name=blueprint.name,
|
||||
error=error,
|
||||
@ -329,10 +329,10 @@ def github_login():
|
||||
if account_info.ok:
|
||||
account_info_json = account_info.json()
|
||||
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")
|
||||
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)
|
||||
return redirect(url_for('web.login'))
|
||||
|
||||
@ -353,10 +353,10 @@ def google_login():
|
||||
if resp.ok:
|
||||
account_info_json = resp.json()
|
||||
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")
|
||||
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)
|
||||
return redirect(url_for('web.login'))
|
||||
|
||||
|
70
cps/opds.py
70
cps/opds.py
@ -21,41 +21,28 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import json
|
||||
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_babel import get_locale
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||
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 .pagination import Pagination
|
||||
from .web import render_read_books
|
||||
from .usermanagement import load_user_from_request
|
||||
from flask_babel import gettext as _
|
||||
|
||||
|
||||
opds = Blueprint('opds', __name__)
|
||||
|
||||
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")
|
||||
@requires_basic_auth_if_no_ano
|
||||
@ -69,7 +56,7 @@ def feed_osd():
|
||||
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>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_cc_search(query):
|
||||
@ -328,7 +315,7 @@ def feed_format(book_id):
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_languagesindex():
|
||||
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()
|
||||
else:
|
||||
languages = calibre_db.session.query(db.Languages).filter(
|
||||
@ -355,7 +342,8 @@ def feed_languages(book_id):
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_shelfindex():
|
||||
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)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
number)
|
||||
@ -402,11 +390,7 @@ def feed_shelf(book_id):
|
||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def opds_download_link(book_id, book_format):
|
||||
# I gave up with this: With enabled ldap login, the user doesn't get logged in, therefore it's always guest
|
||||
# workaround, loading the user from the request and checking 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():
|
||||
if not current_user.role_download():
|
||||
return abort(403)
|
||||
if "Kobo" in request.headers.get('User-Agent'):
|
||||
client = "kobo"
|
||||
@ -429,6 +413,17 @@ def get_metadata_calibre_companion(uuid, library):
|
||||
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/cover_240_240/<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="")
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
try:
|
||||
username = username.encode('windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
username = username.encode('utf-8')
|
||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
|
||||
username.decode('utf-8').lower()).first()
|
||||
if bool(user and check_password_hash(str(user.password), password)):
|
||||
return True
|
||||
else:
|
||||
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_address)
|
||||
return False
|
||||
|
||||
|
||||
def authenticate():
|
||||
return Response(
|
||||
'Could not verify your access level for that URL.\n'
|
||||
'You have to login with proper credentials', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="Login Required"'})
|
||||
|
||||
|
||||
def render_xml_template(*args, **kwargs):
|
||||
# ToDo: return time in current timezone similar to %z
|
||||
|
@ -58,8 +58,8 @@ def remote_login():
|
||||
ub.session.add(auth_token)
|
||||
ub.session_commit()
|
||||
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)
|
||||
return render_title_template('remote_login.html', title=_(u"Login"), token=auth_token.auth_token,
|
||||
log.debug("Remot Login request with token: %s", auth_token.auth_token)
|
||||
return render_title_template('remote_login.html', title=_("Login"), token=auth_token.auth_token,
|
||||
verify_url=verify_url, page="remotelogin")
|
||||
|
||||
|
||||
@ -71,8 +71,8 @@ def verify_token(token):
|
||||
|
||||
# Token not found
|
||||
if auth_token is None:
|
||||
flash(_(u"Token not found"), category="error")
|
||||
log.error(u"Remote Login token not found")
|
||||
flash(_("Token not found"), category="error")
|
||||
log.error("Remote Login token not found")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
# Token expired
|
||||
@ -80,8 +80,8 @@ def verify_token(token):
|
||||
ub.session.delete(auth_token)
|
||||
ub.session_commit()
|
||||
|
||||
flash(_(u"Token has expired"), category="error")
|
||||
log.error(u"Remote Login token expired")
|
||||
flash(_("Token has expired"), category="error")
|
||||
log.error("Remote Login token expired")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
# Update token with user information
|
||||
@ -89,8 +89,8 @@ def verify_token(token):
|
||||
auth_token.verified = True
|
||||
ub.session_commit()
|
||||
|
||||
flash(_(u"Success! Please return to your device"), category="success")
|
||||
log.debug(u"Remote Login token for userid %s verified", auth_token.user_id)
|
||||
flash(_("Success! Please return to your device"), category="success")
|
||||
log.debug("Remote Login token for userid %s verified", auth_token.user_id)
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
|
||||
@ -105,7 +105,7 @@ def token_verified():
|
||||
# Token not found
|
||||
if auth_token is None:
|
||||
data['status'] = 'error'
|
||||
data['message'] = _(u"Token not found")
|
||||
data['message'] = _("Token not found")
|
||||
|
||||
# Token expired
|
||||
elif datetime.now() > auth_token.expiration:
|
||||
@ -113,7 +113,7 @@ def token_verified():
|
||||
ub.session_commit()
|
||||
|
||||
data['status'] = 'error'
|
||||
data['message'] = _(u"Token has expired")
|
||||
data['message'] = _("Token has expired")
|
||||
|
||||
elif not auth_token.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))
|
||||
|
||||
data['status'] = 'success'
|
||||
log.debug(u"Remote Login for userid %s succeeded", user.id)
|
||||
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success")
|
||||
log.debug("Remote Login for userid %s succeeded", user.id)
|
||||
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.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
|
@ -20,11 +20,13 @@ from flask import render_template, g, abort, request
|
||||
from flask_babel import gettext as _
|
||||
from werkzeug.local import LocalProxy
|
||||
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
|
||||
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_sidebar_config(kwargs=None):
|
||||
@ -45,12 +47,12 @@ def get_sidebar_config(kwargs=None):
|
||||
"show_text": _('Show Hot Books'), "config_show": True})
|
||||
if current_user.role_admin():
|
||||
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'),
|
||||
"config_show": content})
|
||||
else:
|
||||
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'),
|
||||
"config_show": content})
|
||||
sidebar.append(
|
||||
@ -58,47 +60,50 @@ def get_sidebar_config(kwargs=None):
|
||||
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
|
||||
"show_text": _('Show Top Rated Books'), "config_show": True})
|
||||
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),
|
||||
"page": "read", "show_text": _('Show read and unread'), "config_show": content})
|
||||
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous),
|
||||
"page": "read", "show_text": _('Show Read and Unread'), "config_show": content})
|
||||
sidebar.append(
|
||||
{"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})
|
||||
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
|
||||
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
||||
"show_text": _('Show Random Books'), "config_show": True})
|
||||
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
||||
"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",
|
||||
"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",
|
||||
"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(
|
||||
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "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",
|
||||
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'),
|
||||
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
|
||||
"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",
|
||||
"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",
|
||||
"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(
|
||||
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
||||
"show_text": _('Show archived books'), "config_show": content})
|
||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
|
||||
"show_text": _('Show Archived Books'), "config_show": content})
|
||||
if not simple:
|
||||
sidebar.append(
|
||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list",
|
||||
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list",
|
||||
"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
|
||||
|
||||
|
||||
|
@ -19,7 +19,7 @@
|
||||
import datetime
|
||||
|
||||
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.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||
from .services.worker import WorkerThread
|
||||
@ -27,13 +27,12 @@ from .tasks.metadata_backup import TaskBackupMetadata
|
||||
|
||||
def get_scheduled_tasks(reconnect=True):
|
||||
tasks = list()
|
||||
# config.schedule_reconnect or
|
||||
# Reconnect Calibre database (metadata.db)
|
||||
# Reconnect Calibre database (metadata.db) based on config.schedule_reconnect
|
||||
if reconnect:
|
||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||
|
||||
# ToDo make configurable. Generate metadata.opf file for each changed book
|
||||
if False:
|
||||
# Generate metadata.opf file for each changed book
|
||||
if config.schedule_metadata_backup:
|
||||
tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False])
|
||||
|
||||
# Generate all missing book cover thumbnails
|
||||
@ -66,10 +65,10 @@ def register_scheduled_tasks(reconnect=True):
|
||||
duration = config.schedule_duration
|
||||
|
||||
# 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)
|
||||
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', name="end scheduled task", hour=end_time.hour,
|
||||
minute=end_time.minute)
|
||||
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute),
|
||||
name="end scheduled task")
|
||||
|
||||
# Kick-off tasks, if they should currently be running
|
||||
if should_task_be_running(start, duration):
|
||||
|
@ -45,7 +45,7 @@ def simple_search():
|
||||
return render_title_template('search.html',
|
||||
searchterm="",
|
||||
result_count=0,
|
||||
title=_(u"Search"),
|
||||
title=_("Search"),
|
||||
page="search")
|
||||
|
||||
|
||||
@ -185,18 +185,18 @@ def extend_search_term(searchterm,
|
||||
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
||||
if pub_start:
|
||||
try:
|
||||
searchterm.extend([_(u"Published after ") +
|
||||
searchterm.extend([_("Published after ") +
|
||||
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
||||
format='medium')])
|
||||
except ValueError:
|
||||
pub_start = u""
|
||||
pub_start = ""
|
||||
if pub_end:
|
||||
try:
|
||||
searchterm.extend([_(u"Published before ") +
|
||||
searchterm.extend([_("Published before ") +
|
||||
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
||||
format='medium')])
|
||||
except ValueError:
|
||||
pub_end = u""
|
||||
pub_end = ""
|
||||
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
||||
for key, db_element in elements.items():
|
||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
||||
@ -214,11 +214,11 @@ def extend_search_term(searchterm,
|
||||
language_names = calibre_db.speaking_language(language_names)
|
||||
searchterm.extend(language.name for language in language_names)
|
||||
if rating_high:
|
||||
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)])
|
||||
searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
|
||||
if rating_low:
|
||||
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)])
|
||||
if read_status:
|
||||
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)])
|
||||
searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
|
||||
if read_status != "Any":
|
||||
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['exclude_extension'])
|
||||
# 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_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||
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='medium')
|
||||
)])
|
||||
cc_present = True
|
||||
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='medium')
|
||||
)])
|
||||
cc_present = True
|
||||
elif term.get('custom_column_' + str(c.id)):
|
||||
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
cc_present = True
|
||||
|
||||
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
||||
or rating_high or description or cc_present or read_status:
|
||||
or rating_high or description or cc_present or read_status != "Any":
|
||||
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||
author_name,
|
||||
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))
|
||||
if 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:
|
||||
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'])
|
||||
@ -339,7 +340,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
pagination=pagination,
|
||||
entries=entries,
|
||||
result_count=result_count,
|
||||
title=_(u"Advanced Search"), page="advsearch",
|
||||
title=_("Advanced Search"), page="advsearch",
|
||||
order=order[1])
|
||||
|
||||
|
||||
@ -366,22 +367,28 @@ def render_prepare_search_form(cc):
|
||||
.filter(calibre_db.common_filters()) \
|
||||
.group_by(db.Data.format)\
|
||||
.order_by(db.Data.format).all()
|
||||
if current_user.filter_language() == u"all":
|
||||
if current_user.filter_language() == "all":
|
||||
languages = calibre_db.speaking_language()
|
||||
else:
|
||||
languages = None
|
||||
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
||||
series=series,shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch")
|
||||
series=series,shelves=shelves, title=_("Advanced Search"), cc=cc, page="advsearch")
|
||||
|
||||
|
||||
def render_search_results(term, offset=None, order=None, limit=None):
|
||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||
config,
|
||||
offset,
|
||||
order,
|
||||
limit,
|
||||
*join)
|
||||
if term:
|
||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||
config,
|
||||
offset,
|
||||
order,
|
||||
limit,
|
||||
*join)
|
||||
else:
|
||||
entries = list()
|
||||
order = [None, None]
|
||||
pagination = result_count = None
|
||||
|
||||
return render_title_template('search.html',
|
||||
searchterm=term,
|
||||
pagination=pagination,
|
||||
@ -389,7 +396,7 @@ def render_search_results(term, offset=None, order=None, limit=None):
|
||||
adv_searchterm=term,
|
||||
entries=entries,
|
||||
result_count=result_count,
|
||||
title=_(u"Search"),
|
||||
title=_("Search"),
|
||||
page="search",
|
||||
order=order[1])
|
||||
|
||||
|
110
cps/server.py
110
cps/server.py
@ -21,12 +21,12 @@ import os
|
||||
import errno
|
||||
import signal
|
||||
import socket
|
||||
import subprocess # nosec
|
||||
|
||||
try:
|
||||
from gevent.pywsgi import WSGIServer
|
||||
from .gevent_wsgi import MyWSGIHandler
|
||||
from gevent.pool import Pool
|
||||
from gevent.socket import socket as GeventSocket
|
||||
from gevent import __version__ as _version
|
||||
from greenlet import GreenletExit
|
||||
import ssl
|
||||
@ -36,6 +36,7 @@ except ImportError:
|
||||
from .tornado_wsgi import MyWSGIContainer
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.netutil import bind_unix_socket
|
||||
from tornado import version as _version
|
||||
VERSION = 'Tornado ' + _version
|
||||
_GEVENT = False
|
||||
@ -95,7 +96,12 @@ class WebServer(object):
|
||||
log.warning('Cert path: %s', certfile_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()
|
||||
if os.path.exists(socket_file):
|
||||
# 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)
|
||||
os.remove(socket_file)
|
||||
|
||||
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
|
||||
self.unix_socket_file = socket_file
|
||||
|
||||
# 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(socket_file, 0o660)
|
||||
|
||||
return unix_sock
|
||||
|
||||
def _make_gevent_socket(self):
|
||||
def _make_gevent_listener(self):
|
||||
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")
|
||||
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:
|
||||
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':
|
||||
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:
|
||||
address = ('::', self.listen_port)
|
||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
||||
except socket.error as 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)
|
||||
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
|
||||
# not exist if a setuptools script is installed as an egg. It may be
|
||||
# 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"
|
||||
and __main__.__package__ == ""
|
||||
and not os.path.exists(py_script)
|
||||
@ -193,15 +205,15 @@ class WebServer(object):
|
||||
rv.extend(("-m", py_module.lstrip(".")))
|
||||
|
||||
rv.extend(args)
|
||||
if os.name == 'nt':
|
||||
rv = ['"{}"'.format(a) for a in rv]
|
||||
return rv
|
||||
|
||||
def _start_gevent(self):
|
||||
ssl_args = self.ssl_args or {}
|
||||
|
||||
try:
|
||||
sock, output = self._make_gevent_socket()
|
||||
if output is None:
|
||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||
sock, output = self._make_gevent_listener()
|
||||
log.info('Starting Gevent server on %s', output)
|
||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||
error_log=log,
|
||||
@ -226,17 +238,42 @@ class WebServer(object):
|
||||
if os.name == 'nt' and sys.version_info > (3, 7):
|
||||
import asyncio
|
||||
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
|
||||
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||
max_buffer_size=209700000,
|
||||
ssl_options=self.ssl_args)
|
||||
http_server.listen(self.listen_port, self.listen_address)
|
||||
self.wsgiserver = IOLoop.current()
|
||||
self.wsgiserver.start()
|
||||
# wait for stop signal
|
||||
self.wsgiserver.close(True)
|
||||
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
||||
if os.environ.get("LISTEN_FDS") and os.name != 'nt':
|
||||
SD_LISTEN_FDS_START = 3
|
||||
sock = socket.socket(fileno=SD_LISTEN_FDS_START)
|
||||
http_server.add_socket(sock)
|
||||
sock.setblocking(0)
|
||||
socket_name =sock.getsockname()
|
||||
output = "systemd-socket:" + _readable_listen_address(socket_name[0], socket_name[1])
|
||||
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):
|
||||
try:
|
||||
@ -262,9 +299,16 @@ class WebServer(object):
|
||||
|
||||
log.info("Performing restart of Calibre-Web")
|
||||
args = self._get_args_for_reloading()
|
||||
subprocess.call(args, close_fds=True) # nosec
|
||||
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def shutdown_scheduler():
|
||||
from .services.background_scheduler import BackgroundScheduler
|
||||
scheduler = BackgroundScheduler()
|
||||
if scheduler:
|
||||
scheduler.scheduler.shutdown()
|
||||
|
||||
def _killServer(self, __, ___):
|
||||
self.stop()
|
||||
|
||||
@ -273,9 +317,13 @@ class WebServer(object):
|
||||
updater_thread.stop()
|
||||
|
||||
log.info("webserver stop (restart=%s)", restart)
|
||||
self.shutdown_scheduler()
|
||||
self.restart = restart
|
||||
if self.wsgiserver:
|
||||
if _GEVENT:
|
||||
self.wsgiserver.close()
|
||||
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)
|
||||
|
@ -19,11 +19,9 @@
|
||||
|
||||
import sys
|
||||
from base64 import b64decode, b64encode
|
||||
from jsonschema import validate, exceptions, __version__
|
||||
from jsonschema import validate, exceptions
|
||||
from datetime import datetime
|
||||
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import json
|
||||
from .. import logger
|
||||
|
||||
|
@ -23,6 +23,8 @@ from .worker import WorkerThread
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
use_APScheduler = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_APScheduler = False
|
||||
@ -43,35 +45,33 @@ class BackgroundScheduler:
|
||||
cls.scheduler = BScheduler()
|
||||
cls.scheduler.start()
|
||||
|
||||
atexit.register(lambda: cls.scheduler.shutdown())
|
||||
|
||||
return cls._instance
|
||||
|
||||
def schedule(self, func, trigger, name=None, **trigger_args):
|
||||
def schedule(self, func, trigger, name=None):
|
||||
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
|
||||
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:
|
||||
def scheduled_task():
|
||||
worker_task = task()
|
||||
worker_task.scheduled = True
|
||||
WorkerThread.add(user, worker_task, hidden=hidden)
|
||||
return self.schedule(func=scheduled_task, trigger=trigger, name=name, **trigger_args)
|
||||
return self.schedule(func=scheduled_task, trigger=trigger, name=name)
|
||||
|
||||
# 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:
|
||||
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
|
||||
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
||||
if use_APScheduler:
|
||||
def immediate_task():
|
||||
WorkerThread.add(user, task(), hidden)
|
||||
return self.schedule(func=immediate_task, trigger='date', name=name)
|
||||
return self.schedule(func=immediate_task, trigger=DateTrigger(), name=name)
|
||||
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks_immediately(self, tasks, user=None):
|
||||
|
@ -20,6 +20,7 @@ import base64
|
||||
|
||||
from flask_simpleldap import LDAP, LDAPException
|
||||
from flask_simpleldap import ldap as pyLDAP
|
||||
from flask import current_app
|
||||
from .. import constants, logger
|
||||
|
||||
try:
|
||||
@ -28,8 +29,47 @@ except ImportError:
|
||||
pass
|
||||
|
||||
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):
|
||||
if config.config_login_type != constants.LOGIN_LDAP:
|
||||
@ -44,15 +84,15 @@ def init_app(app, config):
|
||||
app.config['LDAP_SCHEMA'] = 'ldap'
|
||||
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
|
||||
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
|
||||
if config.config_ldap_serv_password is None:
|
||||
config.config_ldap_serv_password = ''
|
||||
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password)
|
||||
if config.config_ldap_serv_password_e is None:
|
||||
config.config_ldap_serv_password_e = ''
|
||||
app.config['LDAP_PASSWORD'] = config.config_ldap_serv_password_e
|
||||
else:
|
||||
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
||||
app.config['LDAP_PASSWORD'] = ""
|
||||
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
|
||||
else:
|
||||
app.config['LDAP_USERNAME'] = ""
|
||||
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
||||
app.config['LDAP_PASSWORD'] = ""
|
||||
if bool(config.config_ldap_cert_path):
|
||||
app.config['LDAP_CUSTOM_OPTIONS'].update({
|
||||
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_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
|
||||
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
|
||||
|
||||
app.config['LDAP_LOGLEVEL'] = config.config_log_level
|
||||
try:
|
||||
_ldap.init_app(app)
|
||||
except ValueError:
|
||||
|
75
cps/shelf.py
75
cps/shelf.py
@ -46,13 +46,13 @@ def add_to_shelf(shelf_id, book_id):
|
||||
if shelf is None:
|
||||
log.error("Invalid shelf specified: %s", shelf_id)
|
||||
if not xhr:
|
||||
flash(_(u"Invalid shelf specified"), category="error")
|
||||
flash(_("Invalid shelf specified"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "Invalid shelf specified", 400
|
||||
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
if not xhr:
|
||||
flash(_(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 "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:
|
||||
log.error("Book %s is already part of %s", book_id, shelf)
|
||||
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 "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:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
if not xhr:
|
||||
log.debug("Book has been added to shelf: {}".format(shelf.name))
|
||||
flash(_(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:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
@ -100,12 +100,12 @@ def search_to_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||
flash(_(u"Invalid shelf specified"), category="error")
|
||||
flash(_("Invalid shelf specified"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
|
||||
flash(_(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'))
|
||||
|
||||
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:
|
||||
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'))
|
||||
|
||||
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:
|
||||
ub.session.merge(shelf)
|
||||
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:
|
||||
ub.session.rollback()
|
||||
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:
|
||||
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'))
|
||||
|
||||
|
||||
@ -182,13 +182,13 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
if not xhr:
|
||||
flash(_(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:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
@ -197,7 +197,7 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
else:
|
||||
if not xhr:
|
||||
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")
|
||||
return redirect(url_for('web.index'))
|
||||
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
|
||||
def create_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"])
|
||||
@ -215,9 +215,9 @@ def create_shelf():
|
||||
def edit_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
flash(_(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 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"])
|
||||
@ -232,7 +232,7 @@ def delete_shelf(shelf_id):
|
||||
except InvalidRequestError as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
|
||||
@ -269,7 +269,7 @@ def order_shelf(shelf_id):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
|
||||
result = list()
|
||||
if shelf:
|
||||
@ -278,7 +278,7 @@ def order_shelf(shelf_id):
|
||||
.add_columns(calibre_db.common_filters().label("visible")) \
|
||||
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
||||
return render_title_template('shelf_order.html', entries=result,
|
||||
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||
title=_("Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf, page="shelforder")
|
||||
else:
|
||||
abort(404)
|
||||
@ -295,11 +295,14 @@ def check_shelf_edit_permissions(cur_shelf):
|
||||
|
||||
|
||||
def check_shelf_view_permissions(cur_shelf):
|
||||
if cur_shelf.is_public:
|
||||
return True
|
||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
||||
return False
|
||||
try:
|
||||
if cur_shelf.is_public:
|
||||
return True
|
||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
return True
|
||||
|
||||
|
||||
@ -310,7 +313,7 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
if request.method == "POST":
|
||||
to_save = request.form.to_dict()
|
||||
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||
flash(_(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'))
|
||||
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||
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)
|
||||
ub.session.add(shelf)
|
||||
shelf_action = "created"
|
||||
flash_text = _(u"Shelf %(title)s created", title=shelf_title)
|
||||
flash_text = _("Shelf %(title)s created", title=shelf_title)
|
||||
else:
|
||||
shelf_action = "changed"
|
||||
flash_text = _(u"Shelf %(title)s changed", title=shelf_title)
|
||||
flash_text = _("Shelf %(title)s changed", title=shelf_title)
|
||||
try:
|
||||
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")
|
||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||
except (OperationalError, InvalidRequestError) as ex:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception(ex)
|
||||
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||
flash(_(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:
|
||||
ub.session.rollback()
|
||||
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',
|
||||
shelf=shelf,
|
||||
title=page_title,
|
||||
@ -366,7 +369,7 @@ def check_shelf_is_unique(title, is_public, shelf_id=False):
|
||||
|
||||
if not is_shelf_name_unique:
|
||||
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")
|
||||
else:
|
||||
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:
|
||||
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")
|
||||
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:
|
||||
ub.session.rollback()
|
||||
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,
|
||||
entries=result,
|
||||
pagination=pagination,
|
||||
title=_(u"Shelf: '%(name)s'", name=shelf.name),
|
||||
title=_("Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf,
|
||||
page="shelf")
|
||||
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"))
|
||||
|
@ -3290,10 +3290,13 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
|
||||
-ms-transform-origin: center top;
|
||||
transform-origin: center top;
|
||||
border: 0;
|
||||
left: 0 !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dropdown-menu:not(.datepicker-dropdown):not(.profileDropli) {
|
||||
left: 0 !important;
|
||||
}
|
||||
#add-to-shelves {
|
||||
min-height: 48px;
|
||||
max-height: calc(100% - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@ -4423,38 +4426,6 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
|
||||
left: 49px;
|
||||
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 {
|
||||
@ -4842,8 +4813,14 @@ body.advsearch:not(.blur) > div.container-fluid > div.row-fluid > div.col-sm-10
|
||||
z-index: 999999999999999999999999999999999999
|
||||
}
|
||||
|
||||
.search #shelf-actions, body.login .home-btn {
|
||||
display: none
|
||||
body.search #shelf-actions button#add-to-shelf {
|
||||
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] {
|
||||
@ -5164,7 +5141,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||
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
|
||||
}
|
||||
|
||||
@ -5181,7 +5158,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||
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
|
||||
}
|
||||
|
||||
@ -7309,6 +7286,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
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 {
|
||||
display: list-item !important;
|
||||
width: 225px
|
||||
|
@ -22,3 +22,7 @@ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
|
||||
padding: 0 0;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
input.datepicker {color: transparent}
|
||||
input.datepicker:focus {color: transparent}
|
||||
input.datepicker:focus + input {color: #555}
|
||||
|
@ -149,6 +149,20 @@ body {
|
||||
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 {
|
||||
min-height: 25px;
|
||||
height: auto;
|
||||
|
29
cps/static/css/reader.css
Normal file
29
cps/static/css/reader.css
Normal 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;
|
||||
}
|
@ -140,6 +140,7 @@ table .bg-dark-danger a { color: #fff; }
|
||||
|
||||
.container-fluid .book {
|
||||
margin-top: 20px;
|
||||
max-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -433,3 +434,7 @@ div.log {
|
||||
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
|
||||
#detailcover:-ms-fullscreen { cursor:zoom-out; border: 0; }
|
||||
#detailcover:fullscreen { cursor:zoom-out; border: 0; }
|
||||
|
||||
.error-list {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
@ -16,7 +16,6 @@
|
||||
*/
|
||||
// Move advanced search to side-menu
|
||||
$("a[href*='advanced']").parent().insertAfter("#nav_new");
|
||||
$("body").addClass("blur");
|
||||
$("body.stat").addClass("stats");
|
||||
$("body.config").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
|
||||
curHref = window.location.href.split("/");
|
||||
prevHref = document.referrer.split("/");
|
||||
$(".navbar-form.navbar-left")
|
||||
.before('<div class="plexBack"><a href="' + encodeURI(document.referrer) + '"></a></div>');
|
||||
$(".plexBack a").attr('href', encodeURI(document.referrer));
|
||||
|
||||
if (history.length === 1 ||
|
||||
curHref[0] +
|
||||
curHref[1] +
|
||||
@ -44,14 +43,9 @@ if (history.length === 1 ||
|
||||
|
||||
//Weird missing a after pressing back from edit.
|
||||
setTimeout(function () {
|
||||
if ($(".plexBack a").length < 1) {
|
||||
$(".plexBack").append('<a href="' + encodeURI(document.referrer) + '"></a>');
|
||||
}
|
||||
$(".plexBack a").attr('href', encodeURI(document.referrer));
|
||||
}, 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 //
|
||||
///////////////////////////////
|
||||
@ -320,19 +314,11 @@ $(document).mouseup(function (e) {
|
||||
});
|
||||
});
|
||||
|
||||
// Split path name to array and remove blanks
|
||||
url = window.location.pathname
|
||||
|
||||
// Move create shelf
|
||||
$("#nav_createshelf").prependTo(".your-shelves");
|
||||
|
||||
// Create drop-down for profile and move elements to it
|
||||
$("#main-nav")
|
||||
.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");
|
||||
// Move About link it the profile dropdown
|
||||
$(".profileDropli #top_user").parent().after($("#nav_about").addClass("dropdown"))
|
||||
|
||||
// Remove the modals except from some areas where they are needed
|
||||
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
|
||||
$("div.comments").readmore({
|
||||
collapsedHeight: 134,
|
||||
@ -408,6 +369,13 @@ $("div.comments").readmore({
|
||||
// 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
|
||||
if($("body.advsearch").length > 0) {
|
||||
$("#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.
|
||||
backurl = "../../book/" + url[2]
|
||||
$("body.epub #title-controls")
|
||||
@ -540,6 +510,7 @@ if ($("body.shelf").length > 0) {
|
||||
// Rest of Tooltips
|
||||
$(".home-btn > a").attr({
|
||||
"data-toggle": "tooltip",
|
||||
"href": $(".navbar-brand")[0].href,
|
||||
"title": $(document.body).attr("data-text"), // Home
|
||||
"data-placement": "bottom"
|
||||
})
|
||||
@ -666,7 +637,7 @@ $("#sendbtn").attr({
|
||||
|
||||
$("#sendbtn2").attr({
|
||||
"data-toggle-two": "tooltip",
|
||||
"title": $("#sendbtn2").text(), // "Send to E-Reader",
|
||||
"title": $("#sendbtn2").text(), // "Send to eReader",
|
||||
"data-placement": "bottom",
|
||||
"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
14
cps/static/js/compress/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
cps/static/js/compress/jszip_epub.min.js
vendored
Normal file
13
cps/static/js/compress/jszip_epub.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -125,7 +125,7 @@ function loadArchiveFormats(formats, cb) {
|
||||
_loaded_archive_formats.push(archive_format);
|
||||
break;
|
||||
case 'zip':
|
||||
loadScript(path + 'jszip.js', checkForLoadDone);
|
||||
loadScript(path + 'jszip.min.js', checkForLoadDone);
|
||||
_loaded_archive_formats.push(archive_format);
|
||||
break;
|
||||
case 'tar':
|
||||
|
@ -1,5 +1,5 @@
|
||||
/* 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
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@ -17,6 +17,36 @@
|
||||
|
||||
/* 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() {
|
||||
$("#have_read_form").ajaxForm();
|
||||
});
|
||||
|
@ -62,6 +62,7 @@ var currentImage = 0;
|
||||
var imageFiles = [];
|
||||
var imageFilenames = [];
|
||||
var totalImages = 0;
|
||||
var prevScrollPosition = 0;
|
||||
|
||||
var settings = {
|
||||
hflip: false,
|
||||
@ -70,8 +71,9 @@ var settings = {
|
||||
fitMode: kthoom.Key.B,
|
||||
theme: "light",
|
||||
direction: 0, // 0 = Left to Right, 1 = Right to Left
|
||||
nextPage: 0, // 0 = Reset to Top, 1 = Remember Position
|
||||
scrollbar: 1 // 0 = Hide Scrollbar, 1 = Show Scrollbar
|
||||
nextPage: 0, // 0 = Reset to Top, 1 = Remember Position
|
||||
scrollbar: 1, // 0 = Hide Scrollbar, 1 = Show Scrollbar
|
||||
pageDisplay: 0 // 0 = Single Page, 1 = Long Strip
|
||||
};
|
||||
|
||||
kthoom.saveSettings = function() {
|
||||
@ -130,8 +132,8 @@ var createURLFromArray = function(array, mimeType) {
|
||||
}
|
||||
|
||||
if ((typeof URL !== "function" && typeof URL !== "object") ||
|
||||
typeof URL.createObjectURL !== "function") {
|
||||
throw "Browser support for Object URLs is missing";
|
||||
typeof URL.createObjectURL !== "function") {
|
||||
throw "Browser support for Object URLs is missing";
|
||||
}
|
||||
|
||||
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() {
|
||||
$("#progress").click(function(e) {
|
||||
var offset = $(this).offset();
|
||||
var x = e.pageX - offset.left;
|
||||
var rate = settings.direction === 0 ? x / $(this).width() : 1 - x / $(this).width();
|
||||
currentImage = Math.max(1, Math.ceil(rate * totalImages)) - 1;
|
||||
updateDirectionButtons();
|
||||
setBookmark();
|
||||
updatePage();
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromArrayBuffer(ab) {
|
||||
var lastCompletion = 0;
|
||||
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
||||
loadArchiveFormats(['rar', 'zip', 'tar'], function() {
|
||||
// Open the file as an archive
|
||||
@ -216,9 +238,14 @@ function loadFromArrayBuffer(ab) {
|
||||
"</a>" +
|
||||
"</li>"
|
||||
);
|
||||
|
||||
drawCanvas();
|
||||
setImage(test.dataURI, null);
|
||||
|
||||
// display first page if we haven't yet
|
||||
if (imageFiles.length === currentImage + 1) {
|
||||
updatePage(lastCompletion);
|
||||
updateDirectionButtons();
|
||||
updatePage();
|
||||
}
|
||||
} else {
|
||||
totalImages--;
|
||||
@ -233,6 +260,17 @@ function loadFromArrayBuffer(ab) {
|
||||
}
|
||||
|
||||
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
|
||||
$("#tocView").stop().animate({
|
||||
scrollTop: $("#tocView a.active").position().top
|
||||
@ -240,33 +278,33 @@ function scrollTocToActive() {
|
||||
}
|
||||
|
||||
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();
|
||||
scrollCurrentImageIntoView();
|
||||
updateProgress();
|
||||
|
||||
if (imageFiles[currentImage]) {
|
||||
setImage(imageFiles[currentImage].dataURI);
|
||||
} else {
|
||||
setImage("loading");
|
||||
}
|
||||
|
||||
$("body").toggleClass("dark-theme", settings.theme === "dark");
|
||||
$("#mainContent").toggleClass("disabled-scrollbar", settings.scrollbar === 0);
|
||||
pageDisplayUpdate();
|
||||
setTheme();
|
||||
|
||||
kthoom.setSettings();
|
||||
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) {
|
||||
if (settings.direction === 0) {
|
||||
$("#progress .bar-read")
|
||||
@ -298,100 +336,93 @@ function updateProgress(loadPercentage) {
|
||||
$("#progress .bar-read").css({ width: totalImages === 0 ? 0 : Math.round((currentImage + 1) / totalImages * 100) + "%"});
|
||||
}
|
||||
|
||||
function setImage(url) {
|
||||
var canvas = $("#mainImage")[0];
|
||||
var x = $("#mainImage")[0].getContext("2d");
|
||||
function setImage(url, _canvas) {
|
||||
var canvas = _canvas || $(".mainImage").slice(-1)[0]; // Select the last item on the array if _canvas is null
|
||||
var x = canvas.getContext("2d");
|
||||
|
||||
$("#mainText").hide();
|
||||
if (url === "loading") {
|
||||
updateScale(true);
|
||||
canvas.width = innerWidth - 100;
|
||||
canvas.height = 200;
|
||||
if (url === "error") {
|
||||
x.fillStyle = "black";
|
||||
x.textAlign = "center";
|
||||
x.font = "24px sans-serif";
|
||||
x.strokeStyle = "black";
|
||||
x.fillText("Loading Page #" + (currentImage + 1), innerWidth / 2, 100);
|
||||
x.strokeStyle = (settings.theme === "dark") ? "white" : "black";
|
||||
x.fillText("Unable to decompress image #" + (currentImage + 1), innerWidth / 2, 100);
|
||||
|
||||
$(".mainImage").slice(-1).addClass("error");
|
||||
} else {
|
||||
if (url === "error") {
|
||||
updateScale(true);
|
||||
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;
|
||||
if ($("body").css("scrollHeight") / innerHeight > 1) {
|
||||
$("body").css("overflowY", "scroll");
|
||||
}
|
||||
|
||||
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 {
|
||||
showNextPage();
|
||||
}
|
||||
setBookmark();
|
||||
}
|
||||
|
||||
function showRightPage() {
|
||||
@ -409,6 +441,7 @@ function showRightPage() {
|
||||
} else {
|
||||
showPrevPage();
|
||||
}
|
||||
setBookmark();
|
||||
}
|
||||
|
||||
function showPrevPage() {
|
||||
@ -418,10 +451,8 @@ function showPrevPage() {
|
||||
currentImage++;
|
||||
} else {
|
||||
updatePage();
|
||||
if (settings.nextPage === 0) {
|
||||
$("#mainContent").scrollTop(0);
|
||||
}
|
||||
}
|
||||
updateDirectionButtons();
|
||||
}
|
||||
|
||||
function showNextPage() {
|
||||
@ -431,36 +462,54 @@ function showNextPage() {
|
||||
currentImage--;
|
||||
} else {
|
||||
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) {
|
||||
var mainImageStyle = getElem("mainImage").style;
|
||||
mainImageStyle.width = "";
|
||||
mainImageStyle.height = "";
|
||||
mainImageStyle.maxWidth = "";
|
||||
mainImageStyle.maxHeight = "";
|
||||
function updateScale() {
|
||||
var canvasArray = $("#mainContent > canvas");
|
||||
var maxheight = innerHeight - 50;
|
||||
|
||||
canvasArray.css("width", "");
|
||||
canvasArray.css("height", "");
|
||||
canvasArray.css("maxWidth", "");
|
||||
canvasArray.css("maxHeight", "");
|
||||
|
||||
if (!clear) {
|
||||
switch (settings.fitMode) {
|
||||
case kthoom.Key.B:
|
||||
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;
|
||||
}
|
||||
if(settings.pageDisplay === 0) {
|
||||
canvasArray.addClass("hide");
|
||||
pageDisplayUpdate();
|
||||
}
|
||||
|
||||
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});
|
||||
kthoom.setSettings();
|
||||
kthoom.saveSettings();
|
||||
@ -477,6 +526,20 @@ function keyHandler(evt) {
|
||||
if (hasModifier) break;
|
||||
showRightPage();
|
||||
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:
|
||||
if (hasModifier) break;
|
||||
settings.rotateTimes--;
|
||||
@ -484,6 +547,7 @@ function keyHandler(evt) {
|
||||
settings.rotateTimes = 3;
|
||||
}
|
||||
updatePage();
|
||||
reloadImages();
|
||||
break;
|
||||
case kthoom.Key.R:
|
||||
if (hasModifier) break;
|
||||
@ -492,6 +556,7 @@ function keyHandler(evt) {
|
||||
settings.rotateTimes = 0;
|
||||
}
|
||||
updatePage();
|
||||
reloadImages();
|
||||
break;
|
||||
case kthoom.Key.F:
|
||||
if (hasModifier) break;
|
||||
@ -507,26 +572,27 @@ function keyHandler(evt) {
|
||||
settings.hflip = true;
|
||||
}
|
||||
updatePage();
|
||||
reloadImages();
|
||||
break;
|
||||
case kthoom.Key.W:
|
||||
if (hasModifier) break;
|
||||
settings.fitMode = kthoom.Key.W;
|
||||
updateScale(false);
|
||||
updateScale();
|
||||
break;
|
||||
case kthoom.Key.H:
|
||||
if (hasModifier) break;
|
||||
settings.fitMode = kthoom.Key.H;
|
||||
updateScale(false);
|
||||
updateScale();
|
||||
break;
|
||||
case kthoom.Key.B:
|
||||
if (hasModifier) break;
|
||||
settings.fitMode = kthoom.Key.B;
|
||||
updateScale(false);
|
||||
updateScale();
|
||||
break;
|
||||
case kthoom.Key.N:
|
||||
if (hasModifier) break;
|
||||
settings.fitMode = kthoom.Key.N;
|
||||
updateScale(false);
|
||||
updateScale();
|
||||
break;
|
||||
case kthoom.Key.SPACE:
|
||||
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("←");
|
||||
$("#next_page_key").html("→");
|
||||
} else {
|
||||
$("#prev_page_key").html("→");
|
||||
$("#next_page_key").html("←");
|
||||
}
|
||||
};
|
||||
|
||||
function init(filename) {
|
||||
var request = new XMLHttpRequest();
|
||||
request.open("GET", filename);
|
||||
request.responseType = "arraybuffer";
|
||||
request.addEventListener("load", function() {
|
||||
request.addEventListener("load", function () {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
loadFromArrayBuffer(request.response);
|
||||
} else {
|
||||
console.warn(request.statusText, request.responseText);
|
||||
}
|
||||
});
|
||||
kthoom.loadSettings();
|
||||
setTheme();
|
||||
updateScale();
|
||||
request.send();
|
||||
initProgressClick();
|
||||
document.body.className += /AppleWebKit/.test(navigator.userAgent) ? " webkit" : "";
|
||||
kthoom.loadSettings();
|
||||
updateScale(true);
|
||||
|
||||
$(document).keydown(keyHandler);
|
||||
|
||||
$(window).resize(function() {
|
||||
updateScale(false);
|
||||
$(window).resize(function () {
|
||||
updateScale();
|
||||
});
|
||||
|
||||
// Open TOC menu
|
||||
$("#slider").click(function() {
|
||||
$("#slider").click(function () {
|
||||
$("#sidebar").toggleClass("open");
|
||||
$("#main").toggleClass("closed");
|
||||
$(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 ¯\_(ツ)_/¯
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
// Focus on the TOC or the main content area, depending on which is open
|
||||
$("#main:not(.closed) #mainContent, #sidebar.open #tocView").focus();
|
||||
scrollTocToActive();
|
||||
@ -583,12 +697,12 @@ function init(filename) {
|
||||
});
|
||||
|
||||
// Open Settings modal
|
||||
$("#setting").click(function() {
|
||||
$("#setting").click(function () {
|
||||
$("#settings-modal").toggleClass("md-show");
|
||||
});
|
||||
|
||||
// On Settings input change
|
||||
$("#settings input").on("change", function() {
|
||||
$("#settings input").on("change", function () {
|
||||
// Get either the checked boolean or the assigned value
|
||||
var value = this.type === "checkbox" ? this.checked : this.value;
|
||||
|
||||
@ -596,33 +710,41 @@ function init(filename) {
|
||||
value = /^\d+$/.test(value) ? parseInt(value) : value;
|
||||
|
||||
settings[this.name] = value;
|
||||
|
||||
if (["hflip", "vflip", "rotateTimes"].includes(this.name)) {
|
||||
reloadImages();
|
||||
} else if (this.name === "direction") {
|
||||
updateDirectionButtons();
|
||||
return updateProgress();
|
||||
}
|
||||
|
||||
updatePage();
|
||||
updateScale(false);
|
||||
updateScale();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
$(".closer, .overlay").click(function() {
|
||||
$(".closer, .overlay").click(function () {
|
||||
$(".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
|
||||
$("#thumbnails").on("click", "a", function() {
|
||||
$("#thumbnails").on("click", "a", function () {
|
||||
currentImage = $(this).data("page") - 1;
|
||||
updatePage();
|
||||
if (settings.nextPage === 0) {
|
||||
$("#mainContent").scrollTop(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen mode
|
||||
if (typeof screenfull !== "undefined") {
|
||||
$("#fullscreen").click(function() {
|
||||
$("#fullscreen").click(function () {
|
||||
screenfull.toggle($("#container")[0]);
|
||||
// Focus on main container so you can use up/down keys immediately after fullscreen
|
||||
$("#mainContent").focus();
|
||||
});
|
||||
|
||||
if (screenfull.raw) {
|
||||
var $button = $("#fullscreen");
|
||||
document.addEventListener(screenfull.raw.fullscreenchange, function() {
|
||||
document.addEventListener(screenfull.raw.fullscreenchange, function () {
|
||||
screenfull.isFullscreen
|
||||
? $button.addClass("icon-resize-small").removeClass("icon-resize-full")
|
||||
: $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
|
||||
$("#mainContent").focus();
|
||||
|
||||
$("#mainContent").swipe( {
|
||||
swipeRight:function() {
|
||||
$("#mainContent").swipe({
|
||||
swipeRight: function () {
|
||||
showLeftPage();
|
||||
},
|
||||
swipeLeft:function() {
|
||||
swipeLeft: function () {
|
||||
showRightPage();
|
||||
},
|
||||
});
|
||||
$("#mainImage").click(function(evt) {
|
||||
// Firefox does not support offsetX/Y so we have to manually calculate
|
||||
$(".mainImage").click(function (evt) {
|
||||
// Firefox does not support offsetX/Y, so we have to manually calculate
|
||||
// where the user clicked in the image.
|
||||
var mainContentWidth = $("#mainContent").width();
|
||||
var mainContentHeight = $("#mainContent").height();
|
||||
@ -676,5 +798,73 @@ function init(filename) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
4
cps/static/js/libs/Sortable.min.js
vendored
4
cps/static/js/libs/Sortable.min.js
vendored
File diff suppressed because one or more lines are too long
12
cps/static/js/libs/bar-ui.js
vendored
12
cps/static/js/libs/bar-ui.js
vendored
@ -177,6 +177,9 @@
|
||||
|
||||
whileplaying: function () {
|
||||
|
||||
// get csrf_token
|
||||
let csrf_token = $("input[name='csrf_token']").val();
|
||||
|
||||
|
||||
//This sends a bookmark update to calibreweb every 30 seconds.
|
||||
if (this.progressBuffer == undefined) {
|
||||
@ -187,7 +190,10 @@
|
||||
|
||||
$.ajax(calibre.bookmarkUrl, {
|
||||
method: "post",
|
||||
data: { bookmark: this.position }
|
||||
data: {
|
||||
csrf_token: csrf_token,
|
||||
bookmark: this.position
|
||||
}
|
||||
}).fail(function (xhr, status, error) {
|
||||
console.error(error);
|
||||
});
|
||||
@ -313,14 +319,14 @@
|
||||
},
|
||||
|
||||
onstop: function () {
|
||||
|
||||
|
||||
$.ajax(calibre.bookmarkUrl, {
|
||||
method: "post",
|
||||
data: { bookmark: this.position }
|
||||
}).fail(function (xhr, status, error) {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
|
||||
utils.css.remove(dom.o, 'playing');
|
||||
|
||||
},
|
||||
|
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.id.min.js
vendored
Normal file
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.id.min.js
vendored
Normal 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);
|
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.no.min.js
vendored
Normal file
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.no.min.js
vendored
Normal 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);
|
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.pt.min.js
vendored
Normal file
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.pt.min.js
vendored
Normal 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);
|
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.sk.min.js
vendored
Normal file
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.sk.min.js
vendored
Normal 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);
|
2
cps/static/js/libs/epub.min.js
vendored
2
cps/static/js/libs/epub.min.js
vendored
File diff suppressed because one or more lines are too long
4
cps/static/js/libs/jquery.min.js
vendored
4
cps/static/js/libs/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
2
cps/static/js/libs/jquery.min.map
vendored
2
cps/static/js/libs/jquery.min.map
vendored
File diff suppressed because one or more lines are too long
13
cps/static/js/libs/jszip.min.js
vendored
13
cps/static/js/libs/jszip.min.js
vendored
File diff suppressed because one or more lines are too long
1
cps/static/js/libs/pwstrength/i18next.min.js
vendored
Normal file
1
cps/static/js/libs/pwstrength/i18next.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cps/static/js/libs/pwstrength/i18nextHttpBackend.min.js
vendored
Normal file
1
cps/static/js/libs/pwstrength/i18nextHttpBackend.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
17
cps/static/js/libs/pwstrength/locales/ar.json
Normal file
17
cps/static/js/libs/pwstrength/locales/ar.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"wordMinLength": "كلمة المرور قصيرة جداً",
|
||||
"wordMaxLength": "كلمة المرور طويلة جدا",
|
||||
"wordInvalidChar": "تحتوي كلمة المرور على رموز غير صالحة",
|
||||
"wordNotEmail": "لا تستخدم بريدك الإلكتروني ككلمة مرور",
|
||||
"wordSimilarToUsername": "لا يمكن ان تحتوي كلمة المرور على إسم المستخدم",
|
||||
"wordTwoCharacterClasses": "إستخدم فئات أحرف مختلفة",
|
||||
"wordRepetitions": "تكرارات كثيرة",
|
||||
"wordSequences": "تحتوي كلمة المرور على أنماط متتابعة",
|
||||
"errorList": "الأخطاء:",
|
||||
"veryWeak": "ضعيفة جداً",
|
||||
"weak": "ضعيفة",
|
||||
"normal": "عادية",
|
||||
"medium": "متوسطة",
|
||||
"strong": "قوية",
|
||||
"veryStrong": "قوية جداً"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/cs.json
Normal file
17
cps/static/js/libs/pwstrength/locales/cs.json
Normal 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é"
|
||||
}
|
21
cps/static/js/libs/pwstrength/locales/de.json
Normal file
21
cps/static/js/libs/pwstrength/locales/de.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/el.json
Normal file
17
cps/static/js/libs/pwstrength/locales/el.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"wordMinLength": "Ο κωδικός πρόσβασης δεν έχει τον ελάχιστο αριθμό χαρακτήρων",
|
||||
"wordMaxLength": "Ο κωδικός πρόσβασής σας είναι πολύ μεγάλος",
|
||||
"wordInvalidChar": "Ο κωδικός πρόσβασής σας περιέχει έναν μη έγκυρο χαρακτήρα",
|
||||
"wordNotEmail": "Μη χρησιμοποιείτε το email ως κωδικό",
|
||||
"wordSimilarToUsername": "Ο κωδικός πρόσβασης δεν πρέπει να περιέχει το username",
|
||||
"wordTwoCharacterClasses": "Χρησιμοποιήστε διαφορετικές κλάσεις χαρακτήρων",
|
||||
"wordRepetitions": "Πολλές επαναλήψεις",
|
||||
"wordSequences": "Ο κωδικός πρόσβασης περιέχει επαναλήψεις",
|
||||
"errorList": "Σφάλματα:",
|
||||
"veryWeak": "Πολύ Αδύνατος",
|
||||
"weak": "Αδύνατος",
|
||||
"normal": "Κανονικός",
|
||||
"medium": "Μέτριος",
|
||||
"strong": "Δυνατός",
|
||||
"veryStrong": "Πολύ Δυνατός"
|
||||
}
|
21
cps/static/js/libs/pwstrength/locales/en.json
Normal file
21
cps/static/js/libs/pwstrength/locales/en.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/eo.json
Normal file
17
cps/static/js/libs/pwstrength/locales/eo.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/es.json
Normal file
17
cps/static/js/libs/pwstrength/locales/es.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/fr.json
Normal file
17
cps/static/js/libs/pwstrength/locales/fr.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/it.json
Normal file
17
cps/static/js/libs/pwstrength/locales/it.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/no.json
Normal file
17
cps/static/js/libs/pwstrength/locales/no.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/pl.json
Normal file
17
cps/static/js/libs/pwstrength/locales/pl.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/pt.json
Normal file
17
cps/static/js/libs/pwstrength/locales/pt.json
Normal 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"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/ru.json
Normal file
17
cps/static/js/libs/pwstrength/locales/ru.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"wordMinLength": "Слишком короткий пароль",
|
||||
"wordMaxLength": "Ваш пароль слишком длинный",
|
||||
"wordInvalidChar": "Ваш пароль содержит недопустимый символ",
|
||||
"wordNotEmail": "Не используйте e-mail в качестве пароля",
|
||||
"wordSimilarToUsername": "Пароль не должен содержать логин",
|
||||
"wordTwoCharacterClasses": "Используйте разные классы символов",
|
||||
"wordRepetitions": "Слишком много повторений",
|
||||
"wordSequences": "Пароль содержит последовательности",
|
||||
"errorList": "Ошибки:",
|
||||
"veryWeak": "Очень слабый",
|
||||
"weak": "Слабый",
|
||||
"normal": "Нормальный",
|
||||
"medium": "Средний",
|
||||
"strong": "Сильный",
|
||||
"veryStrong": "Очень сильный"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/sk.json
Normal file
17
cps/static/js/libs/pwstrength/locales/sk.json
Normal 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é"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/th.json
Normal file
17
cps/static/js/libs/pwstrength/locales/th.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"wordMinLength": "รหัสผ่านของคุณสั้นเกินไป",
|
||||
"wordMaxLength": "รหัสผ่านของคุณยาวเกินไป",
|
||||
"wordInvalidChar": "รหัสผ่านของคุณมีอักษรที่ไม่ถูกต้อง",
|
||||
"wordNotEmail": "คุณไม่สามารถใช้รหัสผ่านเหมือนกับอีเมล์ของคุณได้",
|
||||
"wordSimilarToUsername": "รหัสผ่านไม่ควรประกอบด้วยคำที่เป็น username",
|
||||
"wordTwoCharacterClasses": "ลองเป็นกลุ่มคำใหม่",
|
||||
"wordRepetitions": "มีอักษรซ้ำเยอะเกินไป",
|
||||
"wordSequences": "รหัสผ่านของคุณเดาง่ายเกินไป",
|
||||
"errorList": "Errors:",
|
||||
"veryWeak": "เดาง่ายมาก",
|
||||
"weak": "เดาง่าย",
|
||||
"normal": "พอใช้",
|
||||
"medium": "กำลังดี",
|
||||
"strong": "ค่อนข้างดี",
|
||||
"veryStrong": "ดีมาก"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/tr.json
Normal file
17
cps/static/js/libs/pwstrength/locales/tr.json
Normal 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ü"
|
||||
}
|
17
cps/static/js/libs/pwstrength/locales/zh-TW.json
Normal file
17
cps/static/js/libs/pwstrength/locales/zh-TW.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"wordMinLength": "您的密碼太短",
|
||||
"wordMaxLength": "您的密碼太長",
|
||||
"wordInvalidChar": "您的密碼包含無效字符",
|
||||
"wordNotEmail": "不要使用電子郵件作為密碼",
|
||||
"wordSimilarToUsername": "您的密碼不能包含您的用戶名",
|
||||
"wordTwoCharacterClasses": "使用不同的字元類型 例如: 大小寫混合",
|
||||
"wordRepetitions": "太多的重複。例如:1111",
|
||||
"wordSequences": "你的密碼包含連續英/數字 例如:123 or abc",
|
||||
"errorList": "錯誤:",
|
||||
"veryWeak": "非常弱",
|
||||
"weak": "弱",
|
||||
"normal": "普通",
|
||||
"medium": "中等",
|
||||
"strong": "強",
|
||||
"veryStrong": "非常強"
|
||||
}
|
1223
cps/static/js/libs/pwstrength/pwstrength-bootstrap.js
Normal file
1223
cps/static/js/libs/pwstrength/pwstrength-bootstrap.js
Normal file
File diff suppressed because it is too large
Load Diff
4
cps/static/js/libs/pwstrength/pwstrength-bootstrap.min.js
vendored
Normal file
4
cps/static/js/libs/pwstrength/pwstrength-bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
412
cps/static/js/libs/tinymce/langs/no.js
Normal file
412
cps/static/js/libs/tinymce/langs/no.js
Normal 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": ""
|
||||
});
|
462
cps/static/js/libs/tinymce/langs/pt.js
Normal file
462
cps/static/js/libs/tinymce/langs/pt.js
Normal 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"
|
||||
});
|
@ -36,7 +36,7 @@ function init(logType) {
|
||||
d.innerHTML = "loading ...";
|
||||
|
||||
$.ajax({
|
||||
url: window.location.pathname + "/../../ajax/log/" + logType,
|
||||
url: getPath() + "/ajax/log/" + logType,
|
||||
datatype: "text",
|
||||
cache: false
|
||||
})
|
||||
|
@ -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) {
|
||||
// $(".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
|
||||
$(".container-fluid").bind("dragenter dragover", function () {
|
||||
if($("#btn-upload").length && !$('body').hasClass('shelforder')) {
|
||||
@ -151,13 +142,13 @@ $("#form-upload").uploadprogress({
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
var inp = $('#query').first()
|
||||
if (inp.length) {
|
||||
var val = inp.val()
|
||||
if (val.length) {
|
||||
inp.val('').blur().focus().val(val)
|
||||
var inp = $('#query').first()
|
||||
if (inp.length) {
|
||||
var val = inp.val()
|
||||
if (val.length) {
|
||||
inp.val('').blur().focus().val(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(".session").click(function() {
|
||||
@ -313,7 +304,7 @@ $(function() {
|
||||
}
|
||||
|
||||
function fillFileTable(path, type, folder, filt) {
|
||||
var request_path = "/../../ajax/pathchooser/";
|
||||
var request_path = "/ajax/pathchooser/";
|
||||
$.ajax({
|
||||
dataType: "json",
|
||||
data: {
|
||||
@ -321,7 +312,7 @@ $(function() {
|
||||
folder: folder,
|
||||
filter: filt
|
||||
},
|
||||
url: window.location.pathname + request_path,
|
||||
url: getPath() + request_path,
|
||||
success: function success(data) {
|
||||
if ($("#element_selected").text() ==="") {
|
||||
$("#element_selected").text(data.cwd);
|
||||
@ -342,7 +333,6 @@ $(function() {
|
||||
} else {
|
||||
$("#parent").addClass('hidden')
|
||||
}
|
||||
// console.log(data);
|
||||
data.files.forEach(function(entry) {
|
||||
if(entry.type === "dir") {
|
||||
var type = "<span class=\"glyphicon glyphicon-folder-close\"></span>";
|
||||
@ -364,12 +354,6 @@ $(function() {
|
||||
layoutMode : "fitRows"
|
||||
});
|
||||
|
||||
$(".grid").isotope({
|
||||
// options
|
||||
itemSelector : ".grid-item",
|
||||
layoutMode : "fitColumns"
|
||||
});
|
||||
|
||||
if ($(".load-more").length && $(".next").length) {
|
||||
var $loadMore = $(".load-more .row").infiniteScroll({
|
||||
debug: false,
|
||||
@ -440,7 +424,7 @@ $(function() {
|
||||
}
|
||||
$.ajax({
|
||||
dataType: "json",
|
||||
url: window.location.pathname + "/../../get_update_status",
|
||||
url: getPath() + "/get_update_status",
|
||||
success: function success(data) {
|
||||
$this.html(buttonText);
|
||||
|
||||
@ -544,6 +528,7 @@ $(function() {
|
||||
$("#bookDetailsModal")
|
||||
.on("show.bs.modal", function(e) {
|
||||
$("#flash_danger").remove();
|
||||
$("#flash_success").remove();
|
||||
var $modalBody = $(this).find(".modal-body");
|
||||
|
||||
// Prevent static assets from loading multiple times
|
||||
@ -656,7 +641,6 @@ $(function() {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
$("#user_submit").click(function() {
|
||||
this.closest("form").submit();
|
||||
});
|
||||
@ -688,7 +672,7 @@ $(function() {
|
||||
$.ajax({
|
||||
method:"post",
|
||||
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()},
|
||||
success: function success(data) {
|
||||
if ( data.change ) {
|
||||
@ -715,17 +699,16 @@ $(function() {
|
||||
e.stopPropagation();
|
||||
this.blur();
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
var request_path = "/../../admin/ajaxconfig";
|
||||
var loader = "/../..";
|
||||
var request_path = "/admin/ajaxconfig";
|
||||
$("#flash_success").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);
|
||||
if(data.reboot) {
|
||||
$("#spinning_success").show();
|
||||
var rebootInterval = setInterval(function(){
|
||||
$.get({
|
||||
url:window.location.pathname + "/../../admin/alive",
|
||||
url:getPath() + "/admin/alive",
|
||||
success: function (d, statusText, xhr) {
|
||||
if (xhr.status < 400) {
|
||||
$("#spinning_success").hide();
|
||||
@ -751,7 +734,6 @@ $(function() {
|
||||
$(this).data('value'),
|
||||
function(value){
|
||||
postButton(event, $("#delete_shelf").data("action"));
|
||||
// $("#delete_shelf").closest("form").submit()
|
||||
}
|
||||
);
|
||||
|
||||
|
65
cps/static/js/password.js
Normal file
65
cps/static/js/password.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
@ -49,7 +49,7 @@ $(function() {
|
||||
method: "post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: window.location.pathname + "/../ajax/canceltask",
|
||||
url: getPath() + "/ajax/canceltask",
|
||||
data: JSON.stringify({"task_id": taskId}),
|
||||
});
|
||||
});
|
||||
@ -634,7 +634,7 @@ function UserActions (value, row) {
|
||||
|
||||
/* Function for cancelling tasks */
|
||||
function TaskActions (value, row) {
|
||||
var cancellableStats = [0, 1, 2];
|
||||
var cancellableStats = [0, 2];
|
||||
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
|
||||
return [
|
||||
"<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">",
|
||||
|
@ -64,13 +64,13 @@ class TaskConvert(CalibreTask):
|
||||
if df:
|
||||
datafile = os.path.join(config.config_calibre_dir,
|
||||
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)):
|
||||
os.makedirs(os.path.join(config.config_calibre_dir, cur_book.path))
|
||||
df.GetContentFile(datafile)
|
||||
worker_db.session.close()
|
||||
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'],
|
||||
fn=data.name + "." + self.settings['old_book_format'].lower())
|
||||
worker_db.session.close()
|
||||
@ -78,7 +78,7 @@ class TaskConvert(CalibreTask):
|
||||
|
||||
filename = self._convert_ebook_format()
|
||||
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 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
|
||||
# todo: figure out how to incorporate this into the progress
|
||||
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'],
|
||||
self.results["path"],
|
||||
filename,
|
||||
@ -107,8 +107,8 @@ class TaskConvert(CalibreTask):
|
||||
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
file_path = self.file_path
|
||||
book_id = self.book_id
|
||||
format_old_ext = u'.' + self.settings['old_book_format'].lower()
|
||||
format_new_ext = u'.' + self.settings['new_book_format'].lower()
|
||||
format_old_ext = '.' + self.settings['old_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
|
||||
# if it does - mark the conversion task as complete and return a success
|
||||
@ -133,7 +133,7 @@ class TaskConvert(CalibreTask):
|
||||
local_db.session.rollback()
|
||||
log.error("Database error: %s", e)
|
||||
local_db.session.close()
|
||||
self._handleError(N_("Database error: %(error)s.", error=e))
|
||||
self._handleError(N_("Oops! Database Error: %(error)s.", error=e))
|
||||
return
|
||||
self._handleSuccess()
|
||||
local_db.session.close()
|
||||
@ -150,7 +150,7 @@ class TaskConvert(CalibreTask):
|
||||
else:
|
||||
# check if calibre converter-executable is existing
|
||||
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
|
||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
||||
|
||||
@ -199,7 +199,7 @@ class TaskConvert(CalibreTask):
|
||||
try:
|
||||
p = process_open(command, quotes)
|
||||
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
|
||||
while True:
|
||||
nextline = p.stdout.readlines()
|
||||
@ -220,7 +220,7 @@ class TaskConvert(CalibreTask):
|
||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||
os.unlink(converted_file[0])
|
||||
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))
|
||||
return check, None
|
||||
|
||||
@ -244,7 +244,7 @@ class TaskConvert(CalibreTask):
|
||||
|
||||
p = process_open(command, quotes, newlines=False)
|
||||
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:
|
||||
nextline = p.stdout.readline()
|
||||
|
@ -18,6 +18,7 @@
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import threading
|
||||
import socket
|
||||
import mimetypes
|
||||
@ -152,7 +153,7 @@ class TaskEmail(CalibreTask):
|
||||
main_type, sub_type = content_type.split('/', 1)
|
||||
message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=self.attachment)
|
||||
else:
|
||||
self._handleError(u"Attachment not found")
|
||||
self._handleError("Attachment not found")
|
||||
return
|
||||
return message
|
||||
|
||||
@ -166,7 +167,7 @@ class TaskEmail(CalibreTask):
|
||||
self.send_gmail_email(msg)
|
||||
except MemoryError as e:
|
||||
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:
|
||||
log.error_or_exception(e, stacklevel=3)
|
||||
if hasattr(e, "smtp_error"):
|
||||
@ -177,13 +178,13 @@ class TaskEmail(CalibreTask):
|
||||
text = '\n'.join(e.args)
|
||||
else:
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
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
|
||||
log.debug("Start sending e-mail")
|
||||
if use_ssl == 2:
|
||||
context = ssl.create_default_context()
|
||||
self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"],
|
||||
timeout=timeout)
|
||||
timeout=timeout, context=context)
|
||||
else:
|
||||
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():
|
||||
self.asyncSMTP.set_debuglevel(1)
|
||||
if use_ssl == 1:
|
||||
self.asyncSMTP.starttls()
|
||||
if self.settings["mail_password"]:
|
||||
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"]))
|
||||
context = ssl.create_default_context()
|
||||
self.asyncSMTP.starttls(context=context)
|
||||
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
|
||||
fp = StringIO()
|
||||
@ -257,7 +260,7 @@ class TaskEmail(CalibreTask):
|
||||
file_.close()
|
||||
except IOError as e:
|
||||
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 data
|
||||
|
||||
|
@ -17,13 +17,12 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
from lxml import objectify
|
||||
from urllib.request import urlopen
|
||||
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_
|
||||
|
||||
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
|
||||
@ -74,7 +73,10 @@ class TaskBackupMetadata(CalibreTask):
|
||||
def backup_metadata(self):
|
||||
try:
|
||||
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)
|
||||
i = 0
|
||||
for backup in metadata_backup:
|
||||
@ -86,7 +88,6 @@ class TaskBackupMetadata(CalibreTask):
|
||||
self.open_metadata(book, custom_columns)
|
||||
else:
|
||||
self.log.error("Book {} not found in database".format(backup.book))
|
||||
# self._handleError("Book {} not found in database".format(backup.book))
|
||||
i += 1
|
||||
self.progress = (1.0 / count) * i
|
||||
self._handleSuccess()
|
||||
@ -100,56 +101,35 @@ class TaskBackupMetadata(CalibreTask):
|
||||
self.calibre_db.session.close()
|
||||
|
||||
def open_metadata(self, book, custom_columns):
|
||||
package = self.create_new_metadata_backup(book, custom_columns)
|
||||
if config.config_use_google_drive:
|
||||
if not gdriveutils.is_gdrive_ready():
|
||||
raise Exception('Google Drive is configured but not ready')
|
||||
|
||||
web_content_link = gdriveutils.get_metadata_backup_via_gdrive(book.path)
|
||||
if not web_content_link:
|
||||
raise Exception('Google Drive cover url not found')
|
||||
|
||||
stream = None
|
||||
try:
|
||||
stream = urlopen(web_content_link)
|
||||
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()
|
||||
gdriveutils.uploadFileToEbooksFolder(os.path.join(book.path, 'metadata.opf').replace("\\", "/"),
|
||||
etree.tostring(package,
|
||||
xml_declaration=True,
|
||||
encoding='utf-8',
|
||||
pretty_print=True).decode('utf-8'),
|
||||
True)
|
||||
else:
|
||||
# ToDo: Handle book folder not found or not readable
|
||||
book_metadata_filepath = os.path.join(config.config_calibre_dir, book.path, 'metadata.opf')
|
||||
#if not os.path.isfile(book_metadata_filepath):
|
||||
self.create_new_metadata_backup(book, custom_columns, book_metadata_filepath)
|
||||
# else:
|
||||
'''namespaces = {'dc': PURL_NAMESPACE, 'opf': OPF_NAMESPACE}
|
||||
test = etree.parse(book_metadata_filepath)
|
||||
root = test.getroot()
|
||||
for i in root.iter():
|
||||
self.log.info(i)
|
||||
title = root.find("dc:metadata", namespaces)
|
||||
pass
|
||||
with open(book_metadata_filepath, "rb") as f:
|
||||
xml = f.read()
|
||||
# prepare finalize everything and output
|
||||
doc = etree.ElementTree(package)
|
||||
try:
|
||||
with open(book_metadata_filepath, 'wb') as f:
|
||||
doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True)
|
||||
except Exception as ex:
|
||||
raise Exception('Writing Metadata failed with error: {} '.format(ex))
|
||||
|
||||
root = objectify.fromstring(xml)
|
||||
# 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):
|
||||
def create_new_metadata_backup(self, book, custom_columns):
|
||||
# generate root package element
|
||||
package = etree.Element(OPF + "package", nsmap=OPF_NS)
|
||||
package.set("unique-identifier", "uuid_id")
|
||||
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)
|
||||
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
|
||||
identifier.set(OPF + "scheme", "calibre")
|
||||
@ -171,10 +151,13 @@ class TaskBackupMetadata(CalibreTask):
|
||||
|
||||
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)
|
||||
if book.comments:
|
||||
if book.comments and book.comments[0].text:
|
||||
for b in book.comments:
|
||||
description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP)
|
||||
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:
|
||||
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
|
||||
language.text = self.export_language
|
||||
@ -196,6 +179,10 @@ class TaskBackupMetadata(CalibreTask):
|
||||
etree.SubElement(metadata, "meta", name="calibre:series_index",
|
||||
content=str(book.series_index),
|
||||
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",
|
||||
content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(
|
||||
d=book.timestamp),
|
||||
@ -209,8 +196,8 @@ class TaskBackupMetadata(CalibreTask):
|
||||
extra = None
|
||||
cc_entry = getattr(book, "custom_column_" + str(cc.id))
|
||||
if cc_entry.__len__():
|
||||
value = cc_entry[0].get("value")
|
||||
extra = cc_entry[0].get("extra")
|
||||
value = [c.value for c in cc_entry] if cc.is_multiple else cc_entry[0].value
|
||||
extra = cc_entry[0].extra if hasattr(cc_entry[0], "extra") else None
|
||||
etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label),
|
||||
content=cc.to_json(value, extra, sequence),
|
||||
nsmap=NSMAP)
|
||||
@ -221,16 +208,8 @@ class TaskBackupMetadata(CalibreTask):
|
||||
guide = etree.SubElement(package, "guide")
|
||||
etree.SubElement(guide, "reference", type="cover", title=self.translated_title, href="cover.jpg")
|
||||
|
||||
# prepare finalize everything and output
|
||||
doc = etree.ElementTree(package)
|
||||
# doc = etree.tostring(package, xml_declaration=True, encoding='utf-8', pretty_print=True) # .replace(b"&quot;", b""")
|
||||
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
|
||||
return package
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Metadata backup"
|
||||
|
@ -17,6 +17,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
from shutil import copyfile, copyfileobj
|
||||
from urllib.request import urlopen
|
||||
|
||||
from .. import constants
|
||||
@ -92,7 +93,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
|
||||
if generated > 0:
|
||||
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
|
||||
if self.stat == STAT_CANCELLED:
|
||||
@ -137,7 +138,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
|
||||
# Replace outdated or missing 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
|
||||
self.update_book_cover_thumbnail(book, thumbnail)
|
||||
|
||||
@ -188,14 +189,18 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
try:
|
||||
stream = urlopen(web_content_link)
|
||||
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)
|
||||
if img.height > height:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
img.resize(width=width, height=height, filter='lanczos')
|
||||
img.format = thumbnail.format
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename,
|
||||
constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
else:
|
||||
with open(filename, 'rb') as fd:
|
||||
copyfileobj(stream, fd)
|
||||
|
||||
except Exception as ex:
|
||||
# Bubble exception to calling function
|
||||
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||
@ -210,12 +215,15 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
|
||||
with Image(filename=book_cover_filepath) as img:
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
if img.height > height:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
img.resize(width=width, height=height, filter='lanczos')
|
||||
img.format = thumbnail.format
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
else:
|
||||
# take cover as is
|
||||
copyfile(book_cover_filepath, filename)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -43,9 +43,7 @@ def get_email_status_json():
|
||||
@login_required
|
||||
def get_tasks_status():
|
||||
# if current user admin, show all email, otherwise only own emails
|
||||
tasks = WorkerThread.get_instance().tasks
|
||||
answer = render_task_status(tasks)
|
||||
return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
|
||||
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
|
||||
|
||||
|
||||
# helper function to apply localize status information in tasklist entries
|
||||
@ -61,19 +59,19 @@ def render_task_status(tasklist):
|
||||
# localize the task status
|
||||
if isinstance(task.stat, int):
|
||||
if task.stat == STAT_WAITING:
|
||||
ret['status'] = _(u'Waiting')
|
||||
ret['status'] = _('Waiting')
|
||||
elif task.stat == STAT_FAIL:
|
||||
ret['status'] = _(u'Failed')
|
||||
ret['status'] = _('Failed')
|
||||
elif task.stat == STAT_STARTED:
|
||||
ret['status'] = _(u'Started')
|
||||
ret['status'] = _('Started')
|
||||
elif task.stat == STAT_FINISH_SUCCESS:
|
||||
ret['status'] = _(u'Finished')
|
||||
ret['status'] = _('Finished')
|
||||
elif task.stat == STAT_ENDED:
|
||||
ret['status'] = _(u'Ended')
|
||||
ret['status'] = _('Ended')
|
||||
elif task.stat == STAT_CANCELLED:
|
||||
ret['status'] = _(u'Cancelled')
|
||||
ret['status'] = _('Cancelled')
|
||||
else:
|
||||
ret['status'] = _(u'Unknown Status')
|
||||
ret['status'] = _('Unknown Status')
|
||||
|
||||
ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name
|
||||
ret['progress'] = "{} %".format(int(task.progress * 100))
|
||||
|
47
cps/templates/admin.html
Executable file → Normal file
47
cps/templates/admin.html
Executable file → Normal file
@ -11,8 +11,8 @@
|
||||
<table class="table table-striped" id="table_user">
|
||||
<tr>
|
||||
<th>{{_('Username')}}</th>
|
||||
<th>{{_('E-mail Address')}}</th>
|
||||
<th>{{_('Send to E-Reader E-mail Address')}}</th>
|
||||
<th>{{_('Email')}}</th>
|
||||
<th>{{_('Send to eReader Email')}}</th>
|
||||
<th>{{_('Downloads')}}</th>
|
||||
<th class="hidden-xs ">{{_('Admin')}}</th>
|
||||
<th class="hidden-xs hidden-sm">{{_('Password')}}</th>
|
||||
@ -59,45 +59,45 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>{{_('E-mail Server Settings')}}</h2>
|
||||
<h2>{{_('Email Server Settings')}}</h2>
|
||||
{% 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="row">
|
||||
<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 class="row">
|
||||
<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 class="row">
|
||||
<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 class="row">
|
||||
<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 class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('From E-mail')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{email.mail_from}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{config.mail_from}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-xs-12 col-sm-12">
|
||||
<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>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('From E-mail')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{email.mail_gmail_token['email']}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{config.mail_gmail_token['email']}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
@ -167,15 +167,15 @@
|
||||
<h2>{{_('Scheduled Tasks')}}</h2>
|
||||
<div class="col-xs-12 col-sm-12 scheduled_tasks_details">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Time at which tasks start to run')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('Start Time')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{schedule_time}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Maximum tasks duration')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('Maximum Duration')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{schedule_duration}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Generate book cover thumbnails')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('Generate Thumbnails')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
|
||||
</div>
|
||||
<!--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-->
|
||||
<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>
|
||||
<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>
|
||||
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
|
||||
{% if config.schedule_generate_book_covers %}
|
||||
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cover Cache')}}</a>
|
||||
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cache')}}</a>
|
||||
{% endif %}
|
||||
</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_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
||||
</div>
|
||||
{% if config.schedule_metadata_backup %}
|
||||
<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>
|
||||
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>{{_('Version Information')}}</h2>
|
||||
@ -224,7 +229,7 @@
|
||||
<tbody>
|
||||
<tr id="current_version">
|
||||
<td>{{commit}} </td>
|
||||
<td><i>{{_('Current version')}}</i></td>
|
||||
<td><i>{{_('Current Version')}}</i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user