1
0
mirror of https://github.com/janeczku/calibre-web synced 2025-10-29 22:33:01 +00:00

Merge branch 'master' into bulk-delete

This commit is contained in:
James Armstrong
2024-09-11 11:08:01 -07:00
committed by GitHub
109 changed files with 23292 additions and 11048 deletions

View File

@@ -1,10 +1,3 @@
# Short Notice from the maintainer
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
# Calibre-Web # Calibre-Web
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. 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.

View File

@@ -10,41 +10,46 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
## History ## History
| Fixed in | Description |CVE number | | Fixed in | Description |CVE number |
|---------------|--------------------------------------------------------------------------------------------------------------------|---------| |---------------|--------------------------------------------------------------------------------------------------------------------------------|---------|
| 3rd July 2018 | Guest access acts as a backdoor || | 3rd July 2018 | Guest access acts as a backdoor ||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 | | V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964| | V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo || | V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) || | V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field || | V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code || | V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code || | V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title || | V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
| V 0.6.13 | JavaScript could get executed in the shelf title || | V 0.6.13 | JavaScript could get executed in the shelf title ||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo || | V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965| | V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164| | V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170| | V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo || | V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo || | V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo || | V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352| | V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339| | V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273| | V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405| | V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767| | V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766| | V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765| | V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939| | V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990| | V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
| V 0.6.20 | Credentials for emails are now stored encrypted || | V 0.6.20 | Credentials for emails are now stored encrypted ||
| V 0.6.20 | Login is rate limited || | V 0.6.20 | Login is rate limited ||
| V 0.6.20 | Passwordstrength can be forced || | 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 | 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) 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 | 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 || | V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
| V 0.6.22 | Upload mimetype is checked to prevent malicious file content in the books library ||
| V 0.6.22 | Cross-site scripting (XSS) stored in comments section is prevented better (switching from lxml to bleach for sanitizing strings) ||
| V 0.6.23 | Cookies are no longer stored for opds basic authentication and proxy authentication ||
## Statement regarding Log4j (CVE-2021-44228 and related) ## Statement regarding Log4j (CVE-2021-44228 and related)

9
cps/__init__.py Executable file → Normal file
View File

@@ -72,6 +72,9 @@ mimetypes.add_type('application/mpeg', '.mpeg')
mimetypes.add_type('audio/mpeg', '.mp3') mimetypes.add_type('audio/mpeg', '.mp3')
mimetypes.add_type('audio/x-m4a', '.m4a') mimetypes.add_type('audio/x-m4a', '.m4a')
mimetypes.add_type('audio/x-m4a', '.m4b') mimetypes.add_type('audio/x-m4a', '.m4b')
mimetypes.add_type('audio/x-hx-aac-adts', '.aac')
mimetypes.add_type('audio/vnd.dolby.dd-raw', '.ac3')
mimetypes.add_type('video/x-ms-asf', '.asf')
mimetypes.add_type('audio/ogg', '.ogg') mimetypes.add_type('audio/ogg', '.ogg')
mimetypes.add_type('application/ogg', '.oga') mimetypes.add_type('application/ogg', '.oga')
mimetypes.add_type('text/css', '.css') mimetypes.add_type('text/css', '.css')
@@ -84,8 +87,10 @@ app = Flask(__name__)
app.config.update( app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Strict', SESSION_COOKIE_SAMESITE='Strict',
REMEMBER_COOKIE_SAMESITE='Strict', # will be available in flask-login 0.5.1 earliest REMEMBER_COOKIE_SAMESITE='Strict',
WTF_CSRF_SSL_STRICT=False WTF_CSRF_SSL_STRICT=False,
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
REMEMBER_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "remember_token"
) )
lm = MyLoginManager() lm = MyLoginManager()

View File

@@ -23,10 +23,10 @@
import sys import sys
import platform import platform
import sqlite3 import sqlite3
import importlib
from collections import OrderedDict from collections import OrderedDict
import flask import flask
import jinja2
from flask_babel import gettext as _ from flask_babel import gettext as _
from . import db, calibre_db, converter, uploader, constants, dep_check from . import db, calibre_db, converter, uploader, constants, dep_check
@@ -41,8 +41,11 @@ req = dep_check.load_dependencies(False)
opt = dep_check.load_dependencies(True) opt = dep_check.load_dependencies(True)
for i in (req + opt): for i in (req + opt):
modules[i[1]] = i[0] modules[i[1]] = i[0]
modules['Jinja2'] = jinja2.__version__ modules['Jinja2'] = importlib.metadata.version("jinja2")
modules['pySqlite'] = sqlite3.version try:
modules['pySqlite'] = sqlite3.version
except Exception:
pass
modules['SQLite'] = sqlite3.sqlite_version modules['SQLite'] = sqlite3.sqlite_version
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold()))) sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))

33
cps/admin.py Executable file → Normal file
View File

@@ -54,6 +54,7 @@ from .services.worker import WorkerThread
from .usermanagement import user_login_required from .usermanagement import user_login_required
from .babel import get_available_translations, get_available_locale, get_user_locale_language from .babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info from . import debug_info
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
@@ -463,9 +464,9 @@ def edit_list_user(param):
if 'value[]' in vals: if 'value[]' in vals:
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]'])) setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
else: else:
setattr(user, param, vals['value'].strip()) setattr(user, param, strip_whitespaces(vals['value']))
else: else:
vals['value'] = vals['value'].strip() vals['value'] = strip_whitespaces(vals['value'])
if param == 'name': if param == 'name':
if user.name == "Guest": if user.name == "Guest":
raise Exception(_("Guest Name can't be changed")) raise Exception(_("Guest Name can't be changed"))
@@ -566,7 +567,7 @@ def update_view_configuration():
_config_string(to_save, "config_calibre_web_title") _config_string(to_save, "config_calibre_web_title")
_config_string(to_save, "config_columns_to_ignore") _config_string(to_save, "config_columns_to_ignore")
if _config_string(to_save, "config_title_regex"): if _config_string(to_save, "config_title_regex"):
calibre_db.update_title_sort(config) calibre_db.create_functions(config)
if not check_valid_read_column(to_save.get("config_read_column", "0")): if not check_valid_read_column(to_save.get("config_read_column", "0")):
flash(_("Invalid Read Column"), category="error") flash(_("Invalid Read Column"), category="error")
@@ -690,7 +691,7 @@ def delete_domain():
def list_domain(allow): def list_domain(allow):
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all() answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer]) json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"') js = json.dumps(json_dumps.replace('"', "'")).strip('"')
response = make_response(js.replace("'", '"')) response = make_response(js.replace("'", '"'))
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"
return response return response
@@ -1100,7 +1101,7 @@ def _config_checkbox_int(to_save, x):
def _config_string(to_save, x): def _config_string(to_save, x):
return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y) return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)
def _configuration_gdrive_helper(to_save): def _configuration_gdrive_helper(to_save):
@@ -1311,9 +1312,9 @@ def update_mailsettings():
if to_save.get("mail_password_e", ""): if to_save.get("mail_password_e", ""):
_config_string(to_save, "mail_password_e") _config_string(to_save, "mail_password_e")
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024) _config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
config.mail_server = to_save.get('mail_server', "").strip() config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
config.mail_from = to_save.get('mail_from', "").strip() config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
config.mail_login = to_save.get('mail_login', "").strip() config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
try: try:
config.save() config.save()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
@@ -1678,10 +1679,10 @@ def cancel_task():
def _db_simulate_change(): def _db_simulate_change():
param = request.form.to_dict() param = request.form.to_dict()
to_save = dict() to_save = dict()
to_save['config_calibre_dir'] = re.sub(r'[\\/]metadata\.db$', to_save['config_calibre_dir'] = strip_whitespaces(re.sub(r'[\\/]metadata\.db$',
'', '',
param['config_calibre_dir'], param['config_calibre_dir'],
flags=re.IGNORECASE).strip() flags=re.IGNORECASE))
db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"], db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
ub.app_DB_path, ub.app_DB_path,
config.config_calibre_uuid) config.config_calibre_uuid)
@@ -1715,6 +1716,13 @@ def _db_configuration_update_helper():
db_change = True db_change = True
except Exception as ex: except Exception as ex:
return _db_configuration_result('{}'.format(ex), gdrive_error) return _db_configuration_result('{}'.format(ex), gdrive_error)
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
if config.config_calibre_split:
split_dir = to_save.get("config_calibre_split_dir")
if not os.path.exists(split_dir):
return _db_configuration_result(_("Books path not valid"), gdrive_error)
else:
_config_string(to_save, "config_calibre_split_dir")
if db_change or not db_valid or not config.db_configured \ if db_change or not db_valid or not config.db_configured \
or config.config_calibre_dir != to_save["config_calibre_dir"]: or config.config_calibre_dir != to_save["config_calibre_dir"]:
@@ -1740,8 +1748,6 @@ def _db_configuration_update_helper():
calibre_db.update_config(config) calibre_db.update_config(config)
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK): if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
flash(_("DB is not Writeable"), category="warning") flash(_("DB is not Writeable"), category="warning")
_config_string(to_save, "config_calibre_split_dir")
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
calibre_db.update_config(config) calibre_db.update_config(config)
config.save() config.save()
return _db_configuration_result(None, gdrive_error) return _db_configuration_result(None, gdrive_error)
@@ -1775,9 +1781,8 @@ def _configuration_update_helper():
if "config_upload_formats" in to_save: if "config_upload_formats" in to_save:
to_save["config_upload_formats"] = ','.join( to_save["config_upload_formats"] = ','.join(
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')])) helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
_config_string(to_save, "config_upload_formats") _config_string(to_save, "config_upload_formats")
# constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
_config_string(to_save, "config_calibre") _config_string(to_save, "config_calibre")
_config_string(to_save, "config_binariesdir") _config_string(to_save, "config_binariesdir")

153
cps/audio.py Normal file
View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2024 Ozzieisaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import mutagen
import base64
from . import cover, logger
from cps.constants import BookMeta
log = logger.create()
def get_audio_file_info(tmp_file_path, original_file_extension, original_file_name, no_cover_processing):
tmp_cover_name = None
audio_file = mutagen.File(tmp_file_path)
comments = None
if original_file_extension in [".mp3", ".wav", ".aiff"]:
cover_data = list()
for key, val in audio_file.tags.items():
if key.startswith("APIC:"):
cover_data.append(val)
if key.startswith("COMM:"):
comments = val.text[0]
title = audio_file.tags.get('TIT2').text[0] if "TIT2" in audio_file.tags else None
author = audio_file.tags.get('TPE1').text[0] if "TPE1" in audio_file.tags else None
if author is None:
author = audio_file.tags.get('TPE2').text[0] if "TPE2" in audio_file.tags else None
tags = audio_file.tags.get('TCON').text[0] if "TCON" in audio_file.tags else None # Genre
series = audio_file.tags.get('TALB').text[0] if "TALB" in audio_file.tags else None# Album
series_id = audio_file.tags.get('TRCK').text[0] if "TRCK" in audio_file.tags else None # track no.
publisher = audio_file.tags.get('TPUB').text[0] if "TPUB" in audio_file.tags else None
pubdate = str(audio_file.tags.get('TDRL').text[0]) if "TDRL" in audio_file.tags else None
if not pubdate:
pubdate = str(audio_file.tags.get('TDRC').text[0]) if "TDRC" in audio_file.tags else None
if not pubdate:
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_info = cover_data[0]
for dat in cover_data:
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
cover_info = dat
break
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
comments = audio_file.tags.get('COMMENTS')[0] if "COMMENTS" in audio_file else None
tags = audio_file.tags.get('GENRE')[0] if "GENRE" in audio_file else None # Genre
series = audio_file.tags.get('ALBUM')[0] if "ALBUM" in audio_file else None
series_id = audio_file.tags.get('TRACKNUMBER')[0] if "TRACKNUMBER" in audio_file else None
publisher = audio_file.tags.get('LABEL')[0] if "LABEL" in audio_file else None
pubdate = audio_file.tags.get('DATE')[0] if "DATE" in audio_file else None
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
if not no_cover_processing:
if cover_data:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
if hasattr(audio_file, "pictures"):
cover_info = audio_file.pictures[0]
for dat in audio_file.pictures:
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
cover_info = dat
break
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
elif original_file_extension in [".aac"]:
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
comments = audio_file.tags.get('Comment').value if "Comment" in audio_file else None
tags = audio_file.tags.get('Genre').value if "Genre" in audio_file else None
series = audio_file.tags.get('Album').value if "Album" in audio_file else None
series_id = audio_file.tags.get('Track').value if "Track" in audio_file else None
publisher = audio_file.tags.get('Label').value if "Label" in audio_file else None
pubdate = audio_file.tags.get('Year').value if "Year" in audio_file else None
cover_data = audio_file.tags['Cover Art (Front)']
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
with open(tmp_cover_name, "wb") as cover_file:
cover_file.write(cover_data.value.split(b"\x00",1)[1])
elif original_file_extension in [".asf"]:
title = audio_file.tags.get('Title')[0].value if "Title" in audio_file else None
author = audio_file.tags.get('Artist')[0].value if "Artist" in audio_file else None
comments = audio_file.tags.get('Comments')[0].value if "Comments" in audio_file else None
tags = audio_file.tags.get('Genre')[0].value if "Genre" in audio_file else None
series = audio_file.tags.get('Album')[0].value if "Album" in audio_file else None
series_id = audio_file.tags.get('Track')[0].value if "Track" in audio_file else None
publisher = audio_file.tags.get('Label')[0].value if "Label" in audio_file else None
pubdate = audio_file.tags.get('Year')[0].value if "Year" in audio_file else None
cover_data = audio_file.tags.get('WM/Picture', None)
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
with open(tmp_cover_name, "wb") as cover_file:
cover_file.write(cover_data[0].value)
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
title = audio_file.tags.get('©nam')[0] if "©nam" in audio_file.tags else None
author = audio_file.tags.get('©ART')[0] if "©ART" in audio_file.tags else None
comments = audio_file.tags.get('©cmt')[0] if "©cmt" in audio_file.tags else None
tags = audio_file.tags.get('©gen')[0] if "©gen" in audio_file.tags else None
series = audio_file.tags.get('©alb')[0] if "©alb" in audio_file.tags else None
series_id = str(audio_file.tags.get('trkn')[0][0]) if "trkn" in audio_file.tags else None
publisher = ""
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
cover_data = audio_file.tags.get('covr', None)
if cover_data and not no_cover_processing:
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
cover_type = None
for c in cover_data:
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
cover_type =".jpg"
cover_bin = c
break
elif c.imageformat == mutagen.mp4.AtomDataType.PNG:
cover_type = ".png"
cover_bin = c
break
if cover_type:
cover.cover_processing(tmp_file_path, cover_bin, cover_type)
else:
logger.error("Unknown covertype in file {} ".format(original_file_name))
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=title or original_file_name ,
author="Unknown" if author is None else author,
cover=tmp_cover_name,
description="" if comments is None else comments,
tags="" if tags is None else tags,
series="" if series is None else series,
series_id="1" if series_id is None else series_id.split("/")[0],
languages="",
publisher= "" if publisher is None else publisher,
pubdate="" if pubdate is None else pubdate,
identifiers=[],
)

View File

@@ -130,7 +130,7 @@ def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
return cover.cover_processing(tmp_file_name, cover_data, extension) return cover.cover_processing(tmp_file_name, cover_data, extension)
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable): def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable, no_cover_processing):
if use_comic_meta: if use_comic_meta:
try: try:
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable) archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
@@ -155,14 +155,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
lang = loaded_metadata.language or "" lang = loaded_metadata.language or ""
loaded_metadata.language = isoLanguages.get_lang3(lang) loaded_metadata.language = isoLanguages.get_lang3(lang)
if not no_cover_processing:
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
else:
cover_file = None
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=loaded_metadata.title or original_file_name, title=loaded_metadata.title or original_file_name,
author=" & ".join([credit["person"] author=" & ".join([credit["person"]
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown', for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable), cover=cover_file,
description=loaded_metadata.comments or "", description=loaded_metadata.comments or "",
tags="", tags="",
series=loaded_metadata.series or "", series=loaded_metadata.series or "",
@@ -171,13 +174,17 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
publisher="", publisher="",
pubdate="", pubdate="",
identifiers=[]) identifiers=[])
if not no_cover_processing:
cover_file = _extract_cover(tmp_file_path, original_file_extension, rar_executable)
else:
cover_file = None
return BookMeta( return BookMeta(
file_path=tmp_file_path, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=original_file_name, title=original_file_name,
author='Unknown', author='Unknown',
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable), cover=cover_file,
description="", description="",
tags="", tags="",
series="", series="",

View File

@@ -35,7 +35,7 @@ except ImportError:
from . import constants, logger from . import constants, logger
from .subproc_wrapper import process_wait from .subproc_wrapper import process_wait
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
_Base = declarative_base() _Base = declarative_base()
@@ -182,26 +182,6 @@ class _Settings(_Base):
class ConfigSQL(object): class ConfigSQL(object):
# pylint: disable=no-member # pylint: disable=no-member
def __init__(self): def __init__(self):
'''self.config_calibre_uuid = None
self.config_calibre_split_dir = None
self.dirty = None
self.config_logfile = None
self.config_upload_formats = None
self.mail_gmail_token = None
self.mail_server_type = None
self.mail_server = None
self.config_log_level = None
self.config_allowed_column_value = None
self.config_denied_column_value = None
self.config_allowed_tags = None
self.config_denied_tags = None
self.config_default_show = None
self.config_default_role = None
self.config_keyfile = None
self.config_certfile = None
self.config_rarfile_location = None
self.config_kepubifypath = None
self.config_binariesdir = None'''
self.__dict__["dirty"] = list() self.__dict__["dirty"] = list()
def init_config(self, session, secret_key, cli): def init_config(self, session, secret_key, cli):
@@ -288,19 +268,19 @@ class ConfigSQL(object):
def list_denied_tags(self): def list_denied_tags(self):
mct = self.config_denied_tags or "" mct = self.config_denied_tags or ""
return [t.strip() for t in mct.split(",")] return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_tags(self): def list_allowed_tags(self):
mct = self.config_allowed_tags or "" mct = self.config_allowed_tags or ""
return [t.strip() for t in mct.split(",")] return [strip_whitespaces(t) for t in mct.split(",")]
def list_denied_column_values(self): def list_denied_column_values(self):
mct = self.config_denied_column_value or "" mct = self.config_denied_column_value or ""
return [t.strip() for t in mct.split(",")] return [strip_whitespaces(t) for t in mct.split(",")]
def list_allowed_column_values(self): def list_allowed_column_values(self):
mct = self.config_allowed_column_value or "" mct = self.config_allowed_column_value or ""
return [t.strip() for t in mct.split(",")] return [strip_whitespaces(t) for t in mct.split(",")]
def get_log_level(self): def get_log_level(self):
return logger.get_level_name(self.config_log_level) return logger.get_level_name(self.config_log_level)
@@ -372,7 +352,7 @@ class ConfigSQL(object):
db_file = os.path.join(self.config_calibre_dir, 'metadata.db') db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
have_metadata_db = os.path.isfile(db_file) have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db self.db_configured = have_metadata_db
# constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
from . import cli_param from . import cli_param
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG) logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)

View File

@@ -175,7 +175,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
'series_id, languages, publisher, pubdate, identifiers') 'series_id, languages, publisher, pubdate, identifiers')
# python build process likes to have x.y.zbw -> b for beta and w a counting number # python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = {'version': '0.6.23b'} STABLE_VERSION = {'version': '0.6.24b'}
NIGHTLY_VERSION = dict() NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'
@@ -193,7 +193,7 @@ THUMBNAIL_TYPE_AUTHOR = 3
COVER_THUMBNAIL_ORIGINAL = 0 COVER_THUMBNAIL_ORIGINAL = 0
COVER_THUMBNAIL_SMALL = 1 COVER_THUMBNAIL_SMALL = 1
COVER_THUMBNAIL_MEDIUM = 2 COVER_THUMBNAIL_MEDIUM = 2
COVER_THUMBNAIL_LARGE = 3 COVER_THUMBNAIL_LARGE = 4
# clean-up the module namespace # clean-up the module namespace
del sys, os, namedtuple del sys, os, namedtuple

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from datetime import timezone
from datetime import timedelta from datetime import timedelta
import hashlib import hashlib
@@ -496,7 +497,7 @@ class LoginManager:
duration = timedelta(seconds=duration) duration = timedelta(seconds=duration)
try: try:
expires = datetime.utcnow() + duration expires = datetime.now(timezone.utc) + duration
except TypeError as e: except TypeError as e:
raise Exception( raise Exception(
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta," "REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"

View File

@@ -20,10 +20,11 @@
import os import os
import re import re
import json import json
from datetime import datetime from datetime import datetime, timezone
from urllib.parse import quote from urllib.parse import quote
import unidecode import unidecode
from weakref import WeakSet from weakref import WeakSet
from uuid import uuid4
from sqlite3 import OperationalError as sqliteOperationalError from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -48,7 +49,7 @@ from flask import flash
from . import logger, ub, isoLanguages from . import logger, ub, isoLanguages
from .pagination import Pagination from .pagination import Pagination
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
@@ -378,10 +379,10 @@ class Books(Base):
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown') title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
sort = Column(String(collation='NOCASE')) sort = Column(String(collation='NOCASE'))
author_sort = Column(String(collation='NOCASE')) author_sort = Column(String(collation='NOCASE'))
timestamp = Column(TIMESTAMP, default=datetime.utcnow) timestamp = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE) pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
series_index = Column(String, nullable=False, default="1.0") series_index = Column(String, nullable=False, default="1.0")
last_modified = Column(TIMESTAMP, default=datetime.utcnow) last_modified = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
path = Column(String, default="", nullable=False) path = Column(String, default="", nullable=False)
has_cover = Column(Integer, default=0) has_cover = Column(Integer, default=0)
uuid = Column(String) uuid = Column(String)
@@ -533,7 +534,7 @@ class CalibreDB:
def init_session(self, expire_on_commit=True): def init_session(self, expire_on_commit=True):
self.session = self.session_factory() self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config) self.create_functions(self.config)
@classmethod @classmethod
def setup_db_cc_classes(cls, cc): def setup_db_cc_classes(cls, cc):
@@ -875,10 +876,11 @@ class CalibreDB:
authors_ordered = list() authors_ordered = list()
# error = False # error = False
for auth in sort_authors: for auth in sort_authors:
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all() auth = strip_whitespaces(auth)
results = self.session.query(Authors).filter(Authors.sort == auth).all()
# ToDo: How to handle not found author name # ToDo: How to handle not found author name
if not len(results): if not len(results):
log.error("Author {} not found to display name in right order".format(auth.strip())) log.error("Author {} not found to display name in right order".format(auth))
# error = True # error = True
break break
for r in results: for r in results:
@@ -900,7 +902,8 @@ class CalibreDB:
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()): def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
query = query or '' query = query or ''
self.session.connection().connection.connection.create_function("lower", 1, lcase) self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
entries = self.session.query(database).filter(tag_filter). \ entries = self.session.query(database).filter(tag_filter). \
filter(func.lower(database.name).ilike("%" + query + "%")).all() filter(func.lower(database.name).ilike("%" + query + "%")).all()
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries]) # json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
@@ -908,7 +911,8 @@ class CalibreDB:
return json_dumps return json_dumps
def check_exists_book(self, authr, title): def check_exists_book(self, authr, title):
self.session.connection().connection.connection.create_function("lower", 1, lcase) self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list() q = list()
author_terms = re.split(r'\s*&\s*', authr) author_terms = re.split(r'\s*&\s*', authr)
for author_term in author_terms: for author_term in author_terms:
@@ -918,8 +922,9 @@ class CalibreDB:
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first() .filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
def search_query(self, term, config, *join): def search_query(self, term, config, *join):
term.strip().lower() strip_whitespaces(term).lower()
self.session.connection().connection.connection.create_function("lower", 1, lcase) self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list() q = list()
author_terms = re.split("[, ]+", term) author_terms = re.split("[, ]+", term)
for author_term in author_terms: for author_term in author_terms:
@@ -1017,7 +1022,7 @@ class CalibreDB:
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code) lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
return sorted(languages, key=lambda x: x.name, reverse=reverse_order) return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
def update_title_sort(self, config, conn=None): def create_functions(self, config=None):
# user defined sort function for calibre databases (Series, etc.) # user defined sort function for calibre databases (Series, etc.)
def _title_sort(title): def _title_sort(title):
# calibre sort stuff # calibre sort stuff
@@ -1026,16 +1031,19 @@ class CalibreDB:
if match: if match:
prep = match.group(1) prep = match.group(1)
title = title[len(prep):] + ', ' + prep title = title[len(prep):] + ', ' + prep
return title.strip() return strip_whitespaces(title)
try: try:
# sqlalchemy <1.4.24 # sqlalchemy <1.4.24 and sqlalchemy 2.0
conn = conn or self.session.connection().connection.driver_connection conn = self.session.connection().connection.driver_connection
except AttributeError: except AttributeError:
# sqlalchemy >1.4.24 and sqlalchemy 2.0 # sqlalchemy >1.4.24
conn = conn or self.session.connection().connection.connection conn = self.session.connection().connection.connection
try: try:
conn.create_function("title_sort", 1, _title_sort) if config:
conn.create_function("title_sort", 1, _title_sort)
conn.create_function('uuid4', 0, lambda: str(uuid4()))
conn.create_function("lower", 1, lcase)
except sqliteOperationalError: except sqliteOperationalError:
pass pass

View File

@@ -26,7 +26,8 @@ from flask_babel.speaklater import LazyString
import os import os
from flask import send_file, __version__ from flask import send_file
import importlib
from . import logger, config from . import logger, config
from .about import collect_stats from .about import collect_stats
@@ -49,7 +50,8 @@ def assemble_logfiles(file_name):
with open(f, 'rb') as fd: with open(f, 'rb') as fd:
shutil.copyfileobj(fd, wfd) shutil.copyfileobj(fd, wfd)
wfd.seek(0) wfd.seek(0)
if int(__version__.split('.')[0]) < 2: version = importlib.metadata.version("flask")
if int(version.split('.')[0]) < 2:
return send_file(wfd, return send_file(wfd,
as_attachment=True, as_attachment=True,
attachment_filename=os.path.basename(file_name)) attachment_filename=os.path.basename(file_name))
@@ -72,7 +74,8 @@ def send_debug():
for fp in file_list: for fp in file_list:
zf.write(fp, os.path.basename(fp)) zf.write(fp, os.path.basename(fp))
memory_zip.seek(0) memory_zip.seek(0)
if int(__version__.split('.')[0]) < 2: version = importlib.metadata.version("flask")
if int(version.split('.')[0]) < 2:
return send_file(memory_zip, return send_file(memory_zip,
as_attachment=True, as_attachment=True,
attachment_filename="Calibre-Web-debug-pack.zip") attachment_filename="Calibre-Web-debug-pack.zip")

View File

@@ -21,10 +21,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os import os
from datetime import datetime from datetime import datetime, timezone
import json import json
from shutil import copyfile from shutil import copyfile
from uuid import uuid4
from markupsafe import escape, Markup # dependency of flask from markupsafe import escape, Markup # dependency of flask
from functools import wraps from functools import wraps
@@ -47,7 +47,7 @@ from .kobo_sync_status import change_archived_books
from .redirect import get_redirect_location from .redirect import get_redirect_location
from .file_helper import validate_mime_type from .file_helper import validate_mime_type
from .usermanagement import user_login_required, login_required_if_no_ano from .usermanagement import user_login_required, login_required_if_no_ano
from .string_helper import strip_whitespaces
editbook = Blueprint('edit-book', __name__) editbook = Blueprint('edit-book', __name__)
log = logger.create() log = logger.create()
@@ -97,157 +97,22 @@ def show_edit_book(book_id):
@login_required_if_no_ano @login_required_if_no_ano
@edit_required @edit_required
def edit_book(book_id): def edit_book(book_id):
modify_date = False return do_edit_book(book_id)
edit_error = False
# create the function for sorting...
calibre_db.update_title_sort(config)
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found
if not book:
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
to_save = request.form.to_dict()
try:
# Update folder of book on local disk
edited_books_id = None
title_author_error = None
# handle book title change
title_change = handle_title_on_edit(book, to_save["book_title"])
# handle book author change
input_authors, author_change = handle_author_on_edit(book, to_save["author_name"])
if author_change or title_change:
edited_books_id = book.id
modify_date = True
title_author_error = helper.update_dir_structure(edited_books_id,
config.get_book_path(),
input_authors[0])
if title_author_error:
flash(title_author_error, category="error")
calibre_db.session.rollback()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# handle upload other formats from local disk
meta = upload_single_file(request, book, book_id)
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
upload_format = False
if meta:
upload_format = merge_metadata(to_save, meta)
# handle upload covers from local disk
cover_upload_success = upload_cover(request, book)
if cover_upload_success:
book.has_cover = 1
modify_date = True
# upload new covers or new file formats to google drive
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if to_save.get("cover_url", None):
if not current_user.role_upload():
edit_error = True
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:
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
if result is True:
book.has_cover = 1
modify_date = True
helper.replace_cover_thumbnail_cache(book.id)
else:
flash(error, category="error")
# Add default series_index to book
modify_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save['tags'], book)
# Handle book series
modify_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modify_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
try:
modify_date |= edit_book_languages(to_save['languages'], book, upload_format)
except ValueError as e:
flash(str(e), category="error")
edit_error = True
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
if to_save.get("pubdate", None):
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError as e:
book.pubdate = db.Books.DEFAULT_PUBDATE
flash(str(e), category="error")
edit_error = True
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.utcnow()
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.set_metadata_dirty(book.id)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if meta is not False \
and edit_error is not True \
and title_author_error is not True \
and cover_upload_success is not False:
flash(_("Metadata successfully updated"), category="success")
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
return render_edit_book(book_id)
except ValueError as e:
log.error_or_exception("Error: {}".format(e))
calibre_db.session.rollback()
flash(str(e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
log.error_or_exception("Database error: {}".format(e))
calibre_db.session.rollback()
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)
calibre_db.session.rollback()
flash(_("Error editing book: {}".format(ex)), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
@editbook.route("/upload", methods=["POST"]) @editbook.route("/upload", methods=["POST"])
@login_required_if_no_ano @login_required_if_no_ano
@upload_required @upload_required
def upload(): def upload():
if not config.config_uploading: if len(request.files.getlist("btn-upload-format")):
abort(404) book_id = request.form.get('book_id', -1)
if request.method == 'POST' and 'btn-upload' in request.files: return do_edit_book(book_id, request.files.getlist("btn-upload-format"))
elif len(request.files.getlist("btn-upload")):
for requested_file in request.files.getlist("btn-upload"): for requested_file in request.files.getlist("btn-upload"):
try: try:
modify_date = False modify_date = False
# create the function for sorting... # create the function for sorting...
calibre_db.update_title_sort(config) calibre_db.create_functions(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
meta, error = file_handling_on_upload(requested_file) meta, error = file_handling_on_upload(requested_file)
if error: if error:
return error return error
@@ -275,9 +140,7 @@ def upload():
input_authors[0], input_authors[0],
meta.file_path, meta.file_path,
title_dir + meta.extension.lower()) title_dir + meta.extension.lower())
move_coverfile(meta, db_book) move_coverfile(meta, db_book)
if modify_date: if modify_date:
calibre_db.set_metadata_dirty(book_id) calibre_db.set_metadata_dirty(book_id)
# save data to database, reread data # save data to database, reread data
@@ -305,6 +168,7 @@ def upload():
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error") category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
abort(404)
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST']) @editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
@@ -508,7 +372,7 @@ def edit_book_param(param, vals):
mimetype='application/json') mimetype='application/json')
else: else:
return _("Parameter not found"), 400 return _("Parameter not found"), 400
book.last_modified = datetime.utcnow() book.last_modified = datetime.now(timezone.utc)
calibre_db.session.commit() calibre_db.session.commit()
# revert change for sort if automatic fields link is deactivated # revert change for sort if automatic fields link is deactivated
@@ -681,7 +545,7 @@ def table_xchange_author_title():
# toDo: Handle error # toDo: Handle error
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0]) edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
if modify_date: if modify_date:
book.last_modified = datetime.utcnow() book.last_modified = datetime.now(timezone.utc)
calibre_db.set_metadata_dirty(book.id) calibre_db.set_metadata_dirty(book.id)
try: try:
calibre_db.session.commit() calibre_db.session.commit()
@@ -696,23 +560,166 @@ def table_xchange_author_title():
return "" return ""
def merge_metadata(to_save, meta): def do_edit_book(book_id, upload_formats=None):
if to_save.get('author_name', "") == _('Unknown'): modify_date = False
to_save['author_name'] = '' edit_error = False
if to_save.get('book_title', "") == _('Unknown'):
to_save['book_title'] = '' # create the function for sorting...
if not to_save["languages"] and meta.languages: calibre_db.create_functions(config)
upload_language = True
else: book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
upload_language = False # Book not found
if not book:
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
to_save = request.form.to_dict()
try:
# Update folder of book on local disk
edited_books_id = None
title_author_error = None
# upload_mode = False
# handle book title change
if "title" in to_save:
title_change = handle_title_on_edit(book, to_save["title"])
# handle book author change
if not upload_formats:
input_authors, author_change = handle_author_on_edit(book, to_save["authors"])
if author_change or title_change:
edited_books_id = book.id
modify_date = True
title_author_error = helper.update_dir_structure(edited_books_id,
config.get_book_path(),
input_authors[0])
if title_author_error:
flash(title_author_error, category="error")
calibre_db.session.rollback()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
else:
# handle upload other formats from local disk
to_save, edit_error = upload_book_formats(upload_formats, book, book_id, book.has_cover)
# handle upload covers from local disk
cover_upload_success = upload_cover(request, book)
if cover_upload_success or to_save.get("format_cover"):
book.has_cover = 1
modify_date = True
# upload new covers or new file formats to google drive
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if to_save.get("cover_url",):
if not current_user.role_upload():
edit_error = True
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:
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
if result is True:
book.has_cover = 1
modify_date = True
helper.replace_cover_thumbnail_cache(book.id)
else:
flash(error, category="error")
# Add default series_index to book
modify_date |= edit_book_series_index(to_save.get("series_index"), book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save.get('comments')).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save.get('tags'), book)
# Handle book series
modify_date |= edit_book_series(to_save.get("series"), book)
# handle book publisher
modify_date |= edit_book_publisher(to_save.get('publisher'), book)
# handle book languages
try:
invalid = []
modify_date |= edit_book_languages(to_save.get('languages'), book, upload_mode=upload_formats,
invalid=invalid)
if invalid:
for lang in invalid:
flash(_("'%(langname)s' is not a valid language", langname=lang), category="warning")
except ValueError as e:
flash(str(e), category="error")
edit_error = True
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
if to_save.get("pubdate") is not None:
if to_save.get("pubdate"):
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError as e:
book.pubdate = db.Books.DEFAULT_PUBDATE
flash(str(e), category="error")
edit_error = True
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.now(timezone.utc)
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.set_metadata_dirty(book.id)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if edit_error is not True and title_author_error is not True and cover_upload_success is not False:
flash(_("Metadata successfully updated"), category="success")
if upload_formats:
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
return Response(json.dumps(resp), mimetype='application/json')
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
return render_edit_book(book_id)
except ValueError as e:
log.error_or_exception("Error: {}".format(e))
calibre_db.session.rollback()
flash(str(e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
log.error_or_exception("Database error: {}".format(e))
calibre_db.session.rollback()
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)
calibre_db.session.rollback()
flash(_("Error editing book: {}".format(ex)), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
def merge_metadata(book, meta, to_save):
if meta.cover:
to_save['cover_format'] = meta.cover
for s_field, m_field in [ for s_field, m_field in [
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'), ('tags', 'tags'), ('authors', 'author'), ('series', 'series'),
('series_index', 'series_id'), ('languages', 'languages'), ('series_index', 'series_id'), ('languages', 'languages'),
('book_title', 'title')]: ('title', 'title'), ('comments', 'description')]:
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '') try:
to_save["description"] = to_save["description"] or Markup( val = None if len(getattr(book, s_field)) else getattr(meta, m_field, '')
getattr(meta, 'description', '')).unescape() except TypeError:
return upload_language val = None if len(str(getattr(book, s_field))) else getattr(meta, m_field, '')
if val:
to_save[s_field] = val
def identifier_list(to_save, book): def identifier_list(to_save, book):
"""Generate a list of Identifiers from form information""" """Generate a list of Identifiers from form information"""
@@ -832,8 +839,8 @@ def create_book_on_upload(modify_date, meta):
pubdate = datetime(101, 1, 1) pubdate = datetime(101, 1, 1)
# Calibre adds books with utc as timezone # Calibre adds books with utc as timezone
db_book = db.Books(title, "", sort_authors, datetime.utcnow(), pubdate, db_book = db.Books(title, "", sort_authors, datetime.now(timezone.utc), pubdate,
'1', datetime.utcnow(), path, meta.cover, db_author, [], "") '1', datetime.now(timezone.utc), path, meta.cover, db_author, [], "")
modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session, modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session,
'author') 'author')
@@ -1100,7 +1107,7 @@ def render_edit_book(book_id):
def edit_book_ratings(to_save, book): def edit_book_ratings(to_save, book):
changed = False changed = False
if to_save.get("rating", "").strip(): if strip_whitespaces(to_save.get("rating", "")):
old_rating = False old_rating = False
if len(book.ratings) > 0: if len(book.ratings) > 0:
old_rating = book.ratings[0].rating old_rating = book.ratings[0].rating
@@ -1123,84 +1130,93 @@ def edit_book_ratings(to_save, book):
def edit_book_tags(tags, book): def edit_book_tags(tags, book):
input_tags = tags.split(',') if tags is not None:
input_tags = list(map(lambda it: it.strip(), input_tags)) input_tags = tags.split(',')
# Remove duplicates input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
input_tags = helper.uniq(input_tags) # Remove duplicates
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags') input_tags = helper.uniq(input_tags)
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
return False
def edit_book_series(series, book): def edit_book_series(series, book):
input_series = [series.strip()] if series is not None:
input_series = [x for x in input_series if x != ''] input_series = [strip_whitespaces(series)]
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series') input_series = [x for x in input_series if x != '']
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
return False
def edit_book_series_index(series_index, book): def edit_book_series_index(series_index, book):
# Add default series_index to book if series_index:
modify_date = False # Add default series_index to book
series_index = series_index or '1' modify_date = False
if not series_index.replace('.', '', 1).isdigit(): series_index = series_index or '1'
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning") if not series_index.replace('.', '', 1).isdigit():
return False flash(_("Seriesindex: %(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
if str(book.series_index) != series_index: return False
book.series_index = series_index if str(book.series_index) != series_index:
modify_date = True book.series_index = series_index
return modify_date modify_date = True
return modify_date
return False
# Handle book comments/description # Handle book comments/description
def edit_book_comments(comments, book): def edit_book_comments(comments, book):
modify_date = False if comments is not None:
if comments: modify_date = False
comments = clean_string(comments, book.id)
if len(book.comments):
if book.comments[0].text != comments:
book.comments[0].text = comments
modify_date = True
else:
if comments: if comments:
book.comments.append(db.Comments(comment=comments, book=book.id)) comments = clean_string(comments, book.id)
modify_date = True if len(book.comments):
return modify_date if book.comments[0].text != comments:
book.comments[0].text = comments
modify_date = True
else:
if comments:
book.comments.append(db.Comments(comment=comments, book=book.id))
modify_date = True
return modify_date
def edit_book_languages(languages, book, upload_mode=False, invalid=None): def edit_book_languages(languages, book, upload_mode=False, invalid=None):
input_languages = languages.split(',') if languages is not None:
unknown_languages = [] input_languages = languages.split(',')
if not upload_mode: unknown_languages = []
input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages) if not upload_mode:
else: input_l = isoLanguages.get_language_code_from_name(get_locale(), input_languages, unknown_languages)
input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages)
for lang in unknown_languages:
log.error("'%s' is not a valid language", lang)
if isinstance(invalid, list):
invalid.append(lang)
else: else:
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang)) input_l = isoLanguages.get_valid_language_codes_from_code(get_locale(), input_languages, unknown_languages)
# ToDo: Not working correct for lang in unknown_languages:
if upload_mode and len(input_l) == 1: log.error("'%s' is not a valid language", lang)
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view if isinstance(invalid, list):
# the book it's language is set to the filter language invalid.append(lang)
if input_l[0] != current_user.filter_language() and current_user.filter_language() != "all": else:
input_l[0] = calibre_db.session.query(db.Languages). \ raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code # ToDo: Not working correct
# Remove duplicates if upload_mode and len(input_l) == 1:
input_l = helper.uniq(input_l) # If the language of the file is excluded from the users view, it's not imported, to allow the user to view
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages') # the book it's language is set to the filter language
if input_l[0] != current_user.filter_language() and current_user.filter_language() != "all":
input_l[0] = calibre_db.session.query(db.Languages). \
filter(db.Languages.lang_code == current_user.filter_language()).first().lang_code
# Remove duplicates from normalized langcodes
input_l = helper.uniq(input_l)
return modify_database_object(input_l, book.languages, db.Languages, calibre_db.session, 'languages')
return False
def edit_book_publisher(publishers, book): def edit_book_publisher(publishers, book):
changed = False if publishers is not None:
if publishers: changed = False
publisher = publishers.rstrip().strip() if publishers:
if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name): publisher = strip_whitespaces(publishers)
changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session, if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
'publisher') changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session,
elif len(book.publishers): 'publisher')
changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher') elif len(book.publishers):
return changed changed |= modify_database_object([], book.publishers, db.Publishers, calibre_db.session, 'publisher')
return changed
return False
def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string): def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
changed = False changed = False
@@ -1240,7 +1256,7 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
changed = False changed = False
if c.datatype == 'rating': if c.datatype == 'rating':
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2)) to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
if to_save[cc_string].strip() != cc_db_value: if strip_whitespaces(to_save[cc_string]) != cc_db_value:
if cc_db_value is not None: if cc_db_value is not None:
# remove old cc_val # remove old cc_val
del_cc = getattr(book, cc_string)[0] del_cc = getattr(book, cc_string)[0]
@@ -1250,15 +1266,15 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
changed = True changed = True
cc_class = db.cc_classes[c.id] cc_class = db.cc_classes[c.id]
new_cc = calibre_db.session.query(cc_class).filter( new_cc = calibre_db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first() cc_class.value == strip_whitespaces(to_save[cc_string])).first()
# if no cc val is found add it # if no cc val is found add it
if new_cc is None: if new_cc is None:
new_cc = cc_class(value=to_save[cc_string].strip()) new_cc = cc_class(value=strip_whitespaces(to_save[cc_string]))
calibre_db.session.add(new_cc) calibre_db.session.add(new_cc)
changed = True changed = True
calibre_db.session.flush() calibre_db.session.flush()
new_cc = calibre_db.session.query(cc_class).filter( new_cc = calibre_db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first() cc_class.value == strip_whitespaces(to_save[cc_string])).first()
# add cc value to book # add cc value to book
getattr(book, cc_string).append(new_cc) getattr(book, cc_string).append(new_cc)
return changed, to_save return changed, to_save
@@ -1281,61 +1297,66 @@ def edit_cc_data(book_id, book, to_save, cc):
changed = False changed = False
for c in cc: for c in cc:
cc_string = "custom_column_" + str(c.id) cc_string = "custom_column_" + str(c.id)
if not c.is_multiple: if to_save.get(cc_string) is not None:
if len(getattr(book, cc_string)) > 0: if not c.is_multiple:
cc_db_value = getattr(book, cc_string)[0].value if len(getattr(book, cc_string)) > 0:
else: cc_db_value = getattr(book, cc_string)[0].value
cc_db_value = None
if to_save[cc_string].strip():
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else: else:
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string) cc_db_value = None
changed |= change if strip_whitespaces(to_save[cc_string]):
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else:
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
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
if not del_cc.books or len(del_cc.books) == 0:
calibre_db.session.delete(del_cc)
changed = True
else: else:
if cc_db_value is not None: input_tags = to_save[cc_string].split(',')
# remove old cc_val input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
del_cc = getattr(book, cc_string)[0] changed |= modify_database_object(input_tags,
getattr(book, cc_string).remove(del_cc) getattr(book, cc_string),
if not del_cc.books or len(del_cc.books) == 0: db.cc_classes[c.id],
calibre_db.session.delete(del_cc) calibre_db.session,
changed = True 'custom')
else:
input_tags = to_save[cc_string].split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
changed |= modify_database_object(input_tags,
getattr(book, cc_string),
db.cc_classes[c.id],
calibre_db.session,
'custom')
return changed return changed
# returns None if no file is uploaded # returns False if an error occurs or no book is uploaded, in all other cases the ebook metadata to change is returned
# returns False if an error occurs, in all other cases the ebook metadata is returned def upload_book_formats(requested_files, book, book_id, no_cover=True):
def upload_single_file(file_request, book, book_id):
# Check and handle Uploaded file # Check and handle Uploaded file
requested_file = file_request.files.get('btn-upload-format', None) to_save = dict()
error = False
allowed_extensions = config.config_upload_formats.split(',') allowed_extensions = config.config_upload_formats.split(',')
if requested_file: for requested_file in requested_files:
current_filename = requested_file.filename
if config.config_check_extensions and allowed_extensions != ['']: if config.config_check_extensions and allowed_extensions != ['']:
if not validate_mime_type(requested_file, allowed_extensions): if not validate_mime_type(requested_file, allowed_extensions):
flash(_("File type isn't allowed to be uploaded to this server"), category="error") flash(_("File type isn't allowed to be uploaded to this server"), category="error")
return False error = True
# check for empty request continue
if requested_file.filename != '': if current_filename != '':
if not current_user.role_upload(): if not current_user.role_upload():
flash(_("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 error = True
if '.' in requested_file.filename: continue
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() if '.' in current_filename:
file_ext = current_filename.rsplit('.', 1)[-1].lower()
if file_ext not in allowed_extensions and '' not in allowed_extensions: if file_ext not in allowed_extensions and '' not in allowed_extensions:
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext), flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
category="error") category="error")
return False error = True
continue
else: else:
flash(_('File to be uploaded must have an extension'), category="error") flash(_('File to be uploaded must have an extension'), category="error")
return False error = True
continue
file_name = book.path.rsplit('/', 1)[-1] file_name = book.path.rsplit('/', 1)[-1]
filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path)) filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path))
@@ -1348,41 +1369,50 @@ def upload_single_file(file_request, book, book_id):
except OSError: except OSError:
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath), flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
category="error") category="error")
return False error = True
continue
try: try:
requested_file.save(saved_filename) requested_file.save(saved_filename)
except OSError: except OSError:
flash(_("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 error = True
continue
file_size = os.path.getsize(saved_filename) file_size = os.path.getsize(saved_filename)
is_format = calibre_db.get_book_format(book_id, file_ext.upper())
# Format entry already exists, no need to update the database # Format entry already exists, no need to update the database
if is_format: if calibre_db.get_book_format(book_id, file_ext.upper()):
log.warning('Book format %s already existing', file_ext.upper()) log.warning('Book format %s already existing', file_ext.upper())
else: else:
try: try:
db_format = db.Data(book_id, file_ext.upper(), file_size, file_name) db_format = db.Data(book_id, file_ext.upper(), file_size, file_name)
calibre_db.session.add(db_format) calibre_db.session.add(db_format)
calibre_db.session.commit() calibre_db.session.commit()
calibre_db.update_title_sort(config) calibre_db.create_functions(config)
except (OperationalError, IntegrityError, StaleDataError) as e: except (OperationalError, IntegrityError, StaleDataError) as e:
calibre_db.session.rollback() calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e)) log.error_or_exception("Database error: {}".format(e))
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
category="error") category="error")
return False # return redirect(url_for('web.show_book', book_id=book.id)) error = True
continue
# Queue uploader info # Queue uploader info
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title)) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link) upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title))) WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
meta = uploader.process(
return uploader.process( saved_filename,
saved_filename, *os.path.splitext(requested_file.filename), *os.path.splitext(current_filename),
rar_executable=config.config_rarfile_location) rar_executable=config.config_rarfile_location,
return None no_cover=no_cover)
merge_metadata(book, meta, to_save)
#if to_save.get('languages'):
# langs = []
# for lang_code in to_save['languages'].split(','):
# langs.append(isoLanguages.get_language_name(get_locale(), lang_code))
# to_save['languages'] = ",".join(langs)
return to_save, error
def upload_cover(cover_request, book): def upload_cover(cover_request, book):
@@ -1405,7 +1435,7 @@ def upload_cover(cover_request, book):
def handle_title_on_edit(book, book_title): def handle_title_on_edit(book, book_title):
# handle book title # handle book title
book_title = book_title.rstrip().strip() book_title = strip_whitespaces(book_title)
if book.title != book_title: if book.title != book_title:
if book_title == '': if book_title == '':
book_title = _(u'Unknown') book_title = _(u'Unknown')
@@ -1416,7 +1446,6 @@ def handle_title_on_edit(book, book_title):
def handle_author_on_edit(book, author_name, update_stored=True): def handle_author_on_edit(book, author_name, update_stored=True):
change = False change = False
# handle author(s)
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive) input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
# Search for each author if author is in database, if not, author name and sorted author name is generated new # Search for each author if author is in database, if not, author name and sorted author name is generated new
@@ -1446,7 +1475,6 @@ def search_objects_remove(db_book_object, db_type, input_elements):
if db_type == 'custom': if db_type == 'custom':
type_elements = c_elements.value type_elements = c_elements.value
else: else:
# type_elements = c_elements.name
type_elements = c_elements type_elements = c_elements
for inp_element in input_elements: for inp_element in input_elements:
if type_elements == inp_element: if type_elements == inp_element:

View File

@@ -35,9 +35,7 @@ def do_calibre_export(book_id, book_format):
my_env = os.environ.copy() my_env = os.environ.copy()
if config.config_calibre_split: if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.config_calibre_split_dir library_path = config.get_book_path()
else:
library_path = config.config_calibre_dir
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path, opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name), '--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
str(book_id)] str(book_id)]

View File

@@ -25,6 +25,7 @@ from . import config, logger
from .helper import split_authors from .helper import split_authors
from .epub_helper import get_content_opf, default_ns from .epub_helper import get_content_opf, default_ns
from .constants import BookMeta from .constants import BookMeta
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
@@ -65,7 +66,7 @@ def get_epub_layout(book, book_data):
return layout[0] return layout[0]
def get_epub_info(tmp_file_path, original_file_name, original_file_extension): def get_epub_info(tmp_file_path, original_file_name, original_file_extension, no_cover_processing):
ns = { ns = {
'n': 'urn:oasis:names:tc:opendocument:xmlns:container', 'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
'pkg': 'http://www.idpf.org/2007/opf', 'pkg': 'http://www.idpf.org/2007/opf',
@@ -90,7 +91,7 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
elif s == 'date': elif s == 'date':
epub_metadata[s] = tmp[0][:10] epub_metadata[s] = tmp[0][:10]
else: else:
epub_metadata[s] = tmp[0].strip() epub_metadata[s] = strip_whitespaces(tmp[0])
else: else:
epub_metadata[s] = 'Unknown' epub_metadata[s] = 'Unknown'
@@ -116,7 +117,10 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
epub_metadata = parse_epub_series(ns, tree, epub_metadata) epub_metadata = parse_epub_series(ns, tree, epub_metadata)
epub_zip = zipfile.ZipFile(tmp_file_path) epub_zip = zipfile.ZipFile(tmp_file_path)
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path) if not no_cover_processing:
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
else:
cover_file = None
identifiers = [] identifiers = []
for node in p.xpath('dc:identifier', namespaces=ns): for node in p.xpath('dc:identifier', namespaces=ns):

View File

@@ -25,7 +25,7 @@ import re
import regex import regex
import shutil import shutil
import socket import socket
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import requests import requests
import unidecode import unidecode
from uuid import uuid4 from uuid import uuid4
@@ -52,6 +52,7 @@ except ImportError:
UnacceptableAddressException = MissingSchema = BaseException UnacceptableAddressException = MissingSchema = BaseException
from . import calibre_db, cli_param from . import calibre_db, cli_param
from .string_helper import strip_whitespaces
from .tasks.convert import TaskConvert from .tasks.convert import TaskConvert
from . import logger, config, db, ub, fs from . import logger, config, db, ub, fs
from . import gdriveutils as gd from . import gdriveutils as gd
@@ -118,7 +119,7 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
# Texts are not lazy translated as they are supposed to get send out as is # Texts are not lazy translated as they are supposed to get send out as is
def send_test_mail(ereader_mail, user_name): def send_test_mail(ereader_mail, user_name):
for email in ereader_mail.split(','): for email in ereader_mail.split(','):
email = email.strip() email = strip_whitespaces(email)
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None, WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
config.get_mail_settings(), email, N_("Test Email"), config.get_mail_settings(), email, N_("Test Email"),
_('This Email has been sent via Calibre-Web.'))) _('This Email has been sent via Calibre-Web.')))
@@ -228,7 +229,7 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title)) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
email_text = N_("%(book)s send to eReader", book=link) email_text = N_("%(book)s send to eReader", book=link)
for email in ereader_mail.split(','): for email in ereader_mail.split(','):
email = email.strip() email = strip_whitespaces(email)
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name, WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), email, config.get_mail_settings(), email,
email_text, _('This Email has been sent via Calibre-Web.'), book.id)) email_text, _('This Email has been sent via Calibre-Web.'), book.id))
@@ -252,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
# pipe has to be replaced with comma # pipe has to be replaced with comma
value = re.sub(r'[|]+', ',', value, flags=re.U) value = re.sub(r'[|]+', ',', value, flags=re.U)
value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip() value = strip_whitespaces(value.encode('utf-8')[:chars].decode('utf-8', errors='ignore'))
if not value: if not value:
raise ValueError("Filename cannot be empty") raise ValueError("Filename cannot be empty")
@@ -267,11 +268,11 @@ def split_authors(values):
commas = author.count(',') commas = author.count(',')
if commas == 1: if commas == 1:
author_split = author.split(',') author_split = author.split(',')
authors_list.append(author_split[1].strip() + ' ' + author_split[0].strip()) authors_list.append(strip_whitespaces(author_split[1]) + ' ' + strip_whitespaces(author_split[0]))
elif commas > 1: elif commas > 1:
authors_list.extend([x.strip() for x in author.split(',')]) authors_list.extend([strip_whitespaces(x) for x in author.split(',')])
else: else:
authors_list.append(author.strip()) authors_list.append(strip_whitespaces(author))
return authors_list return authors_list
@@ -327,7 +328,7 @@ def edit_book_read_status(book_id, read_status=None):
ub.session_commit("Book {} readbit toggled".format(book_id)) ub.session_commit("Book {} readbit toggled".format(book_id))
else: else:
try: try:
calibre_db.update_title_sort(config) calibre_db.create_functions(config)
book = calibre_db.get_filtered_book(book_id) book = calibre_db.get_filtered_book(book_id)
book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column)) book_read_status = getattr(book, 'custom_column_' + str(config.config_read_column))
if len(book_read_status): if len(book_read_status):
@@ -661,7 +662,7 @@ def check_email(email):
def check_username(username): def check_username(username):
username = username.strip() username = strip_whitespaces(username)
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar(): if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error("This username is already taken") log.error("This username is already taken")
raise Exception(_("This username is already taken")) raise Exception(_("This username is already taken"))
@@ -669,16 +670,18 @@ def check_username(username):
def valid_email(emails): def valid_email(emails):
valid_emails = []
for email in emails.split(','): for email in emails.split(','):
email = email.strip() email = strip_whitespaces(email)
# if email is not deleted # if email is not deleted
if email: if email:
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation # Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$", if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email): email):
log.error("Invalid Email address format") log.error("Invalid Email address format for {}".format(email))
raise Exception(_("Invalid Email address format")) raise Exception(_("Invalid Email address format"))
return email valid_emails.append(email)
return ",".join(valid_emails)
def valid_password(check_password): def valid_password(check_password):
@@ -788,24 +791,23 @@ def get_book_cover_internal(book, resolution=None):
def get_book_cover_thumbnail(book, resolution): def get_book_cover_thumbnail(book, resolution):
if book and book.has_cover: if book and book.has_cover:
return ub.session \ return (ub.session
.query(ub.Thumbnail) \ .query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \ .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER)
.filter(ub.Thumbnail.entity_id == book.id) \ .filter(ub.Thumbnail.entity_id == book.id)
.filter(ub.Thumbnail.resolution == resolution) \ .filter(ub.Thumbnail.resolution == resolution)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.first() .first())
def get_series_thumbnail_on_failure(series_id, resolution): def get_series_thumbnail_on_failure(series_id, resolution):
book = calibre_db.session \ book = (calibre_db.session
.query(db.Books) \ .query(db.Books)
.join(db.books_series_link) \ .join(db.books_series_link)
.join(db.Series) \ .join(db.Series)
.filter(db.Series.id == series_id) \ .filter(db.Series.id == series_id)
.filter(db.Books.has_cover == 1) \ .filter(db.Books.has_cover == 1)
.first() .first())
return get_book_cover_internal(book, resolution=resolution) return get_book_cover_internal(book, resolution=resolution)
@@ -827,13 +829,13 @@ def get_series_cover_internal(series_id, resolution=None):
def get_series_thumbnail(series_id, resolution): def get_series_thumbnail(series_id, resolution):
return ub.session \ return (ub.session
.query(ub.Thumbnail) \ .query(ub.Thumbnail)
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \ .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES)
.filter(ub.Thumbnail.entity_id == series_id) \ .filter(ub.Thumbnail.entity_id == series_id)
.filter(ub.Thumbnail.resolution == resolution) \ .filter(ub.Thumbnail.resolution == resolution)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.first() .first())
# saves book cover from url # saves book cover from url

View File

@@ -15,24 +15,30 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
from . import logger from . import logger
from .string_helper import strip_whitespaces
log = logger.create() log = logger.create()
try: try:
from iso639 import languages, __version__ from iso639 import languages
# iso_version = importlib.metadata.version("iso639")
get = languages.get get = languages.get
except ImportError:
from pycountry import languages as pyc_languages
try: try:
import pkg_resources if sys.version_info >= (3, 12):
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)' import pkg_resources
del pkg_resources except ImportError:
except (ImportError, Exception): print("Python 3.12 isn't compatible with iso-639. Please install pycountry.")
__version__ = "? (PyCountry)" except ImportError as ex:
from pycountry import languages as pyc_languages
#try:
# iso_version = importlib.metadata.version("pycountry") + ' (PyCountry)'
#except (ImportError, Exception):
# iso_version = "?" + ' (PyCountry)'
def _copy_fields(l): def _copy_fields(l):
l.part1 = getattr(l, 'alpha_2', None) l.part1 = getattr(l, 'alpha_2', None)
@@ -69,20 +75,20 @@ def get_language_name(locale, lang_code):
return name return name
def get_language_codes(locale, language_names, remainder=None): def get_language_code_from_name(locale, language_names, remainder=None):
language_names = set(x.strip().lower() for x in language_names if x) language_names = set(strip_whitespaces(x).lower() for x in language_names if x)
lang = list() lang = list()
for k, v in get_language_names(locale).items(): for key, val in get_language_names(locale).items():
v = v.lower() val = val.lower()
if v in language_names: if val in language_names:
lang.append(k) lang.append(key)
language_names.remove(v) language_names.remove(val)
if remainder is not None and language_names: if remainder is not None and language_names:
remainder.extend(language_names) remainder.extend(language_names)
return lang return lang
def get_valid_language_codes(locale, language_names, remainder=None): def get_valid_language_codes_from_code(locale, language_names, remainder=None):
lang = list() lang = list()
if "" in language_names: if "" in language_names:
language_names.remove("") language_names.remove("")

View File

@@ -27,7 +27,7 @@ import datetime
import mimetypes import mimetypes
from uuid import uuid4 from uuid import uuid4
from flask import Blueprint, request, url_for from flask import Blueprint, request, url_for, g
from flask_babel import format_date from flask_babel import format_date
from .cw_login import current_user from .cw_login import current_user
@@ -112,7 +112,10 @@ def yesno(value, yes, no):
@jinjia.app_template_filter('formatfloat') @jinjia.app_template_filter('formatfloat')
def formatfloat(value, decimals=1): def formatfloat(value, decimals=1):
value = 0 if not value else value value = 0 if not value else value
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.') formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
if formated_value.endswith('.' + "0" * decimals):
formated_value = formated_value.rstrip('0').rstrip('.')
return formated_value
@jinjia.app_template_filter('formatseriesindex') @jinjia.app_template_filter('formatseriesindex')
@@ -179,3 +182,12 @@ def get_cover_srcset(series):
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp()) url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
srcset.append(f'{url} {resolution}x') srcset.append(f'{url} {resolution}x')
return ', '.join(srcset) return ', '.join(srcset)
@jinjia.app_template_filter('music')
def contains_music(book_formats):
result = False
for format in book_formats:
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
result = True
return result

View File

@@ -18,7 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64 import base64
import datetime from datetime import datetime, timezone
import os import os
import uuid import uuid
import zipfile import zipfile
@@ -47,7 +47,7 @@ import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages from . import isoLanguages
from .epub import get_epub_layout from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
from .helper import get_download_link from .helper import get_download_link
from .services import SyncToken as SyncToken from .services import SyncToken as SyncToken
from .web import download_required from .web import download_required
@@ -131,7 +131,7 @@ def convert_to_kobo_timestamp_string(timestamp):
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
except AttributeError as exc: except AttributeError as exc:
log.debug("Timestamp not valid: {}".format(exc)) log.debug("Timestamp not valid: {}".format(exc))
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@kobo.route("/v1/library/sync") @kobo.route("/v1/library/sync")
@@ -150,15 +150,15 @@ def HandleSyncRequest():
# if no books synced don't respect sync_token # if no books synced don't respect sync_token
if not ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == current_user.id).count(): if not ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == current_user.id).count():
sync_token.books_last_modified = datetime.datetime.min sync_token.books_last_modified = datetime.min
sync_token.books_last_created = datetime.datetime.min sync_token.books_last_created = datetime.min
sync_token.reading_state_last_modified = datetime.datetime.min sync_token.reading_state_last_modified = datetime.min
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
new_reading_state_last_modified = sync_token.reading_state_last_modified new_reading_state_last_modified = sync_token.reading_state_last_modified
new_archived_last_modified = datetime.datetime.min new_archived_last_modified = datetime.min
sync_results = [] sync_results = []
# We reload the book database so that the user gets a fresh view of the library # We reload the book database so that the user gets a fresh view of the library
@@ -375,7 +375,7 @@ def create_book_entitlement(book, archived):
book_uuid = str(book.uuid) book_uuid = str(book.uuid)
return { return {
"Accessibility": "Full", "Accessibility": "Full",
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.utcnow())}, "ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.now(timezone.utc))},
"Created": convert_to_kobo_timestamp_string(book.timestamp), "Created": convert_to_kobo_timestamp_string(book.timestamp),
"CrossRevisionId": book_uuid, "CrossRevisionId": book_uuid,
"Id": book_uuid, "Id": book_uuid,
@@ -795,7 +795,7 @@ def HandleStateRequest(book_uuid):
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \ if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
and new_book_read_status != book_read.read_status: and new_book_read_status != book_read.read_status:
book_read.times_started_reading += 1 book_read.times_started_reading += 1
book_read.last_time_started_reading = datetime.datetime.utcnow() book_read.last_time_started_reading = datetime.now(timezone.utc)
book_read.read_status = new_book_read_status book_read.read_status = new_book_read_status
update_results_response["StatusInfoResult"] = {"Result": "Success"} update_results_response["StatusInfoResult"] = {"Result": "Success"}
except (KeyError, TypeError, ValueError, StatementError): except (KeyError, TypeError, ValueError, StatementError):
@@ -903,7 +903,12 @@ def get_current_bookmark_response(current_bookmark):
@requires_kobo_auth @requires_kobo_auth
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale): def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
try: try:
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL if int(height) > 1000:
resolution = COVER_THUMBNAIL_LARGE
elif int(height) > 500:
resolution = COVER_THUMBNAIL_MEDIUM
else:
resolution = COVER_THUMBNAIL_SMALL
except ValueError: except ValueError:
log.error("Requested height %s of book %s is invalid" % (book_uuid, height)) log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
resolution = COVER_THUMBNAIL_SMALL resolution = COVER_THUMBNAIL_SMALL

View File

@@ -19,7 +19,7 @@
from .cw_login import current_user from .cw_login import current_user
from . import ub from . import ub
import datetime from datetime import datetime, timezone
from sqlalchemy.sql.expression import or_, and_, true from sqlalchemy.sql.expression import or_, and_, true
# from sqlalchemy import exc # from sqlalchemy import exc
@@ -60,7 +60,7 @@ def change_archived_books(book_id, state=None, message=None):
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.is_archived = state if state != None else not archived_book.is_archived archived_book.is_archived = state if state != None else not archived_book.is_archived
archived_book.last_modified = datetime.datetime.utcnow() # toDo. Check utc timestamp archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp
ub.session.merge(archived_book) ub.session.merge(archived_book)
ub.session_commit(message) ub.session_commit(message)

View File

@@ -53,7 +53,6 @@ class Amazon(Metadata):
def search( def search(
self, query: str, generic_cover: str = "", locale: str = "en" self, query: str, generic_cover: str = "", locale: str = "en"
) -> Optional[List[MetaRecord]]: ) -> Optional[List[MetaRecord]]:
#timer=time()
def inner(link, index) -> [dict, int]: def inner(link, index) -> [dict, int]:
with self.session as session: with self.session as session:
try: try:
@@ -61,11 +60,11 @@ class Amazon(Metadata):
r.raise_for_status() r.raise_for_status()
except Exception as ex: except Exception as ex:
log.warning(ex) log.warning(ex)
return None return []
long_soup = BS(r.text, "lxml") #~4sec :/ long_soup = BS(r.text, "lxml") #~4sec :/
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"}) soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
if soup2 is None: if soup2 is None:
return None return []
try: try:
match = MetaRecord( match = MetaRecord(
title = "", title = "",
@@ -88,7 +87,7 @@ class Amazon(Metadata):
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\ soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
.replace("\xa0"," ")[:-9].strip().strip("\n") .replace("\xa0"," ")[:-9].strip().strip("\n")
except (AttributeError, TypeError): except (AttributeError, TypeError):
return None # if there is no description it is not a book and therefore should be ignored return [] # if there is no description it is not a book and therefore should be ignored
try: try:
match.title = soup2.find("span", attrs={"id": "productTitle"}).text match.title = soup2.find("span", attrs={"id": "productTitle"}).text
except (AttributeError, TypeError): except (AttributeError, TypeError):
@@ -113,7 +112,7 @@ class Amazon(Metadata):
return match, index return match, index
except Exception as e: except Exception as e:
log.error_or_exception(e) log.error_or_exception(e)
return None return []
val = list() val = list()
if self.active: if self.active:
@@ -134,6 +133,6 @@ class Amazon(Metadata):
soup.findAll("div", attrs={"data-component-type": "s-search-result"})] soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])} fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut))) val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
result = list(filter(lambda x: x, val)) result = list(filter(lambda x: x, val))
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance

View File

@@ -54,7 +54,7 @@ class Google(Metadata):
results.raise_for_status() results.raise_for_status()
except Exception as e: except Exception as e:
log.warning(e) log.warning(e)
return None return []
for result in results.json().get("items", []): for result in results.json().get("items", []):
val.append( val.append(
self._parse_search_result( self._parse_search_result(

0
cps/redirect.py Executable file → Normal file
View File

View File

@@ -26,6 +26,7 @@ from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
from sqlalchemy.sql.functions import coalesce from sqlalchemy.sql.functions import coalesce
from . import logger, db, calibre_db, config, ub from . import logger, db, calibre_db, config, ub
from .string_helper import strip_whitespaces
from .usermanagement import login_required_if_no_ano from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template from .render_template import render_title_template
from .pagination import Pagination from .pagination import Pagination
@@ -81,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
if custom_end: if custom_end:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end))) func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
elif c.datatype in ["int", "float"]:
custom_low = term.get('custom_column_' + str(c.id) + '_low')
custom_high = term.get('custom_column_' + str(c.id) + '_high')
if custom_low:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value >= custom_low))
if custom_high:
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value <= custom_high))
else: else:
custom_query = term.get('custom_column_' + str(c.id)) custom_query = term.get('custom_column_' + str(c.id))
if custom_query != '' and custom_query is not None: if c.datatype == 'bool':
if c.datatype == 'bool': if custom_query != "Any":
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( if custom_query == "":
db.cc_classes[c.id].value == (custom_query == "True"))) q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
elif c.datatype == 'int' or c.datatype == 'float': any(db.cc_classes[c.id].value >= 0))
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( else:
db.cc_classes[c.id].value == custom_query)) q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
elif c.datatype == 'rating': db.cc_classes[c.id].value == bool(custom_query == "True")))
elif custom_query != '' and custom_query is not None:
if c.datatype == 'rating':
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
db.cc_classes[c.id].value == int(float(custom_query) * 2))) db.cc_classes[c.id].value == int(float(custom_query) * 2)))
else: else:
@@ -129,10 +141,10 @@ def adv_search_read_status(read_status):
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
else: else:
try: try:
if read_status == "True": if read_status == "":
db_filter = db.cc_classes[config.config_read_column].value == True db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
else: else:
db_filter = coalesce(db.cc_classes[config.config_read_column].value, False) != True db_filter = db.cc_classes[config.config_read_column].value == bool(read_status == "True")
except (KeyError, AttributeError, IndexError): except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column)) log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
flash(_("Custom Column No.%(column)d does not exist in calibre database", flash(_("Custom Column No.%(column)d does not exist in calibre database",
@@ -232,7 +244,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
pagination = None pagination = None
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase) calibre_db.create_functions()
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books) query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\ q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series)\ .outerjoin(db.Series)\
@@ -245,8 +258,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
tags['include_' + element] = term.get('include_' + element) tags['include_' + element] = term.get('include_' + element)
tags['exclude_' + element] = term.get('exclude_' + element) tags['exclude_' + element] = term.get('exclude_' + element)
author_name = term.get("author_name") author_name = term.get("authors")
book_title = term.get("book_title") book_title = term.get("title")
publisher = term.get("publisher") publisher = term.get("publisher")
pub_start = term.get("publishstart") pub_start = term.get("publishstart")
pub_end = term.get("publishend") pub_end = term.get("publishend")
@@ -255,11 +268,11 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
description = term.get("comment") description = term.get("comment")
read_status = term.get("read_status") read_status = term.get("read_status")
if author_name: if author_name:
author_name = author_name.strip().lower().replace(',', '|') author_name = strip_whitespaces(author_name).lower().replace(',', '|')
if book_title: if book_title:
book_title = book_title.strip().lower() book_title = strip_whitespaces(book_title).lower()
if publisher: if publisher:
publisher = publisher.strip().lower() publisher = strip_whitespaces(publisher).lower()
search_term = [] search_term = []
cc_present = False cc_present = False
@@ -275,10 +288,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
cc_present = True cc_present = True
if column_end: if column_end:
search_term.extend(["{} <= {}".format(c.name, search_term.extend(["{} <= {}".format(c.name,
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(), format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
format='medium') format='medium')
)]) )])
cc_present = True cc_present = True
if c.datatype in ["int", "float"]:
column_low = term.get('custom_column_' + str(c.id) + '_low')
column_high = term.get('custom_column_' + str(c.id) + '_high')
if column_low:
search_term.extend(["{} >= {}".format(c.name, column_low)])
cc_present = True
if column_high:
search_term.extend(["{} <= {}".format(c.name,column_high)])
cc_present = True
elif c.datatype == "bool":
if term.get('custom_column_' + str(c.id)) != "Any":
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True
elif term.get('custom_column_' + str(c.id)): elif term.get('custom_column_' + str(c.id)):
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))]) search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
cc_present = True cc_present = True

View File

@@ -21,7 +21,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from flask import Blueprint, flash, redirect, request, url_for, abort from flask import Blueprint, flash, redirect, request, url_for, abort
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -80,7 +80,7 @@ def add_to_shelf(shelf_id, book_id):
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400 return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1)) shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
shelf.last_modified = datetime.utcnow() shelf.last_modified = datetime.now(timezone.utc)
try: try:
ub.session.merge(shelf) ub.session.merge(shelf)
ub.session.commit() ub.session.commit()
@@ -139,7 +139,7 @@ def search_to_shelf(shelf_id):
for book in books_for_shelf: for book in books_for_shelf:
maxOrder += 1 maxOrder += 1
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder)) shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
shelf.last_modified = datetime.utcnow() shelf.last_modified = datetime.now(timezone.utc)
try: try:
ub.session.merge(shelf) ub.session.merge(shelf)
ub.session.commit() ub.session.commit()
@@ -185,7 +185,7 @@ def remove_from_shelf(shelf_id, book_id):
try: try:
ub.session.delete(book_shelf) ub.session.delete(book_shelf)
shelf.last_modified = datetime.utcnow() shelf.last_modified = datetime.now(timezone.utc)
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
ub.session.rollback() ub.session.rollback()
@@ -250,7 +250,7 @@ def show_simpleshelf(shelf_id):
return render_show_shelf(2, shelf_id, 1, None) return render_show_shelf(2, shelf_id, 1, None)
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "order", 'page': 1}) @shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "stored", 'page': 1})
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1}) @shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>") @shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
@login_required_if_no_ano @login_required_if_no_ano
@@ -271,7 +271,7 @@ def order_shelf(shelf_id):
for book in books_in_shelf: for book in books_in_shelf:
setattr(book, 'order', to_save[str(book.book_id)]) setattr(book, 'order', to_save[str(book.book_id)])
counter += 1 counter += 1
# if order different from before -> shelf.last_modified = datetime.utcnow() # if order different from before -> shelf.last_modified = datetime.now(timezone.utc)
try: try:
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as e:
@@ -418,29 +418,37 @@ def change_shelf_order(shelf_id, order):
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param): def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
status = current_user.get_view_property("shelf", 'man')
# check user is allowed to access shelf # check user is allowed to access shelf
if shelf and check_shelf_view_permissions(shelf): if shelf and check_shelf_view_permissions(shelf):
if shelf_type == 1: if shelf_type == 1:
# order = [ub.BookShelf.order.asc()] if status != 'on':
if sort_param == 'pubnew': if sort_param == 'stored':
change_shelf_order(shelf_id, [db.Books.pubdate.desc()]) sort_param = current_user.get_view_property("shelf", 'stored')
if sort_param == 'pubold': else:
change_shelf_order(shelf_id, [db.Books.pubdate]) current_user.set_view_property("shelf", 'stored', sort_param)
if sort_param == 'abc': if sort_param == 'pubnew':
change_shelf_order(shelf_id, [db.Books.sort]) change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
if sort_param == 'zyx': if sort_param == 'pubold':
change_shelf_order(shelf_id, [db.Books.sort.desc()]) change_shelf_order(shelf_id, [db.Books.pubdate])
if sort_param == 'new': if sort_param == 'shelfnew':
change_shelf_order(shelf_id, [db.Books.timestamp.desc()]) change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
if sort_param == 'old': if sort_param == 'shelfold':
change_shelf_order(shelf_id, [db.Books.timestamp]) change_shelf_order(shelf_id, [ub.BookShelf.date_added])
if sort_param == 'authaz': if sort_param == 'abc':
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index]) change_shelf_order(shelf_id, [db.Books.sort])
if sort_param == 'authza': if sort_param == 'zyx':
change_shelf_order(shelf_id, [db.Books.author_sort.desc(), change_shelf_order(shelf_id, [db.Books.sort.desc()])
db.Series.name.desc(), if sort_param == 'new':
db.Books.series_index.desc()]) change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
if sort_param == 'old':
change_shelf_order(shelf_id, [db.Books.timestamp])
if sort_param == 'authaz':
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
if sort_param == 'authza':
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
db.Series.name.desc(),
db.Books.series_index.desc()])
page = "shelf.html" page = "shelf.html"
pagesize = 0 pagesize = 0
else: else:
@@ -453,7 +461,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
[ub.BookShelf.order.asc()], [ub.BookShelf.order.asc()],
True, config.config_read_column, True, config.config_read_column,
ub.BookShelf, ub.BookShelf.book_id == db.Books.id) ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web # delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
wrong_entries = calibre_db.session.query(ub.BookShelf) \ wrong_entries = calibre_db.session.query(ub.BookShelf) \
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \ .join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
.filter(db.Books.id == None).all() .filter(db.Books.id == None).all()
@@ -472,7 +480,9 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
pagination=pagination, pagination=pagination,
title=_("Shelf: '%(name)s'", name=shelf.name), title=_("Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, shelf=shelf,
page="shelf") page="shelf",
status=status,
order=sort_param)
else: else:
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error") flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))

View File

@@ -3,9 +3,9 @@
*/ */
/* global Bloodhound, language, Modernizr, tinymce, getPath */ /* global Bloodhound, language, Modernizr, tinymce, getPath */
if ($("#description").length) { if ($("#comments").length) {
tinymce.init({ tinymce.init({
selector: "#description", selector: "#comments",
plugins: 'code', plugins: 'code',
branding: false, branding: false,
menubar: "edit view format", menubar: "edit view format",
@@ -93,7 +93,7 @@ var authors = new Bloodhound({
}, },
}); });
$(".form-group #bookAuthor").typeahead( $(".form-group #authors").typeahead(
{ {
highlight: true, highlight: true,
minLength: 1, minLength: 1,
@@ -243,13 +243,13 @@ $("#search").on("change input.typeahead:selected", function(event) {
}); });
}); });
$("#btn-upload-format").on("change", function () { /*$("#btn-upload-format").on("change", function () {
var filename = $(this).val(); var filename = $(this).val();
if (filename.substring(3, 11) === "fakepath") { if (filename.substring(3, 11) === "fakepath") {
filename = filename.substring(12); filename = filename.substring(12);
} // Remove c:\fake at beginning from localhost chrome } // Remove c:\fake at beginning from localhost chrome
$("#upload-format").text(filename); $("#upload-format").text(filename);
}); });*/
$("#btn-upload-cover").on("change", function () { $("#btn-upload-cover").on("change", function () {
var filename = $(this).val(); var filename = $(this).val();
@@ -261,8 +261,8 @@ $("#btn-upload-cover").on("change", function () {
$("#xchange").click(function () { $("#xchange").click(function () {
this.blur(); this.blur();
var title = $("#book_title").val(); var title = $("#title").val();
$("#book_title").val($("#bookAuthor").val()); $("#title").val($("#authors").val());
$("#bookAuthor").val(title); $("#authors").val(title);
}); });

View File

@@ -38,12 +38,12 @@ $(function () {
} }
function populateForm (book) { function populateForm (book) {
tinymce.get("description").setContent(book.description); tinymce.get("comments").setContent(book.description);
var uniqueTags = getUniqueValues('tags', book) var uniqueTags = getUniqueValues('tags', book)
var uniqueLanguages = getUniqueValues('languages', book) var uniqueLanguages = getUniqueValues('languages', book)
var ampSeparatedAuthors = (book.authors || []).join(" & "); var ampSeparatedAuthors = (book.authors || []).join(" & ");
$("#bookAuthor").val(ampSeparatedAuthors); $("#authors").val(ampSeparatedAuthors);
$("#book_title").val(book.title); $("#title").val(book.title);
$("#tags").val(uniqueTags.join(", ")); $("#tags").val(uniqueTags.join(", "));
$("#languages").val(uniqueLanguages.join(", ")); $("#languages").val(uniqueLanguages.join(", "));
$("#rating").data("rating").setValue(Math.round(book.rating)); $("#rating").data("rating").setValue(Math.round(book.rating));
@@ -172,7 +172,7 @@ $(function () {
$("#get_meta").click(function () { $("#get_meta").click(function () {
populate_provider(); populate_provider();
var bookTitle = $("#book_title").val(); var bookTitle = $("#title").val();
$("#keyword").val(bookTitle); $("#keyword").val(bookTitle);
keyword = bookTitle; keyword = bookTitle;
doSearch(bookTitle); doSearch(bookTitle);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
* @licstart The following is the entire license notice for the * @licstart The following is the entire license notice for the
* JavaScript code in this page * JavaScript code in this page
* *
* Copyright 2023 Mozilla Foundation * Copyright 2024 Mozilla Foundation
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -446,7 +446,7 @@ class ProgressBar {
} }
} }
setDisableAutoFetch(delay = 5000) { setDisableAutoFetch(delay = 5000) {
if (isNaN(this.#percent)) { if (this.#percent === 100 || isNaN(this.#percent)) {
return; return;
} }
if (this.#disableAutoFetchTimeout) { if (this.#disableAutoFetchTimeout) {
@@ -535,15 +535,20 @@ function toggleExpandedBtn(button, toggle, view = null) {
;// CONCATENATED MODULE: ./web/app_options.js ;// CONCATENATED MODULE: ./web/app_options.js
{ {
var compatibilityParams = Object.create(null); var compatParams = new Map();
const userAgent = navigator.userAgent || ""; const userAgent = navigator.userAgent || "";
const platform = navigator.platform || ""; const platform = navigator.platform || "";
const maxTouchPoints = navigator.maxTouchPoints || 1; const maxTouchPoints = navigator.maxTouchPoints || 1;
const isAndroid = /Android/.test(userAgent); const isAndroid = /Android/.test(userAgent);
const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent) || platform === "MacIntel" && maxTouchPoints > 1; const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent) || platform === "MacIntel" && maxTouchPoints > 1;
(function checkCanvasSizeLimitation() { (function () {
if (isIOS || isAndroid) { if (isIOS || isAndroid) {
compatibilityParams.maxCanvasPixels = 5242880; compatParams.set("maxCanvasPixels", 5242880);
}
})();
(function () {
if (isAndroid) {
compatParams.set("useSystemFonts", false);
} }
})(); })();
} }
@@ -552,9 +557,21 @@ const OptionKind = {
VIEWER: 0x02, VIEWER: 0x02,
API: 0x04, API: 0x04,
WORKER: 0x08, WORKER: 0x08,
EVENT_DISPATCH: 0x10,
PREFERENCE: 0x80 PREFERENCE: 0x80
}; };
const Type = {
BOOLEAN: 0x01,
NUMBER: 0x02,
OBJECT: 0x04,
STRING: 0x08,
UNDEFINED: 0x10
};
const defaultOptions = { const defaultOptions = {
allowedGlobalEvents: {
value: null,
kind: OptionKind.BROWSER
},
canvasMaxAreaInBytes: { canvasMaxAreaInBytes: {
value: -1, value: -1,
kind: OptionKind.BROWSER + OptionKind.API kind: OptionKind.BROWSER + OptionKind.API
@@ -563,6 +580,16 @@ const defaultOptions = {
value: false, value: false,
kind: OptionKind.BROWSER kind: OptionKind.BROWSER
}, },
localeProperties: {
value: {
lang: navigator.language || "en-US"
},
kind: OptionKind.BROWSER
},
nimbusDataStr: {
value: "",
kind: OptionKind.BROWSER
},
supportsCaretBrowsingMode: { supportsCaretBrowsingMode: {
value: false, value: false,
kind: OptionKind.BROWSER kind: OptionKind.BROWSER
@@ -587,6 +614,14 @@ const defaultOptions = {
value: true, value: true,
kind: OptionKind.BROWSER kind: OptionKind.BROWSER
}, },
toolbarDensity: {
value: 0,
kind: OptionKind.BROWSER + OptionKind.EVENT_DISPATCH
},
altTextLearnMoreUrl: {
value: "",
kind: OptionKind.VIEWER + OptionKind.PREFERENCE
},
annotationEditorMode: { annotationEditorMode: {
value: 0, value: 0,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
@@ -619,6 +654,14 @@ const defaultOptions = {
value: false, value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
}, },
enableAltText: {
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE
},
enableGuessAltText: {
value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE
},
enableHighlightEditor: { enableHighlightEditor: {
value: false, value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
@@ -627,10 +670,6 @@ const defaultOptions = {
value: false, value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
}, },
enableML: {
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE
},
enablePermissions: { enablePermissions: {
value: false, value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
@@ -643,8 +682,8 @@ const defaultOptions = {
value: true, value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
}, },
enableStampEditor: { enableUpdatedAddImage: {
value: true, value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE kind: OptionKind.VIEWER + OptionKind.PREFERENCE
}, },
externalLinkRel: { externalLinkRel: {
@@ -775,6 +814,11 @@ const defaultOptions = {
value: "../web/standard_fonts/", value: "../web/standard_fonts/",
kind: OptionKind.API kind: OptionKind.API
}, },
useSystemFonts: {
value: undefined,
kind: OptionKind.API,
type: Type.BOOLEAN + Type.UNDEFINED
},
verbosity: { verbosity: {
value: 1, value: 1,
kind: OptionKind.API kind: OptionKind.API
@@ -807,62 +851,80 @@ const defaultOptions = {
value: false, value: false,
kind: OptionKind.VIEWER kind: OptionKind.VIEWER
}; };
defaultOptions.locale = {
value: navigator.language || "en-US",
kind: OptionKind.VIEWER
};
} }
const userOptions = Object.create(null); const userOptions = new Map();
{ {
for (const name in compatibilityParams) { for (const [name, value] of compatParams) {
userOptions[name] = compatibilityParams[name]; userOptions.set(name, value);
} }
} }
class AppOptions { class AppOptions {
static eventBus;
constructor() { constructor() {
throw new Error("Cannot initialize AppOptions."); throw new Error("Cannot initialize AppOptions.");
} }
static get(name) { static get(name) {
return userOptions[name] ?? defaultOptions[name]?.value ?? undefined; return userOptions.has(name) ? userOptions.get(name) : defaultOptions[name]?.value;
} }
static getAll(kind = null, defaultOnly = false) { static getAll(kind = null, defaultOnly = false) {
const options = Object.create(null); const options = Object.create(null);
for (const name in defaultOptions) { for (const name in defaultOptions) {
const defaultOption = defaultOptions[name]; const defaultOpt = defaultOptions[name];
if (kind && !(kind & defaultOption.kind)) { if (kind && !(kind & defaultOpt.kind)) {
continue; continue;
} }
options[name] = defaultOnly ? defaultOption.value : userOptions[name] ?? defaultOption.value; options[name] = !defaultOnly && userOptions.has(name) ? userOptions.get(name) : defaultOpt.value;
} }
return options; return options;
} }
static set(name, value) { static set(name, value) {
userOptions[name] = value; this.setAll({
[name]: value
});
} }
static setAll(options, init = false) { static setAll(options, prefs = false) {
if (init) { let events;
if (this.get("disablePreferences")) {
return;
}
for (const name in userOptions) {
if (compatibilityParams[name] !== undefined) {
continue;
}
console.warn("setAll: The Preferences may override manually set AppOptions; " + 'please use the "disablePreferences"-option in order to prevent that.');
break;
}
}
for (const name in options) { for (const name in options) {
userOptions[name] = options[name]; const defaultOpt = defaultOptions[name],
userOpt = options[name];
if (!defaultOpt || !(typeof userOpt === typeof defaultOpt.value || Type[(typeof userOpt).toUpperCase()] & defaultOpt.type)) {
continue;
}
const {
kind
} = defaultOpt;
if (prefs && !(kind & OptionKind.BROWSER || kind & OptionKind.PREFERENCE)) {
continue;
}
if (this.eventBus && kind & OptionKind.EVENT_DISPATCH) {
(events ||= new Map()).set(name, userOpt);
}
userOptions.set(name, userOpt);
}
if (events) {
for (const [name, value] of events) {
this.eventBus.dispatch(name.toLowerCase(), {
source: this,
value
});
}
} }
} }
static remove(name) { }
delete userOptions[name]; {
const val = compatibilityParams[name]; AppOptions._checkDisablePreferences = () => {
if (val !== undefined) { if (AppOptions.get("disablePreferences")) {
userOptions[name] = val; return true;
} }
} for (const [name] of userOptions) {
if (compatParams.has(name)) {
continue;
}
console.warn("The Preferences may override manually set AppOptions; " + 'please use the "disablePreferences"-option to prevent that.');
break;
}
return false;
};
} }
;// CONCATENATED MODULE: ./web/pdf_link_service.js ;// CONCATENATED MODULE: ./web/pdf_link_service.js
@@ -1171,26 +1233,27 @@ class PDFLinkService {
if (!(typeof zoom === "object" && typeof zoom?.name === "string")) { if (!(typeof zoom === "object" && typeof zoom?.name === "string")) {
return false; return false;
} }
const argsLen = args.length;
let allowNull = true; let allowNull = true;
switch (zoom.name) { switch (zoom.name) {
case "XYZ": case "XYZ":
if (args.length !== 3) { if (argsLen < 2 || argsLen > 3) {
return false; return false;
} }
break; break;
case "Fit": case "Fit":
case "FitB": case "FitB":
return args.length === 0; return argsLen === 0;
case "FitH": case "FitH":
case "FitBH": case "FitBH":
case "FitV": case "FitV":
case "FitBV": case "FitBV":
if (args.length !== 1) { if (argsLen > 1) {
return false; return false;
} }
break; break;
case "FitR": case "FitR":
if (args.length !== 4) { if (argsLen !== 4) {
return false; return false;
} }
allowNull = false; allowNull = false;
@@ -1240,7 +1303,6 @@ const {
noContextMenu, noContextMenu,
normalizeUnicode, normalizeUnicode,
OPS, OPS,
Outliner,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,
@@ -1248,12 +1310,10 @@ const {
PermissionFlag, PermissionFlag,
PixelsPerInch, PixelsPerInch,
RenderingCancelledException, RenderingCancelledException,
renderTextLayer,
setLayerDimensions, setLayerDimensions,
shadow, shadow,
TextLayer, TextLayer,
UnexpectedResponseException, UnexpectedResponseException,
updateTextLayer,
Util, Util,
VerbosityLevel, VerbosityLevel,
version, version,
@@ -1401,40 +1461,28 @@ class BaseExternalServices {
updateEditorStates(data) { updateEditorStates(data) {
throw new Error("Not implemented: updateEditorStates"); throw new Error("Not implemented: updateEditorStates");
} }
async getNimbusExperimentData() {}
async getGlobalEventNames() {
return null;
}
dispatchGlobalEvent(_event) {} dispatchGlobalEvent(_event) {}
} }
;// CONCATENATED MODULE: ./web/preferences.js ;// CONCATENATED MODULE: ./web/preferences.js
class BasePreferences { class BasePreferences {
#browserDefaults = Object.freeze({
canvasMaxAreaInBytes: -1,
isInAutomation: false,
supportsCaretBrowsingMode: false,
supportsDocumentFonts: true,
supportsIntegratedFind: false,
supportsMouseWheelZoomCtrlKey: true,
supportsMouseWheelZoomMetaKey: true,
supportsPinchToZoom: true
});
#defaults = Object.freeze({ #defaults = Object.freeze({
altTextLearnMoreUrl: "",
annotationEditorMode: 0, annotationEditorMode: 0,
annotationMode: 2, annotationMode: 2,
cursorToolOnLoad: 0, cursorToolOnLoad: 0,
defaultZoomDelay: 400, defaultZoomDelay: 400,
defaultZoomValue: "", defaultZoomValue: "",
disablePageLabels: false, disablePageLabels: false,
enableAltText: false,
enableGuessAltText: true,
enableHighlightEditor: false, enableHighlightEditor: false,
enableHighlightFloatingButton: false, enableHighlightFloatingButton: false,
enableML: false,
enablePermissions: false, enablePermissions: false,
enablePrintAutoRotate: true, enablePrintAutoRotate: true,
enableScripting: true, enableScripting: true,
enableStampEditor: true, enableUpdatedAddImage: false,
externalLinkTarget: 0, externalLinkTarget: 0,
highlightEditorColors: "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F", highlightEditorColors: "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F",
historyUpdateUrl: false, historyUpdateUrl: false,
@@ -1456,7 +1504,6 @@ class BasePreferences {
enableXfa: true, enableXfa: true,
viewerCssTheme: 0 viewerCssTheme: 0
}); });
#prefs = Object.create(null);
#initializedPromise = null; #initializedPromise = null;
constructor() { constructor() {
if (this.constructor === BasePreferences) { if (this.constructor === BasePreferences) {
@@ -1466,16 +1513,13 @@ class BasePreferences {
browserPrefs, browserPrefs,
prefs prefs
}) => { }) => {
const options = Object.create(null); if (AppOptions._checkDisablePreferences()) {
for (const [name, val] of Object.entries(this.#browserDefaults)) { return;
const prefVal = browserPrefs?.[name];
options[name] = typeof prefVal === typeof val ? prefVal : val;
} }
for (const [name, val] of Object.entries(this.#defaults)) { AppOptions.setAll({
const prefVal = prefs?.[name]; ...browserPrefs,
options[name] = this.#prefs[name] = typeof prefVal === typeof val ? prefVal : val; ...prefs
} }, true);
AppOptions.setAll(options, true);
}); });
} }
async _writeToStorage(prefObj) { async _writeToStorage(prefObj) {
@@ -1484,58 +1528,21 @@ class BasePreferences {
async _readFromStorage(prefObj) { async _readFromStorage(prefObj) {
throw new Error("Not implemented: _readFromStorage"); throw new Error("Not implemented: _readFromStorage");
} }
#updatePref({
name,
value
}) {
throw new Error("Not implemented: #updatePref");
}
async reset() { async reset() {
await this.#initializedPromise; await this.#initializedPromise;
const oldPrefs = structuredClone(this.#prefs); AppOptions.setAll(this.#defaults, true);
this.#prefs = Object.create(null); await this._writeToStorage(this.#defaults);
try {
await this._writeToStorage(this.#defaults);
} catch (reason) {
this.#prefs = oldPrefs;
throw reason;
}
} }
async set(name, value) { async set(name, value) {
await this.#initializedPromise; await this.#initializedPromise;
const defaultValue = this.#defaults[name], AppOptions.setAll({
oldPrefs = structuredClone(this.#prefs); [name]: value
if (defaultValue === undefined) { }, true);
throw new Error(`Set preference: "${name}" is undefined.`); await this._writeToStorage(AppOptions.getAll(OptionKind.PREFERENCE));
} else if (value === undefined) {
throw new Error("Set preference: no value is specified.");
}
const valueType = typeof value,
defaultType = typeof defaultValue;
if (valueType !== defaultType) {
if (valueType === "number" && defaultType === "string") {
value = value.toString();
} else {
throw new Error(`Set preference: "${value}" is a ${valueType}, expected a ${defaultType}.`);
}
} else if (valueType === "number" && !Number.isInteger(value)) {
throw new Error(`Set preference: "${value}" must be an integer.`);
}
this.#prefs[name] = value;
try {
await this._writeToStorage(this.#prefs);
} catch (reason) {
this.#prefs = oldPrefs;
throw reason;
}
} }
async get(name) { async get(name) {
await this.#initializedPromise; await this.#initializedPromise;
const defaultValue = this.#defaults[name]; return AppOptions.get(name);
if (defaultValue === undefined) {
throw new Error(`Get preference: "${name}" is undefined.`);
}
return this.#prefs[name] ?? defaultValue;
} }
get initializedPromise() { get initializedPromise() {
return this.#initializedPromise; return this.#initializedPromise;
@@ -3098,13 +3105,19 @@ class Preferences extends BasePreferences {
} }
class ExternalServices extends BaseExternalServices { class ExternalServices extends BaseExternalServices {
async createL10n() { async createL10n() {
return new genericl10n_GenericL10n(AppOptions.get("locale")); return new genericl10n_GenericL10n(AppOptions.get("localeProperties")?.lang);
} }
createScripting() { createScripting() {
return new GenericScripting(AppOptions.get("sandboxBundleSrc")); return new GenericScripting(AppOptions.get("sandboxBundleSrc"));
} }
} }
class MLManager { class MLManager {
async isEnabledFor(_name) {
return false;
}
async deleteModel(_service) {
return null;
}
async guess() { async guess() {
return null; return null;
} }
@@ -8411,6 +8424,9 @@ class AnnotationLayerBuilder {
} }
this.div.hidden = true; this.div.hidden = true;
} }
hasEditableAnnotations() {
return !!this.annotationLayer?.hasEditableAnnotations();
}
#updatePresentationModeState(state) { #updatePresentationModeState(state) {
if (!this.div) { if (!this.div) {
return; return;
@@ -9142,6 +9158,7 @@ class PDFPageView {
#annotationMode = AnnotationMode.ENABLE_FORMS; #annotationMode = AnnotationMode.ENABLE_FORMS;
#enableHWA = false; #enableHWA = false;
#hasRestrictedScaling = false; #hasRestrictedScaling = false;
#isEditing = false;
#layerProperties = null; #layerProperties = null;
#loadingId = null; #loadingId = null;
#previousRotation = null; #previousRotation = null;
@@ -9296,6 +9313,9 @@ class PDFPageView {
this.reset(); this.reset();
this.pdfPage?.cleanup(); this.pdfPage?.cleanup();
} }
hasEditableAnnotations() {
return !!this.annotationLayer?.hasEditableAnnotations();
}
get _textHighlighter() { get _textHighlighter() {
return shadow(this, "_textHighlighter", new TextHighlighter({ return shadow(this, "_textHighlighter", new TextHighlighter({
pageIndex: this.id - 1, pageIndex: this.id - 1,
@@ -9472,6 +9492,19 @@ class PDFPageView {
this._resetZoomLayer(); this._resetZoomLayer();
} }
} }
toggleEditingMode(isEditing) {
if (!this.hasEditableAnnotations()) {
return;
}
this.#isEditing = isEditing;
this.reset({
keepZoomLayer: true,
keepAnnotationLayer: true,
keepAnnotationEditorLayer: true,
keepXfaLayer: true,
keepTextLayer: true
});
}
update({ update({
scale = 0, scale = 0,
rotation = null, rotation = null,
@@ -9822,7 +9855,8 @@ class PDFPageView {
annotationMode: this.#annotationMode, annotationMode: this.#annotationMode,
optionalContentConfigPromise: this._optionalContentConfigPromise, optionalContentConfigPromise: this._optionalContentConfigPromise,
annotationCanvasMap: this._annotationCanvasMap, annotationCanvasMap: this._annotationCanvasMap,
pageColors pageColors,
isEditing: this.#isEditing
}; };
const renderTask = this.renderTask = pdfPage.render(renderContext); const renderTask = this.renderTask = pdfPage.render(renderContext);
renderTask.onContinue = renderContinueCallback; renderTask.onContinue = renderContinueCallback;
@@ -9982,8 +10016,11 @@ class PDFViewer {
#enableHWA = false; #enableHWA = false;
#enableHighlightFloatingButton = false; #enableHighlightFloatingButton = false;
#enablePermissions = false; #enablePermissions = false;
#enableUpdatedAddImage = false;
#eventAbortController = null; #eventAbortController = null;
#mlManager = null; #mlManager = null;
#onPageRenderedCallback = null;
#switchAnnotationEditorModeTimeoutId = null;
#getAllTextInProgress = false; #getAllTextInProgress = false;
#hiddenCopyElement = null; #hiddenCopyElement = null;
#interruptCopyCondition = false; #interruptCopyCondition = false;
@@ -9993,7 +10030,7 @@ class PDFViewer {
#scaleTimeoutId = null; #scaleTimeoutId = null;
#textLayerMode = TextLayerMode.ENABLE; #textLayerMode = TextLayerMode.ENABLE;
constructor(options) { constructor(options) {
const viewerVersion = "4.4.168"; const viewerVersion = "4.5.136";
if (version !== viewerVersion) { if (version !== viewerVersion) {
throw new Error(`The API version "${version}" does not match the Viewer version "${viewerVersion}".`); throw new Error(`The API version "${version}" does not match the Viewer version "${viewerVersion}".`);
} }
@@ -10020,6 +10057,7 @@ class PDFViewer {
this.#annotationEditorMode = options.annotationEditorMode ?? AnnotationEditorType.NONE; this.#annotationEditorMode = options.annotationEditorMode ?? AnnotationEditorType.NONE;
this.#annotationEditorHighlightColors = options.annotationEditorHighlightColors || null; this.#annotationEditorHighlightColors = options.annotationEditorHighlightColors || null;
this.#enableHighlightFloatingButton = options.enableHighlightFloatingButton === true; this.#enableHighlightFloatingButton = options.enableHighlightFloatingButton === true;
this.#enableUpdatedAddImage = options.enableUpdatedAddImage === true;
this.imageResourcesPath = options.imageResourcesPath || ""; this.imageResourcesPath = options.imageResourcesPath || "";
this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; this.enablePrintAutoRotate = options.enablePrintAutoRotate || false;
this.removePageBorders = options.removePageBorders || false; this.removePageBorders = options.removePageBorders || false;
@@ -10425,7 +10463,7 @@ class PDFViewer {
if (pdfDocument.isPureXfa) { if (pdfDocument.isPureXfa) {
console.warn("Warning: XFA-editing is not implemented."); console.warn("Warning: XFA-editing is not implemented.");
} else if (isValidAnnotationEditorMode(mode)) { } else if (isValidAnnotationEditorMode(mode)) {
this.#annotationEditorUIManager = new AnnotationEditorUIManager(this.container, viewer, this.#altTextManager, eventBus, pdfDocument, pageColors, this.#annotationEditorHighlightColors, this.#enableHighlightFloatingButton, this.#mlManager); this.#annotationEditorUIManager = new AnnotationEditorUIManager(this.container, viewer, this.#altTextManager, eventBus, pdfDocument, pageColors, this.#annotationEditorHighlightColors, this.#enableHighlightFloatingButton, this.#enableUpdatedAddImage, this.#mlManager);
eventBus.dispatch("annotationeditoruimanager", { eventBus.dispatch("annotationeditoruimanager", {
source: this, source: this,
uiManager: this.#annotationEditorUIManager uiManager: this.#annotationEditorUIManager
@@ -10584,6 +10622,7 @@ class PDFViewer {
this.viewer.removeAttribute("lang"); this.viewer.removeAttribute("lang");
this.#hiddenCopyElement?.remove(); this.#hiddenCopyElement?.remove();
this.#hiddenCopyElement = null; this.#hiddenCopyElement = null;
this.#cleanupSwitchAnnotationEditorMode();
} }
#ensurePageViewVisible() { #ensurePageViewVisible() {
if (this._scrollMode !== ScrollMode.PAGE) { if (this._scrollMode !== ScrollMode.PAGE) {
@@ -10956,6 +10995,34 @@ class PDFViewer {
location: this._location location: this._location
}); });
} }
#switchToEditAnnotationMode() {
const visible = this._getVisiblePages();
const pagesToRefresh = [];
const {
ids,
views
} = visible;
for (const page of views) {
const {
view
} = page;
if (!view.hasEditableAnnotations()) {
ids.delete(view.id);
continue;
}
pagesToRefresh.push(page);
}
if (pagesToRefresh.length === 0) {
return null;
}
this.renderingQueue.renderHighestPriority({
first: pagesToRefresh[0],
last: pagesToRefresh.at(-1),
views: pagesToRefresh,
ids
});
return ids;
}
containsElement(element) { containsElement(element) {
return this.container.contains(element); return this.container.contains(element);
} }
@@ -11388,6 +11455,16 @@ class PDFViewer {
get containerTopLeft() { get containerTopLeft() {
return this.#containerTopLeft ||= [this.container.offsetTop, this.container.offsetLeft]; return this.#containerTopLeft ||= [this.container.offsetTop, this.container.offsetLeft];
} }
#cleanupSwitchAnnotationEditorMode() {
if (this.#onPageRenderedCallback) {
this.eventBus._off("pagerendered", this.#onPageRenderedCallback);
this.#onPageRenderedCallback = null;
}
if (this.#switchAnnotationEditorModeTimeoutId !== null) {
clearTimeout(this.#switchAnnotationEditorModeTimeoutId);
this.#switchAnnotationEditorModeTimeoutId = null;
}
}
get annotationEditorMode() { get annotationEditorMode() {
return this.#annotationEditorUIManager ? this.#annotationEditorMode : AnnotationEditorType.DISABLE; return this.#annotationEditorUIManager ? this.#annotationEditorMode : AnnotationEditorType.DISABLE;
} }
@@ -11408,12 +11485,47 @@ class PDFViewer {
if (!this.pdfDocument) { if (!this.pdfDocument) {
return; return;
} }
this.#annotationEditorMode = mode; const {
this.eventBus.dispatch("annotationeditormodechanged", { eventBus
source: this, } = this;
mode const updater = () => {
}); this.#cleanupSwitchAnnotationEditorMode();
this.#annotationEditorUIManager.updateMode(mode, editId, isFromKeyboard); this.#annotationEditorMode = mode;
this.#annotationEditorUIManager.updateMode(mode, editId, isFromKeyboard);
eventBus.dispatch("annotationeditormodechanged", {
source: this,
mode
});
};
if (mode === AnnotationEditorType.NONE || this.#annotationEditorMode === AnnotationEditorType.NONE) {
const isEditing = mode !== AnnotationEditorType.NONE;
if (!isEditing) {
this.pdfDocument.annotationStorage.resetModifiedIds();
}
for (const pageView of this._pages) {
pageView.toggleEditingMode(isEditing);
}
const idsToRefresh = this.#switchToEditAnnotationMode();
if (isEditing && idsToRefresh) {
this.#cleanupSwitchAnnotationEditorMode();
this.#onPageRenderedCallback = ({
pageNumber
}) => {
idsToRefresh.delete(pageNumber);
if (idsToRefresh.size === 0) {
this.#switchAnnotationEditorModeTimeoutId = setTimeout(updater, 0);
}
};
const {
signal
} = this.#eventAbortController;
eventBus._on("pagerendered", this.#onPageRenderedCallback, {
signal
});
return;
}
}
updater();
} }
set annotationEditorParams({ set annotationEditorParams({
type, type,
@@ -11721,7 +11833,7 @@ class SecondaryToolbar {
class Toolbar { class Toolbar {
#opts; #opts;
constructor(options, eventBus) { constructor(options, eventBus, toolbarDensity = 0) {
this.#opts = options; this.#opts = options;
this.eventBus = eventBus; this.eventBus = eventBus;
const buttons = [{ const buttons = [{
@@ -11806,8 +11918,13 @@ class Toolbar {
break; break;
} }
}); });
eventBus._on("toolbardensity", this.#updateToolbarDensity.bind(this));
this.#updateToolbarDensity({
value: toolbarDensity
});
this.reset(); this.reset();
} }
#updateToolbarDensity() {}
#setAnnotationEditorUIManager(uiManager, parentContainer) { #setAnnotationEditorUIManager(uiManager, parentContainer) {
const colorPicker = new ColorPicker({ const colorPicker = new ColorPicker({
uiManager uiManager
@@ -12078,7 +12195,6 @@ class ViewHistory {
const FORCE_PAGES_LOADED_TIMEOUT = 10000; const FORCE_PAGES_LOADED_TIMEOUT = 10000;
const WHEEL_ZOOM_DISABLED_TIMEOUT = 1000;
const ViewOnLoad = { const ViewOnLoad = {
UNKNOWN: -1, UNKNOWN: -1,
PREVIOUS: 0, PREVIOUS: 0,
@@ -12110,18 +12226,17 @@ const PDFViewerApplication = {
store: null, store: null,
downloadManager: null, downloadManager: null,
overlayManager: null, overlayManager: null,
preferences: null, preferences: new Preferences(),
toolbar: null, toolbar: null,
secondaryToolbar: null, secondaryToolbar: null,
eventBus: null, eventBus: null,
l10n: null, l10n: null,
annotationEditorParams: null, annotationEditorParams: null,
isInitialViewSet: false, isInitialViewSet: false,
downloadComplete: false,
isViewerEmbedded: window.parent !== window, isViewerEmbedded: window.parent !== window,
url: "", url: "",
baseUrl: "", baseUrl: "",
_allowedGlobalEventsPromise: null, mlManager: null,
_downloadUrl: "", _downloadUrl: "",
_eventBusAbortController: null, _eventBusAbortController: null,
_windowAbortController: null, _windowAbortController: null,
@@ -12141,11 +12256,9 @@ const PDFViewerApplication = {
_printAnnotationStoragePromise: null, _printAnnotationStoragePromise: null,
_touchInfo: null, _touchInfo: null,
_isCtrlKeyDown: false, _isCtrlKeyDown: false,
_nimbusDataPromise: null,
_caretBrowsing: null, _caretBrowsing: null,
_isScrolling: false, _isScrolling: false,
async initialize(appConfig) { async initialize(appConfig) {
let l10nPromise;
this.appConfig = appConfig; this.appConfig = appConfig;
try { try {
await this.preferences.initializedPromise; await this.preferences.initializedPromise;
@@ -12167,8 +12280,7 @@ const PDFViewerApplication = {
if (mode) { if (mode) {
document.documentElement.classList.add(mode); document.documentElement.classList.add(mode);
} }
l10nPromise = this.externalServices.createL10n(); this.l10n = await this.externalServices.createL10n();
this.l10n = await l10nPromise;
document.getElementsByTagName("html")[0].dir = this.l10n.getDirection(); document.getElementsByTagName("html")[0].dir = this.l10n.getDirection();
this.l10n.translate(appConfig.appContainer || document.documentElement); this.l10n.translate(appConfig.appContainer || document.documentElement);
if (this.isViewerEmbedded && AppOptions.get("externalLinkTarget") === LinkTarget.NONE) { if (this.isViewerEmbedded && AppOptions.get("externalLinkTarget") === LinkTarget.NONE) {
@@ -12257,7 +12369,9 @@ const PDFViewerApplication = {
} }
} }
if (params.has("locale")) { if (params.has("locale")) {
AppOptions.set("locale", params.get("locale")); AppOptions.set("localeProperties", {
lang: params.get("locale")
});
} }
}, },
async _initializeViewerComponents() { async _initializeViewerComponents() {
@@ -12318,6 +12432,7 @@ const PDFViewerApplication = {
annotationEditorMode, annotationEditorMode,
annotationEditorHighlightColors: AppOptions.get("highlightEditorColors"), annotationEditorHighlightColors: AppOptions.get("highlightEditorColors"),
enableHighlightFloatingButton: AppOptions.get("enableHighlightFloatingButton"), enableHighlightFloatingButton: AppOptions.get("enableHighlightFloatingButton"),
enableUpdatedAddImage: AppOptions.get("enableUpdatedAddImage"),
imageResourcesPath: AppOptions.get("imageResourcesPath"), imageResourcesPath: AppOptions.get("imageResourcesPath"),
enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"),
maxCanvasPixels: AppOptions.get("maxCanvasPixels"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"),
@@ -12355,9 +12470,6 @@ const PDFViewerApplication = {
} }
if (appConfig.annotationEditorParams) { if (appConfig.annotationEditorParams) {
if (annotationEditorMode !== AnnotationEditorType.DISABLE) { if (annotationEditorMode !== AnnotationEditorType.DISABLE) {
if (AppOptions.get("enableStampEditor")) {
appConfig.toolbar?.editorStampButton?.classList.remove("hidden");
}
const editorHighlightButton = appConfig.toolbar?.editorHighlightButton; const editorHighlightButton = appConfig.toolbar?.editorHighlightButton;
if (editorHighlightButton && AppOptions.get("enableHighlightEditor")) { if (editorHighlightButton && AppOptions.get("enableHighlightEditor")) {
editorHighlightButton.hidden = false; editorHighlightButton.hidden = false;
@@ -12380,7 +12492,7 @@ const PDFViewerApplication = {
}); });
} }
if (appConfig.toolbar) { if (appConfig.toolbar) {
this.toolbar = new Toolbar(appConfig.toolbar, eventBus); this.toolbar = new Toolbar(appConfig.toolbar, eventBus, AppOptions.get("toolbarDensity"));
} }
if (appConfig.secondaryToolbar) { if (appConfig.secondaryToolbar) {
this.secondaryToolbar = new SecondaryToolbar(appConfig.secondaryToolbar, eventBus); this.secondaryToolbar = new SecondaryToolbar(appConfig.secondaryToolbar, eventBus);
@@ -12437,7 +12549,6 @@ const PDFViewerApplication = {
} }
}, },
async run(config) { async run(config) {
this.preferences = new Preferences();
await this.initialize(config); await this.initialize(config);
const { const {
appConfig, appConfig,
@@ -12514,9 +12625,6 @@ const PDFViewerApplication = {
get externalServices() { get externalServices() {
return shadow(this, "externalServices", new ExternalServices()); return shadow(this, "externalServices", new ExternalServices());
}, },
get mlManager() {
return shadow(this, "mlManager", AppOptions.get("enableML") === true ? new MLManager() : null);
},
get initialized() { get initialized() {
return this._initializedCapability.settled; return this._initializedCapability.settled;
}, },
@@ -12597,12 +12705,10 @@ const PDFViewerApplication = {
let title = pdfjs_getPdfFilenameFromUrl(url, ""); let title = pdfjs_getPdfFilenameFromUrl(url, "");
if (!title) { if (!title) {
try { try {
title = decodeURIComponent(getFilenameFromUrl(url)) || url; title = decodeURIComponent(getFilenameFromUrl(url));
} catch { } catch {}
title = url;
}
} }
this.setTitle(title); this.setTitle(title || url);
}, },
setTitle(title = this._title) { setTitle(title = this._title) {
this._title = title; this._title = title;
@@ -12648,7 +12754,6 @@ const PDFViewerApplication = {
this.pdfLinkService.externalLinkEnabled = true; this.pdfLinkService.externalLinkEnabled = true;
this.store = null; this.store = null;
this.isInitialViewSet = false; this.isInitialViewSet = false;
this.downloadComplete = false;
this.url = ""; this.url = "";
this.baseUrl = ""; this.baseUrl = "";
this._downloadUrl = ""; this._downloadUrl = "";
@@ -12724,9 +12829,7 @@ const PDFViewerApplication = {
async download(options = {}) { async download(options = {}) {
let data; let data;
try { try {
if (this.downloadComplete) { data = await this.pdfDocument.getData();
data = await this.pdfDocument.getData();
}
} catch {} } catch {}
this.downloadManager.download(data, this._downloadUrl, this._docFilename, options); this.downloadManager.download(data, this._downloadUrl, this._docFilename, options);
}, },
@@ -12793,11 +12896,8 @@ const PDFViewerApplication = {
return message; return message;
}, },
progress(level) { progress(level) {
if (!this.loadingBar || this.downloadComplete) {
return;
}
const percent = Math.round(level * 100); const percent = Math.round(level * 100);
if (percent <= this.loadingBar.percent) { if (!this.loadingBar || percent <= this.loadingBar.percent) {
return; return;
} }
this.loadingBar.percent = percent; this.loadingBar.percent = percent;
@@ -12811,7 +12911,6 @@ const PDFViewerApplication = {
length length
}) => { }) => {
this._contentLength = length; this._contentLength = length;
this.downloadComplete = true;
this.loadingBar?.hide(); this.loadingBar?.hide();
firstPagePromise.then(() => { firstPagePromise.then(() => {
this.eventBus.dispatch("documentloaded", { this.eventBus.dispatch("documentloaded", {
@@ -13413,9 +13512,6 @@ const PDFViewerApplication = {
}); });
} }
addWindowResolutionChange(); addWindowResolutionChange();
window.addEventListener("visibilitychange", webViewerVisibilityChange, {
signal
});
window.addEventListener("wheel", webViewerWheel, { window.addEventListener("wheel", webViewerWheel, {
passive: false, passive: false,
signal signal
@@ -13730,7 +13826,7 @@ function webViewerHashchange(evt) {
} }
} }
{ {
/*var webViewerFileInputChange = function (evt) { var webViewerFileInputChange = function (evt) {
if (PDFViewerApplication.pdfViewer?.isInPresentationMode) { if (PDFViewerApplication.pdfViewer?.isInPresentationMode) {
return; return;
} }
@@ -13742,7 +13838,7 @@ function webViewerHashchange(evt) {
}; };
var webViewerOpenFile = function (evt) { var webViewerOpenFile = function (evt) {
PDFViewerApplication._openFileInput?.click(); PDFViewerApplication._openFileInput?.click();
};*/ };
} }
function webViewerPresentationMode() { function webViewerPresentationMode() {
PDFViewerApplication.requestPresentationMode(); PDFViewerApplication.requestPresentationMode();
@@ -13876,20 +13972,6 @@ function webViewerPageChanging({
function webViewerResolutionChange(evt) { function webViewerResolutionChange(evt) {
PDFViewerApplication.pdfViewer.refresh(); PDFViewerApplication.pdfViewer.refresh();
} }
function webViewerVisibilityChange(evt) {
if (document.visibilityState === "visible") {
setZoomDisabledTimeout();
}
}
let zoomDisabledTimeout = null;
function setZoomDisabledTimeout() {
if (zoomDisabledTimeout) {
clearTimeout(zoomDisabledTimeout);
}
zoomDisabledTimeout = setTimeout(function () {
zoomDisabledTimeout = null;
}, WHEEL_ZOOM_DISABLED_TIMEOUT);
}
function webViewerWheel(evt) { function webViewerWheel(evt) {
const { const {
pdfViewer, pdfViewer,
@@ -13907,7 +13989,7 @@ function webViewerWheel(evt) {
const origin = [evt.clientX, evt.clientY]; const origin = [evt.clientX, evt.clientY];
if (isPinchToZoom || evt.ctrlKey && supportsMouseWheelZoomCtrlKey || evt.metaKey && supportsMouseWheelZoomMetaKey) { if (isPinchToZoom || evt.ctrlKey && supportsMouseWheelZoomCtrlKey || evt.metaKey && supportsMouseWheelZoomMetaKey) {
evt.preventDefault(); evt.preventDefault();
if (PDFViewerApplication._isScrolling || zoomDisabledTimeout || document.visibilityState === "hidden" || PDFViewerApplication.overlayManager.active) { if (PDFViewerApplication._isScrolling || document.visibilityState === "hidden" || PDFViewerApplication.overlayManager.active) {
return; return;
} }
if (isPinchToZoom && supportsPinchToZoom) { if (isPinchToZoom && supportsPinchToZoom) {
@@ -14335,14 +14417,20 @@ function webViewerReportTelemetry({
}) { }) {
PDFViewerApplication.externalServices.reportTelemetry(details); PDFViewerApplication.externalServices.reportTelemetry(details);
} }
function webViewerSetPreference({
name,
value
}) {
PDFViewerApplication.preferences.set(name, value);
}
;// CONCATENATED MODULE: ./web/viewer.js ;// CONCATENATED MODULE: ./web/viewer.js
const pdfjsVersion = "4.4.168"; const pdfjsVersion = "4.5.136";
const pdfjsBuild = "19fbc8998"; const pdfjsBuild = "3a21f03b0";
const AppConstants = { const AppConstants = {
LinkTarget: LinkTarget, LinkTarget: LinkTarget,
RenderingStates: RenderingStates, RenderingStates: RenderingStates,

View File

@@ -130,8 +130,13 @@ $(".container-fluid").bind('drop', function (e) {
} }
}); });
if (dt.files.length) { if (dt.files.length) {
$("#btn-upload")[0].files = dt.files; if($("#btn-upload-format").length) {
$("#form-upload").submit(); $("#btn-upload-format")[0].files = dt.files;
$("#form-upload-format").submit();
} else {
$("#btn-upload")[0].files = dt.files;
$("#form-upload").submit();
}
} }
} }
}); });
@@ -140,12 +145,25 @@ $("#btn-upload").change(function() {
$("#form-upload").submit(); $("#form-upload").submit();
}); });
$("#btn-upload-format").change(function() {
$("#form-upload-format").submit();
});
$("#form-upload").uploadprogress({ $("#form-upload").uploadprogress({
redirect_url: getPath() + "/", //"{{ url_for('web.index')}}", redirect_url: getPath() + "/",
uploadedMsg: $("#form-upload").data("message"), //"{{_('Upload done, processing, please wait...')}}", uploadedMsg: $("#form-upload").data("message"),
modalTitle: $("#form-upload").data("title"), //"{{_('Uploading...')}}", modalTitle: $("#form-upload").data("title"),
modalFooter: $("#form-upload").data("footer"), //"{{_('Close')}}", modalFooter: $("#form-upload").data("footer"),
modalTitleFailed: $("#form-upload").data("failed") //"{{_('Error')}}" modalTitleFailed: $("#form-upload").data("failed")
});
$("#form-upload-format").uploadprogress({
redirect_url: getPath() + "/",
uploadedMsg: $("#form-upload-format").data("message"),
modalTitle: $("#form-upload-format").data("title"),
modalFooter: $("#form-upload-format").data("footer"),
modalTitleFailed: $("#form-upload-format").data("failed")
}); });
$(document).ready(function() { $(document).ready(function() {
@@ -160,15 +178,21 @@ $(document).ready(function() {
$(".session").click(function() { $(".session").click(function() {
window.sessionStorage.setItem("back", window.location.pathname); window.sessionStorage.setItem("back", window.location.pathname);
window.sessionStorage.setItem("search", window.location.search);
}); });
$("#back").click(function() { $("#back").click(function() {
var loc = sessionStorage.getItem("back"); var loc = sessionStorage.getItem("back");
var param = sessionStorage.getItem("search");
if (!loc) { if (!loc) {
loc = $(this).data("back"); loc = $(this).data("back");
} }
sessionStorage.removeItem("back"); sessionStorage.removeItem("back");
window.location.href = loc; sessionStorage.removeItem("search");
if (param === null) {
param = "";
}
window.location.href = loc + param;
}); });
@@ -598,6 +622,7 @@ $(function() {
}); });
$("#toggle_order_shelf").click(function() { $("#toggle_order_shelf").click(function() {
$("#toggle_order_shelf").toggleClass("dummy");
$("#new").toggleClass("disabled"); $("#new").toggleClass("disabled");
$("#old").toggleClass("disabled"); $("#old").toggleClass("disabled");
$("#asc").toggleClass("disabled"); $("#asc").toggleClass("disabled");
@@ -606,9 +631,20 @@ $(function() {
$("#auth_za").toggleClass("disabled"); $("#auth_za").toggleClass("disabled");
$("#pub_new").toggleClass("disabled"); $("#pub_new").toggleClass("disabled");
$("#pub_old").toggleClass("disabled"); $("#pub_old").toggleClass("disabled");
$("#shelf_new").toggleClass("disabled");
$("#shelf_old").toggleClass("disabled");
var alternative_text = $("#toggle_order_shelf").data('alt-text'); var alternative_text = $("#toggle_order_shelf").data('alt-text');
var status = $("#toggle_order_shelf").hasClass("dummy") ? "on" : "off";
$("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html()); $("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html());
$("#toggle_order_shelf").html(alternative_text); $("#toggle_order_shelf").html(alternative_text);
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/ajax/view",
data: "{\"shelf\": {\"man\": \"" + status + "\"}}",
});
}); });
$("#btndeluser").click(function() { $("#btndeluser").click(function() {

23
cps/string_helper.py Normal file
View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2024 OzzieIsaacs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
def strip_whitespaces(text):
return re.sub("(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)

View File

@@ -39,6 +39,7 @@ from cps.file_helper import get_temp_dir
from cps.tasks.mail import TaskEmail from cps.tasks.mail import TaskEmail
from cps import gdriveutils, helper from cps import gdriveutils, helper
from cps.constants import SUPPORTED_CALIBRE_BINARIES from cps.constants import SUPPORTED_CALIBRE_BINARIES
from cps.string_helper import strip_whitespaces
log = logger.create() log = logger.create()
@@ -107,7 +108,7 @@ class TaskConvert(CalibreTask):
try: try:
EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title)) EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title))
for email in self.ereader_mail.split(','): for email in self.ereader_mail.split(','):
email = email.strip() email = strip_whitespaces(email)
worker_thread.add(self.user, TaskEmail(self.settings['subject'], worker_thread.add(self.user, TaskEmail(self.settings['subject'],
self.results["path"], self.results["path"],
filename, filename,

View File

@@ -34,6 +34,7 @@ from cps.services import gmail
from cps.embed_helper import do_calibre_export from cps.embed_helper import do_calibre_export
from cps import logger, config from cps import logger, config
from cps import gdriveutils from cps import gdriveutils
from cps.string_helper import strip_whitespaces
import uuid import uuid
log = logger.create() log = logger.create()
@@ -127,9 +128,9 @@ class TaskEmail(CalibreTask):
try: try:
# Parse out the address from the From line, and then the domain from that # Parse out the address from the From line, and then the domain from that
from_email = parseaddr(self.settings["mail_from"])[1] from_email = parseaddr(self.settings["mail_from"])[1]
msgid_domain = from_email.partition('@')[2].strip() msgid_domain = strip_whitespaces(from_email.partition('@')[2])
# This can sometimes sneak through parseaddr if the input is malformed # This can sometimes sneak through parseaddr if the input is malformed
msgid_domain = msgid_domain.rstrip('>').strip() msgid_domain = strip_whitespaces(msgid_domain.rstrip('>'))
except Exception: except Exception:
msgid_domain = '' msgid_domain = ''
return msgid_domain or 'calibre-web.com' return msgid_domain or 'calibre-web.com'

View File

@@ -20,14 +20,13 @@ import os
from shutil import copyfile, copyfileobj from shutil import copyfile, copyfileobj
from urllib.request import urlopen from urllib.request import urlopen
from io import BytesIO from io import BytesIO
from datetime import datetime, timezone
from .. import constants from .. import constants
from cps import config, db, fs, gdriveutils, logger, ub from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
from datetime import datetime
from sqlalchemy import func, text, or_ from sqlalchemy import func, text, or_
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
try: try:
from wand.image import Image from wand.image import Image
use_IM = True use_IM = True
@@ -36,7 +35,7 @@ except (ImportError, RuntimeError) as e:
def get_resize_height(resolution): def get_resize_height(resolution):
return int(225 * resolution) return int(255 * resolution)
def get_resize_width(resolution, original_width, original_height): def get_resize_width(resolution, original_width, original_height):
@@ -73,7 +72,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.cache = fs.FileSystem() self.cache = fs.FileSystem()
self.resolutions = [ self.resolutions = [
constants.COVER_THUMBNAIL_SMALL, constants.COVER_THUMBNAIL_SMALL,
constants.COVER_THUMBNAIL_MEDIUM constants.COVER_THUMBNAIL_MEDIUM,
constants.COVER_THUMBNAIL_LARGE
] ]
def run(self, worker_thread): def run(self, worker_thread):
@@ -123,7 +123,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
.query(ub.Thumbnail) \ .query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book_id) \ .filter(ub.Thumbnail.entity_id == book_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc))) \
.all() .all()
def create_book_cover_thumbnails(self, book): def create_book_cover_thumbnails(self, book):
@@ -165,7 +165,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.app_db_session.rollback() self.app_db_session.rollback()
def update_book_cover_thumbnail(self, book, thumbnail): def update_book_cover_thumbnail(self, book, thumbnail):
thumbnail.generated_at = datetime.utcnow() thumbnail.generated_at = datetime.now(timezone.utc)
try: try:
self.app_db_session.commit() self.app_db_session.commit()
@@ -197,9 +197,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
img.format = thumbnail.format img.format = thumbnail.format
img.save(filename=filename) img.save(filename=filename)
else: else:
with open(filename, 'rb') as fd: stream.seek(0)
with open(filename, 'wb') as fd:
copyfileobj(stream, fd) copyfileobj(stream, fd)
except Exception as ex: except Exception as ex:
# Bubble exception to calling function # Bubble exception to calling function
self.log.debug('Error generating thumbnail file: ' + str(ex)) self.log.debug('Error generating thumbnail file: ' + str(ex))
@@ -322,12 +324,12 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
.all() .all()
def get_series_thumbnails(self, series_id): def get_series_thumbnails(self, series_id):
return self.app_db_session \ return (self.app_db_session
.query(ub.Thumbnail) \ .query(ub.Thumbnail)
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES)
.filter(ub.Thumbnail.entity_id == series_id) \ .filter(ub.Thumbnail.entity_id == series_id)
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
.all() .all())
def create_series_thumbnail(self, series, series_books, resolution): def create_series_thumbnail(self, series, series_books, resolution):
thumbnail = ub.Thumbnail() thumbnail = ub.Thumbnail()
@@ -346,7 +348,7 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
self.app_db_session.rollback() self.app_db_session.rollback()
def update_series_thumbnail(self, series_books, thumbnail): def update_series_thumbnail(self, series_books, thumbnail):
thumbnail.generated_at = datetime.utcnow() thumbnail.generated_at = datetime.now(timezone.utc)
try: try:
self.app_db_session.commit() self.app_db_session.commit()

View File

@@ -82,6 +82,7 @@ def render_task_status(tasklist):
ret['task_id'] = task.id ret['task_id'] = task.id
ret['stat'] = task.stat ret['stat'] = task.stat
ret['is_cancellable'] = task.is_cancellable ret['is_cancellable'] = task.is_cancellable
ret['error'] = task.error
rendered_tasklist.append(ret) rendered_tasklist.append(ret)

View File

@@ -62,11 +62,9 @@
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a> <a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% for format in entry.Books.data %} {% if entry.Books.data|music %}
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
<span class="glyphicon glyphicon-music"></span> <span class="glyphicon glyphicon-music"></span>
{% endif %} {% endif %}
{% endfor %}
</p> </p>
{% if entry.Books.series.__len__() > 0 %} {% if entry.Books.series.__len__() > 0 %}
<p class="series"> <p class="series">

View File

@@ -47,26 +47,41 @@
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% if current_user.role_upload() and g.allow_upload %}
<div class="text-center more-stuff"><!--h4 aria-label="Upload new book format"></h4-->
<form id="form-upload-format" action="{{ url_for('edit-book.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
<div class="text-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="book_id" value="{{ book.id }}">
<div role="group" aria-label="Upload new book format">
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
<div class="upload-format-input-text" id="upload-format"></div>
<input id="btn-upload-format" name="btn-upload-format" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple>
</div>
</div>
</form>
</div> </div>
{% endif %}
</div>
<form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm"> <form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-sm-9 col-xs-12"> <div class="col-sm-9 col-xs-12">
<div class="form-group"> <div class="form-group">
<label for="book_title">{{_('Book Title')}}</label> <label for="title">{{_('Book Title')}}</label>
<input type="text" class="form-control" name="book_title" id="book_title" value="{{book.title}}"> <input type="text" class="form-control" name="title" id="title" value="{{book.title}}">
</div> </div>
<div class="text-center"> <div class="text-center">
<button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button> <button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button>
</div> </div>
<div id="author_div" class="form-group"> <div id="author_div" class="form-group">
<label for="bookAuthor">{{_('Author')}}</label> <label for="bookAuthor">{{_('Author')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="author_name" id="bookAuthor" value="{{' & '.join(authors)}}"> <input type="text" class="form-control typeahead" autocomplete="off" name="authors" id="authors" value="{{' & '.join(authors)}}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">{{_('Description')}}</label> <label for="comments">{{_('Description')}}</label>
<textarea class="form-control" name="description" id="description" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea> <textarea class="form-control" name="comments" id="comments" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -196,13 +211,6 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if current_user.role_upload() and g.allow_upload %}
<div role="group" aria-label="Upload new book format">
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
<div class="upload-format-input-text" id="upload-format"></div>
<input id="btn-upload-format" name="btn-upload-format" type="file">
</div>
{% endif %}
<div class="checkbox"> <div class="checkbox">
<label> <label>
@@ -288,7 +296,7 @@
'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}}, 'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}},
'author': {{_('Author')|safe|tojson}}, 'author': {{_('Author')|safe|tojson}},
'publisher': {{_('Publisher')|safe|tojson}}, 'publisher': {{_('Publisher')|safe|tojson}},
'description': {{_('Description')|safe|tojson}}, 'comments': {{_('Description')|safe|tojson}},
'source': {{_('Source')|safe|tojson}}, 'source': {{_('Source')|safe|tojson}},
}; };
var language = '{{ current_user.locale }}'; var language = '{{ current_user.locale }}';

View File

@@ -20,7 +20,7 @@
{{ _('Download') }} : {{ _('Download') }} :
</button> </button>
{% for format in entry.data %} {% for format in entry.data %}
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}" <a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}"
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary" id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
role="button"> role="button">
<span class="glyphicon glyphicon-download"></span>{{ format.format }} <span class="glyphicon glyphicon-download"></span>{{ format.format }}
@@ -36,7 +36,7 @@
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1"> <ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
{% for format in entry.data %} {% for format in entry.data %}
<li> <li>
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}">{{ format.format }} <a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
({{ format.uncompressed_size|filesizeformat }})</a></li> ({{ format.uncompressed_size|filesizeformat }})</a></li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -15,6 +15,6 @@
<img <img
srcset="{{ srcset }}" srcset="{{ srcset }}"
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}" src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
alt="{{ book_title }}" alt="{{ title }}"
/> />
{%- endmacro %} {%- endmacro %}

View File

@@ -119,11 +119,9 @@
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a> <a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% for format in entry.Books.data %} {% if entry.Books.data|music %}
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
<span class="glyphicon glyphicon-music"></span> <span class="glyphicon glyphicon-music"></span>
{% endif %} {% endif %}
{%endfor%}
</p> </p>
{% if entry.Books.series.__len__() > 0 %} {% if entry.Books.series.__len__() > 0 %}
<p class="series"> <p class="series">

View File

@@ -80,6 +80,7 @@
<div class="form-group"> <div class="form-group">
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload" <span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span> type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple></span>
<input class="hide" id="btn-upload2" name="btn-upload2" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}">
</div> </div>
</form> </form>
</li> </li>

View File

@@ -1,10 +0,0 @@
{
"input": {
"placeholder": "a placeholder"
},
"nav": {
"home": "Home",
"page1": "Page One",
"page2": "Page Two"
}
}

View File

@@ -73,11 +73,9 @@
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a> <a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% for format in entry.Books.data %} {% if entry.Books.data|music %}
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
<span class="glyphicon glyphicon-music"></span> <span class="glyphicon glyphicon-music"></span>
{% endif %} {% endif %}
{% endfor %}
</p> </p>
{% if entry.Books.series.__len__() > 0 %} {% if entry.Books.series.__len__() > 0 %}
<p class="series"> <p class="series">

View File

@@ -5,12 +5,12 @@
<form role="form" id="search" action="{{ url_for('search.advanced_search_form') }}" method="POST"> <form role="form" id="search" action="{{ url_for('search.advanced_search_form') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
<label for="book_title">{{_('Book Title')}}</label> <label for="title">{{_('Book Title')}}</label>
<input type="text" class="form-control" name="book_title" id="book_title" value=""> <input type="text" class="form-control" name="title" id="title" value="">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="bookAuthor">{{_('Author')}}</label> <label for="authors">{{_('Author')}}</label>
<input type="text" class="form-control typeahead" name="author_name" id="bookAuthor" value="" autocomplete="off"> <input type="text" class="form-control typeahead" name="authors" id="authors" value="" autocomplete="off">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="Publisher">{{_('Publisher')}}</label> <label for="Publisher">{{_('Publisher')}}</label>
@@ -151,28 +151,48 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="comment">{{_('Description')}}</label> <label for="comments">{{_('Description')}}</label>
<input type="text" class="form-control" name="comment" id="comment" value=""> <input type="text" class="form-control" name="comments" id="comments" value="">
</div> </div>
{% if cc|length > 0 %} {% if cc|length > 0 %}
{% for c in cc %} {% for c in cc %}
<div class="form-group"> <div class="form-group">
<!--input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""-->
<label for="{{ 'custom_column_' ~ c.id }}">{{ c.name }}</label> <label for="{{ 'custom_column_' ~ c.id }}">{{ c.name }}</label>
{% if c.datatype == 'bool' %} {% if c.datatype == 'bool' %}
<select name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" class="form-control"> <select name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" class="form-control">
<option value="" selected></option> <option value="Any" selected>{{_('Any')}}</option>
<option value="">{{_('Empty')}}</option>
<option value="True" >{{_('Yes')}}</option> <option value="True" >{{_('Yes')}}</option>
<option value="False" >{{_('No')}}</option> <option value="False" >{{_('No')}}</option>
</select> </select>
{% endif %} {% endif %}
{% if c.datatype == 'int' %} {% if c.datatype == 'int' %}
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""> <div class="row">
<div class="form-group col-sm-6">
<label for="{{ 'custom_column_' ~ c.id }}_low">{{_('From:')}}</label>
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}_low" id="{{ 'custom_column_' ~ c.id }}_low" value="">
</div>
<div class="form-group col-sm-6">
<label for="{{ 'custom_column_' ~ c.id }}_high">{{_('To:')}}</label>
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}_high" id="{{ 'custom_column_' ~ c.id }}_high" value="">
</div>
</div>
{% endif %} {% endif %}
{% if c.datatype == 'float' %} {% if c.datatype == 'float' %}
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""> <div class="row">
<div class="form-group col-sm-6">
<label for="{{ 'custom_column_' ~ c.id }}_low">{{_('From:')}}</label>
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}_low" id="{{ 'custom_column_' ~ c.id }}_low" value="">
</div>
<div class="form-group col-sm-6">
<label for="{{ 'custom_column_' ~ c.id }}_high">{{_('To:')}}</label>
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}_high" id="{{ 'custom_column_' ~ c.id }}_high" value="">
</div>
</div>
<!--input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""-->
{% endif %} {% endif %}
{% if c.datatype == 'datetime' %} {% if c.datatype == 'datetime' %}

View File

@@ -15,16 +15,19 @@
</form> </form>
{% if entries.__len__() %} {% if entries.__len__() %}
<a id="order_shelf" href="{{ url_for('shelf.order_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Arrange books manually') }} </a> <a id="order_shelf" href="{{ url_for('shelf.order_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Arrange books manually') }} </a>
<button id="toggle_order_shelf" type="button" data-alt-text="{{ _('Disable Change order') }}" class="btn btn-primary">{{ _('Enable Change order') }}</button> <button id="toggle_order_shelf" data-alt-text="{% if status == "on" %}{{_('Disable Change order')}}{% else %}{{_('Enable Change order')}}{% endif %}" data-view="shelf" class="btn btn-primary{% if status == 'on' %} dummy">{{ _('Enable Change order') }}{% else%}">{{ _('Disable Change order') }}{% endif %}</button>
<div class="filterheader hidden-xs"> <div class="filterheader hidden-xs">
<a data-toggle="tooltip" title="{{_('Sort according to book date, newest first')}}" id="new" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='new')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a> <a data-toggle="tooltip" title="{{_('Sort according to book date, newest first')}}" id="new" class="btn btn-primary {% if order == "new" %} active{% endif %}{% if status == "on" %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='new')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
<a data-toggle="tooltip" title="{{_('Sort according to book date, oldest first')}}" id="old" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='old')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a> <a data-toggle="tooltip" title="{{_('Sort according to book date, oldest first')}}" id="old" class="btn btn-primary {% if order == 'old' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='old')}}"><span class="glyphicon glyphicon-book"></span> <span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
<a data-toggle="tooltip" title="{{_('Sort title in alphabetical order')}}" id="asc" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='abc')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet"></span></a> <a data-toggle="tooltip" title="{{_('Sort title in alphabetical order')}}" id="asc" class="btn btn-primary {% if order == 'abc' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='abc')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet"></span></a>
<a data-toggle="tooltip" title="{{_('Sort title in reverse alphabetical order')}}" id="desc" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='zyx')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a> <a data-toggle="tooltip" title="{{_('Sort title in reverse alphabetical order')}}" id="desc" class="btn btn-primary {% if order == 'zyx' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='zyx')}}"><span class="glyphicon glyphicon-font"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a>
<a data-toggle="tooltip" title="{{_('Sort authors in alphabetical order')}}" id="auth_az" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='authaz')}}"><span class="glyphicon glyphicon-user"></span><span class="glyphicon glyphicon-sort-by-alphabet"></span></a> <a data-toggle="tooltip" title="{{_('Sort authors in alphabetical order')}}" id="auth_az" class="btn btn-primary {% if order == 'authaz' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='authaz')}}"><span class="glyphicon glyphicon-user"></span><span class="glyphicon glyphicon-sort-by-alphabet"></span></a>
<a data-toggle="tooltip" title="{{_('Sort authors in reverse alphabetical order')}}" id="auth_za" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='authza')}}"><span class="glyphicon glyphicon-user"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a> <a data-toggle="tooltip" title="{{_('Sort authors in reverse alphabetical order')}}" id="auth_za" class="btn btn-primary {% if order == 'authza' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='authza')}}"><span class="glyphicon glyphicon-user"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a>
<a data-toggle="tooltip" title="{{_('Sort according to publishing date, newest first')}}" id="pub_new" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubnew')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a> <a data-toggle="tooltip" title="{{_('Sort according to publishing date, newest first')}}" id="pub_new" class="btn btn-primary {% if order == 'pubnew' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubnew')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
<a data-toggle="tooltip" title="{{_('Sort according to publishing date, oldest first')}}" id="pub_old" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubold')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a> <a data-toggle="tooltip" title="{{_('Sort according to publishing date, oldest first')}}" id="pub_old" class="btn btn-primary {% if order == 'pubold' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubold')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
<a data-toggle="tooltip" title="{{_('Sort according to book added to shelf, newest first')}}" id="shelf_new" class="btn btn-primary {% if order == 'shelfnew' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='shelfnew')}}"><span class="glyphicon glyphicon-list"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
<a data-toggle="tooltip" title="{{_('Sort according to book added to shelf, oldest first')}}" id="shelf_old" class="btn btn-primary {% if order == 'shelfold' %} active{% endif %}{% if status == 'on' %} disabled{% endif %}" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='shelfold')}}"><span class="glyphicon glyphicon-list"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a
>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -55,7 +55,7 @@
{% if entry.Books.data|length %} {% if entry.Books.data|length %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
{% for format in entry.Books.data %} {% for format in entry.Books.data %}
<a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button"> <a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }}) <span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }})
</a> </a>
{% endfor %} {% endfor %}

View File

@@ -16,6 +16,7 @@
<th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th> <th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th>
<th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th> <th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th>
<th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th> <th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th>
<th data-halign="right" data-align="right" data-field="error" data-sortable="true">{{_('Message')}}</th>
{% if current_user.role_admin() %} {% if current_user.role_admin() %}
<th data-halign="right" data-align="right" data-formatter="TaskActions" data-switchable="false">{{_('Actions')}}</th> <th data-halign="right" data-align="right" data-formatter="TaskActions" data-switchable="false">{{_('Actions')}}</th>
{% endif %} {% endif %}

View File

@@ -25,7 +25,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
<label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to seperate emails for multiple eReaders')}}</label> <label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to separate emails for multiple eReaders')}}</label>
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}"> <input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
</div> </div>
{% if not content.role_anonymous() %} {% if not content.role_anonymous() %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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