Merge branch 'master' into Develop

This commit is contained in:
Ozzie Isaacs 2024-06-22 16:07:01 +02:00
commit e52e8c6fdf
50 changed files with 362 additions and 263 deletions

View File

@ -26,6 +26,7 @@ from flask import session, current_app
from flask_login.utils import decode_cookie
from flask_login.signals import user_loaded_from_cookie
class MyLoginManager(LoginManager):
def _session_protection_failed(self):
sess = session._get_current_object()

View File

@ -107,6 +107,7 @@ if limiter_present:
else:
limiter = None
def create_app():
if csrf:
csrf.init_app(app)

View File

@ -999,10 +999,7 @@ def get_drives(current):
for d in string.ascii_uppercase:
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
drive = "{}:\\".format(d)
data = {"name": drive, "fullpath": drive}
data["sort"] = "_" + data["fullpath"].lower()
data["type"] = "dir"
data["size"] = ""
data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
drive_letters.append(data)
return drive_letters

View File

@ -10,6 +10,7 @@ log = logger.create()
babel = Babel()
def get_locale():
# if a user is logged in, use the locale from the user settings
if current_user is not None and hasattr(current_user, "locale"):

View File

@ -19,29 +19,24 @@
from . import logger
from lxml.etree import ParserError
log = logger.create()
try:
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
from bleach import clean_text as clean_html
BLEACH = True
from bleach import clean as clean_html
from bleach.sanitizer import ALLOWED_TAGS
bleach = True
except ImportError:
try:
BLEACH = False
from nh3 import clean as clean_html
except ImportError:
try:
BLEACH = False
from lxml.html.clean import clean_html
except ImportError:
clean_html = None
log = logger.create()
bleach = False
def clean_string(unsafe_text, book_id=0):
try:
if BLEACH:
safe_text = clean_html(unsafe_text, tags=set(), attributes=set())
if bleach:
allowed_tags = list(ALLOWED_TAGS)
allowed_tags.extend(['p', 'span', 'div', 'pre'])
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
else:
safe_text = clean_html(unsafe_text)
except ParserError as e:

View File

@ -35,6 +35,19 @@ def version_info():
class CliParameter(object):
def __init__(self):
self.user_credentials = None
self.ip_address = None
self.allow_localhost = None
self.reconnect_enable = None
self.memory_backend = None
self.dry_run = None
self.certfilepath = None
self.keyfilepath = None
self.gd_path = None
self.settings_path = None
self.logpath = None
def init(self):
self.arg_parser()
@ -44,22 +57,25 @@ class CliParameter(object):
prog='cps.py')
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, '
'works only in combination with keyfile')
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, '
'e.g. /opt/test.cert, works only in combination with keyfile')
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
'works only in combination with certfile')
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
parser.add_argument('-v', '--version', action='version', help='Shows version number '
'and exits Calibre-Web',
version=version_info())
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
parser.add_argument('-m', action='store_true', help='Use Memory-backend as limiter backend, use this parameter in case of miss configured backend')
parser.add_argument('-m', action='store_true',
help='Use Memory-backend as limiter backend, use this parameter '
'in case of miss configured backend')
parser.add_argument('-s', metavar='user:pass',
help='Sets specific username to new password and exits Calibre-Web')
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
'and exits Calibre-Web')
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions '
'in advance and exits Calibre-Web')
parser.add_argument('-r', action='store_true', help='Enable public database reconnect '
'route under /reconnect')
args = parser.parse_args()
self.logpath = args.o or ""
@ -130,6 +146,3 @@ class CliParameter(object):
if self.user_credentials and ":" not in self.user_credentials:
print("No valid 'username:password' format")
sys.exit(3)
if args.f:
print("Warning: -f flag is depreciated and will be removed in next version")

View File

@ -48,6 +48,7 @@ class _Flask_Settings(_Base):
flask_session_key = Column(BLOB, default=b"")
def __init__(self, key):
super().__init__()
self.flask_session_key = key
@ -82,7 +83,9 @@ class _Settings(_Base):
config_random_books = Column(Integer, default=4)
config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0)
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
config_title_regex = Column(String,
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
@ -178,6 +181,26 @@ class _Settings(_Base):
class ConfigSQL(object):
# pylint: disable=no-member
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()
def init_config(self, session, secret_key, cli):
@ -191,16 +214,16 @@ class ConfigSQL(object):
change = False
if self.config_binariesdir == None: # pylint: disable=access-member-before-definition
if self.config_binariesdir is None:
change = True
self.config_binariesdir = autodetect_calibre_binaries()
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
if self.config_kepubifypath is None:
change = True
self.config_kepubifypath = autodetect_kepubify_binary()
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
if self.config_rarfile_location is None:
change = True
self.config_rarfile_location = autodetect_unrar_binary()
if change:
@ -429,8 +452,7 @@ def _encrypt_fields(session, secret_key):
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
if settings.config_ldap_serv_password:
session.query(_Settings).update(
{_Settings.config_ldap_serv_password_e:
crypter.encrypt(settings.config_ldap_serv_password.encode())})
{_Settings.config_ldap_serv_password_e: crypter.encrypt(settings.config_ldap_serv_password.encode())})
session.commit()
@ -546,7 +568,7 @@ def load_configuration(session, secret_key):
def get_flask_session_key(_session):
flask_settings = _session.query(_Flask_Settings).one_or_none()
if flask_settings == None:
if flask_settings is None:
flask_settings = _Flask_Settings(os.urandom(32))
_session.add(flask_settings)
_session.commit()
@ -557,6 +579,7 @@ def get_encryption_key(key_path):
key_file = os.path.join(key_path, ".key")
generate = True
error = ""
key = None
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
with open(key_file, "rb") as f:
key = f.read()

View File

@ -165,6 +165,7 @@ SUPPORTED_CALIBRE_BINARIES = {binary:binary + _extension for binary in ["ebook-c
def has_flag(value, bit_flag):
return bit_flag == (bit_flag & (value or 0))
def selected_roles(dictionary):
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)

View File

@ -104,6 +104,7 @@ class Identifiers(Base):
book = Column(Integer, ForeignKey('books.id'), nullable=False)
def __init__(self, val, id_type, book):
super().__init__()
self.val = val
self.type = id_type
self.book = book
@ -192,6 +193,7 @@ class Comments(Base):
text = Column(String(collation='NOCASE'), nullable=False)
def __init__(self, comment, book):
super().__init__()
self.text = comment
self.book = book
@ -209,6 +211,7 @@ class Tags(Base):
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
def __init__(self, name):
super().__init__()
self.name = name
def get(self):
@ -230,6 +233,7 @@ class Authors(Base):
link = Column(String, nullable=False, default="")
def __init__(self, name, sort, link=""):
super().__init__()
self.name = name
self.sort = sort
self.link = link
@ -252,6 +256,7 @@ class Series(Base):
sort = Column(String(collation='NOCASE'))
def __init__(self, name, sort):
super().__init__()
self.name = name
self.sort = sort
@ -272,6 +277,7 @@ class Ratings(Base):
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
def __init__(self, rating):
super().__init__()
self.rating = rating
def get(self):
@ -291,6 +297,7 @@ class Languages(Base):
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
def __init__(self, lang_code):
super().__init__()
self.lang_code = lang_code
def get(self):
@ -314,6 +321,7 @@ class Publishers(Base):
sort = Column(String(collation='NOCASE'))
def __init__(self, name, sort):
super().__init__()
self.name = name
self.sort = sort
@ -338,6 +346,7 @@ class Data(Base):
name = Column(String, nullable=False)
def __init__(self, book, book_format, uncompressed_size, name):
super().__init__()
self.book = book
self.format = book_format
self.uncompressed_size = uncompressed_size
@ -357,6 +366,7 @@ class Metadata_Dirtied(Base):
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
def __init__(self, book):
super().__init__()
self.book = book
@ -391,6 +401,7 @@ class Books(Base):
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
authors, tags, languages=None):
super().__init__()
self.title = title
self.sort = sort
self.author_sort = author_sort
@ -399,7 +410,7 @@ class Books(Base):
self.series_index = series_index
self.last_modified = last_modified
self.path = path
self.has_cover = (has_cover != None)
self.has_cover = (has_cover is not None)
def __repr__(self):
return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
@ -448,11 +459,13 @@ class CustomColumns(Base):
content['is_editable'] = self.editable
content['rec_index'] = sequence + 22 # toDo why ??
if isinstance(value, datetime):
content['#value#'] = {"__class__": "datetime.datetime", "__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
content['#value#'] = {"__class__": "datetime.datetime",
"__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
else:
content['#value#'] = value
content['#extra#'] = extra
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",", "list_to_ui": ", "}
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",",
"list_to_ui": ", "}
return json.dumps(content, ensure_ascii=False)
@ -512,7 +525,6 @@ class CalibreDB:
if init:
self.init_db(expire_on_commit)
def init_db(self, expire_on_commit=True):
if self._init:
self.init_session(expire_on_commit)
@ -959,7 +971,7 @@ class CalibreDB:
pagination = None
result = self.search_query(term, config, *join).order_by(*order).all()
result_count = len(result)
if offset != None and limit != None:
if offset is not None and limit is not None:
offset = int(offset)
limit_all = offset + int(limit)
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
@ -1087,9 +1099,3 @@ class Category:
self.id = cat_id
self.rating = rating
self.count = 1
'''class Count:
count = None
def __init__(self, count):
self.count = count'''

View File

@ -33,6 +33,7 @@ from .about import collect_stats
log = logger.create()
class lazyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, LazyString):
@ -40,6 +41,7 @@ class lazyEncoder(json.JSONEncoder):
# Let the base class default method raise the TypeError
return json.JSONEncoder.default(self, obj)
def assemble_logfiles(file_name):
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
wfd = BytesIO()

View File

@ -58,6 +58,8 @@ def load_dependencies(optional=False):
def dependency_check(optional=False):
d = list()
dep_version_int = None
low_check = None
deps = load_dependencies(optional)
for dep in deps:
try:

View File

@ -27,22 +27,6 @@ from shutil import copyfile
from uuid import uuid4
from markupsafe import escape, Markup # dependency of flask
from functools import wraps
# from lxml.etree import ParserError
#try:
# # at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
# from bleach import clean_text as clean_html
# BLEACH = True
#except ImportError:
# try:
# BLEACH = False
# from nh3 import clean as clean_html
# except ImportError:
# try:
# BLEACH = False
# from lxml.html.clean import clean_html
# except ImportError:
# clean_html = None
from flask import Blueprint, request, flash, redirect, url_for, abort, Response
from flask_babel import gettext as _
@ -1006,17 +990,6 @@ def edit_book_comments(comments, book):
modify_date = False
if comments:
comments = clean_string(comments, book.id)
#try:
# if BLEACH:
# comments = clean_html(comments, tags=set(), attributes=set())
# else:
# comments = clean_html(comments)
#except ParserError as e:
# log.error("Comments of book {} are corrupted: {}".format(book.id, e))
# comments = ""
#except TypeError as e:
# log.error("Comments can't be parsed, maybe 'lxml' is too new, try installing 'bleach': {}".format(e))
# comments = ""
if len(book.comments):
if book.comments[0].text != comments:
book.comments[0].text = comments
@ -1075,18 +1048,6 @@ def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
if to_save[cc_string]:
to_save[cc_string] = clean_string(to_save[cc_string], book_id)
#try:
# if BLEACH:
# to_save[cc_string] = clean_html(to_save[cc_string], tags=set(), attributes=set())
# else:
# to_save[cc_string] = clean_html(to_save[cc_string])
#except ParserError as e:
# log.error("Customs Comments of book {} are corrupted: {}".format(book_id, e))
# to_save[cc_string] = ""
#except TypeError as e:
# to_save[cc_string] = ""
# log.error("Customs Comments can't be parsed, maybe 'lxml' is too new, "
# "try installing 'bleach': {}".format(e))
elif c.datatype == 'datetime':
try:
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
@ -1313,8 +1274,6 @@ def search_objects_remove(db_book_object, db_type, input_elements):
del_elements = []
for c_elements in db_book_object:
found = False
#if db_type == 'languages':
# type_elements = c_elements.lang_code
if db_type == 'custom':
type_elements = c_elements.value
else:

View File

@ -45,6 +45,7 @@ def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
cf = zip_file.read(zip_cover_path)
return cover.cover_processing(tmp_file_name, cf, extension)
def get_epub_layout(book, book_data):
file_path = os.path.normpath(os.path.join(config.get_book_path(),
book.path, book_data.name + "." + book_data.format.lower()))

View File

@ -53,7 +53,9 @@ def updateEpub(src, dest, filename, data, ):
zf.writestr(filename, data)
def get_content_opf(file_path, ns=default_ns):
def get_content_opf(file_path, ns=None):
if ns is None:
ns = default_ns
epubZip = zipfile.ZipFile(file_path)
txt = epubZip.read('META-INF/container.xml')
tree = etree.fromstring(txt)
@ -154,6 +156,7 @@ def create_new_metadata_backup(book, custom_columns, export_language, translate
return package
def replace_metadata(tree, package):
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
new_element = package.xpath('//metadata', namespaces=default_ns)[0]

View File

@ -31,6 +31,7 @@ from . import config, app, logger, services
log = logger.create()
# custom error page
def error_http(error):
return render_template('http_error.html',
error_code="Error {0}".format(error.code),
@ -52,6 +53,7 @@ def internal_error(error):
instance=config.config_calibre_web_title
), 500
def init_errorhandler():
# http error handling
for ex in default_exceptions:
@ -60,7 +62,6 @@ def init_errorhandler():
elif ex == 500:
app.register_error_handler(ex, internal_error)
if services.ldap:
# Only way of catching the LDAPException upon logging in with LDAP server down
@app.errorhandler(services.ldap.LDAPException)

View File

@ -20,6 +20,7 @@ from tempfile import gettempdir
import os
import shutil
def get_temp_dir():
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
if not os.path.isdir(tmp_dir):

View File

@ -87,6 +87,7 @@ def watch_gdrive():
try:
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
config.config_google_drive_watch_changes_response = result
config.save()
except HttpError as e:
@ -115,6 +116,7 @@ def revoke_watch_gdrive():
config.save()
return redirect(url_for('admin.db_configuration'))
try:
@csrf.exempt
@gdrive.route("/watch/callback", methods=['GET', 'POST'])

View File

@ -207,6 +207,7 @@ def getDrive(drive=None, gauth=None):
log.error("Google Drive error: {}".format(e))
return drive
def listRootFolders():
try:
drive = getDrive(Gdrive.Instance().drive)
@ -235,6 +236,7 @@ def getFolderInFolder(parentId, folderName, drive):
else:
return fileList[0]
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
def getEbooksFolderId(drive=None):
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
@ -538,6 +540,7 @@ def updateGdriveCalibreFromLocal():
if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
shutil.rmtree(os.path.join(config.config_calibre_dir, x))
# update gdrive.db on edit of books title
def updateDatabaseOnEdit(ID, newPath):
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
@ -585,6 +588,7 @@ def get_cover_via_gdrive(cover_path):
else:
return None
# Gets cover file from gdrive
def get_metadata_backup_via_gdrive(metadata_path):
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
@ -608,6 +612,7 @@ def get_metadata_backup_via_gdrive(metadata_path):
else:
return None
# Creates chunks for downloading big files
def partial(total_byte_len, part_size_limit):
s = []
@ -616,6 +621,7 @@ def partial(total_byte_len, part_size_limit):
s.append([p, last])
return s
# downloads files in chunks from gdrive
def do_gdrive_download(df, headers, convert_encoding=False):
total_size = int(df.metadata.get('fileSize'))
@ -655,6 +661,7 @@ oauth_scope:
- https://www.googleapis.com/auth/drive
"""
def update_settings(client_id, client_secret, redirect_uri):
if redirect_uri.endswith('/'):
redirect_uri = redirect_uri[:-1]

View File

@ -19,6 +19,7 @@
from gevent.pywsgi import WSGIHandler
class MyWSGIHandler(WSGIHandler):
def get_environ(self):
env = super().get_environ()

View File

@ -441,9 +441,9 @@ def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
else:
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
try:
old_author_path = os.path.join(calibre_path, old_author_dir)
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
try:
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path))
except OSError as ex:
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
@ -505,7 +505,6 @@ def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_d
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
book = calibre_db.get_book(book_id)
@ -623,6 +622,7 @@ def reset_password(user_id):
ub.session.rollback()
return 0, None
def generate_random_password(min_length):
min_length = max(8, min_length) - 4
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
@ -690,6 +690,7 @@ def valid_email(email):
raise Exception(_("Invalid Email address format"))
return email
def valid_password(check_password):
if config.config_password_policy:
verify = ""
@ -731,7 +732,7 @@ def update_dir_structure(book_id,
def delete_book(book, calibrepath, book_format):
if not book_format:
clear_cover_thumbnail_cache(book.id) ## here it breaks
clear_cover_thumbnail_cache(book.id) # here it breaks
calibre_db.delete_dirty_metadata(book.id)
if config.config_use_google_drive:
return delete_book_gdrive(book, book_format)
@ -943,6 +944,7 @@ def save_cover(img, book_path):
def do_download_file(book, book_format, client, data, headers):
book_name = data.name
download_name = filename = None
if config.config_use_google_drive:
# startTime = time.time()
df = gd.getFileFromEbooksFolder(book.path, book_name + "." + book_format)

View File

@ -82,7 +82,6 @@ def get_language_codes(locale, language_names, remainder=None):
return lang
def get_valid_language_codes(locale, language_names, remainder=None):
lang = list()
if "" in language_names:

View File

@ -48,7 +48,7 @@ import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages
from .epub import get_epub_layout
from .constants import COVER_THUMBNAIL_SMALL #, sqlalchemy_version2
from .constants import COVER_THUMBNAIL_SMALL
from .helper import get_download_link
from .services import SyncToken as SyncToken
from .web import download_required
@ -951,7 +951,8 @@ def HandleBookDeletionRequest(book_uuid):
@csrf.exempt
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
def HandleUnimplementedRequest(dummy=None):
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)", request.base_url)
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
request.base_url)
return redirect_or_proxy_request()
@ -1004,7 +1005,8 @@ def handle_getests():
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
@kobo.route("/v1/deals", methods=["GET", "POST"])
def HandleProductsRequest(dummy=None):
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)", request.base_url)
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
request.base_url)
return redirect_or_proxy_request()

View File

@ -23,6 +23,7 @@ import datetime
from sqlalchemy.sql.expression import or_, and_, true
from sqlalchemy import exc
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
# do nothing (safety precaution)
def add_synced_books(book_id):
@ -50,7 +51,6 @@ def remove_synced_book(book_id, all=False, session=None):
ub.session_commit(_session=session)
def change_archived_books(book_id, state=None, message=None):
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
ub.ArchivedBook.book_id == book_id)).first()

View File

@ -27,6 +27,7 @@ from flask import request
def request_username():
return request.authorization.username
def main():
app = create_app()
@ -48,12 +49,14 @@ def main():
kobo_available = get_kobo_activated()
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
kobo_available = False
kobo = kobo_auth = get_remote_address = None
try:
from .oauth_bb import oauth
oauth_available = True
except ImportError:
oauth_available = False
oauth = None
from . import web_server
init_errorhandler()

View File

@ -25,7 +25,7 @@ try:
import cchardet #optional for better speed
except ImportError:
pass
from cps import logger
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
import cps.logger as logger
@ -33,8 +33,6 @@ import cps.logger as logger
from operator import itemgetter
log = logger.create()
log = logger.create()
class Amazon(Metadata):
__name__ = "Amazon"

View File

@ -217,7 +217,8 @@ class Douban(Metadata):
return match
def _clean_date(self, date: str) -> str:
@staticmethod
def _clean_date(date: str) -> str:
"""
Clean up the date string to be in the format YYYY-MM-DD

View File

@ -205,6 +205,7 @@ def unlink_oauth(provider):
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
return redirect(url_for('web.profile'))
def generate_oauth_blueprints():
if not ub.session.query(ub.OAuthProvider).count():
for provider in ("github", "google"):
@ -291,6 +292,7 @@ if ub.oauth_support:
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
# notify on OAuth provider error
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
def github_error(blueprint, error, error_description=None, error_uri=None):

View File

@ -394,6 +394,7 @@ def feed_shelf(book_id):
and_(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id))).first()
result = list()
pagination = list()
# user is allowed to access shelf
if shelf:
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),

View File

@ -97,7 +97,8 @@ class WebServer(object):
log.warning('Cert path: %s', certfile_path)
log.warning('Key path: %s', keyfile_path)
def _make_gevent_socket_activated(self):
@staticmethod
def _make_gevent_socket_activated():
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
SD_LISTEN_FDS_START = 3
return GeventSocket(fileno=SD_LISTEN_FDS_START)
@ -139,8 +140,8 @@ class WebServer(object):
return ((self.listen_address, self.listen_port),
_readable_listen_address(self.listen_address, self.listen_port))
try:
address = ('::', self.listen_port)
try:
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
except socket.error as ex:
log.error('%s', ex)
@ -301,7 +302,6 @@ class WebServer(object):
log.info("Performing restart of Calibre-Web")
args = self._get_args_for_reloading()
os.execv(args[0].lstrip('"').rstrip('"'), args)
return True
@staticmethod
def shutdown_scheduler():

View File

@ -36,6 +36,7 @@ SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.g
def setup_gmail(token):
# If there are no (valid) credentials available, let the user log in.
creds = None
user_info = None
if "token" in token:
creds = Credentials(
token=token['token'],

View File

@ -32,6 +32,7 @@ except ImportError:
from .. import logger
from ..clean_html import clean_string
class my_GoodreadsClient(GoodreadsClient):
def request(self, *args, **kwargs):
@ -39,6 +40,7 @@ class my_GoodreadsClient(GoodreadsClient):
req = my_GoodreadsRequest(self, *args, **kwargs)
return req.request()
class GoodreadsRequestException(Exception):
def __init__(self, error_msg, url):
self.error_msg = error_msg
@ -125,7 +127,8 @@ def get_other_books(author_info, library_books=None):
identifiers = []
library_titles = []
if library_books:
identifiers = list(reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
identifiers = list(
reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
library_titles = [book.title for book in library_books]
for book in author_info.books:

View File

@ -30,9 +30,11 @@ except ImportError:
log = logger.create()
class LDAPLogger(object):
def write(self, message):
@staticmethod
def write(message):
try:
log.debug(message.strip("\n").replace("\n", ""))
except Exception:
@ -71,6 +73,7 @@ class mySimpleLDap(LDAP):
_ldap = mySimpleLDap()
def init_app(app, config):
if config.config_login_type != constants.LOGIN_LDAP:
return

View File

@ -44,9 +44,11 @@ log = logger.create()
current_milli_time = lambda: int(round(time() * 1000))
class TaskConvert(CalibreTask):
def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None):
super(TaskConvert, self).__init__(task_message)
self.worker_thread = None
self.file_path = file_path
self.book_id = book_id
self.title = ""
@ -67,6 +69,7 @@ class TaskConvert(CalibreTask):
data.name + "." + self.settings['old_book_format'].lower())
df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg")
if df:
datafile_cover = None
datafile = os.path.join(config.get_book_path(),
cur_book.path,
data.name + "." + self.settings['old_book_format'].lower())
@ -85,7 +88,7 @@ class TaskConvert(CalibreTask):
format=self.settings['old_book_format'],
fn=data.name + "." + self.settings['old_book_format'].lower())
worker_db.session.close()
return self._handleError(self, error_message)
return self._handleError(error_message)
filename = self._convert_ebook_format()
if config.config_use_google_drive:
@ -246,6 +249,7 @@ class TaskConvert(CalibreTask):
return check, None
def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover):
path_tmp_opf = None
try:
# path_tmp_opf = self._embed_metadata()
if config.config_embed_metadata:

View File

@ -31,7 +31,6 @@ class TaskReconnectDatabase(CalibreTask):
self.listen_address = config.get_config_ipaddress()
self.listen_port = config.config_port
def run(self, worker_thread):
address = self.listen_address if self.listen_address else 'localhost'
port = self.listen_port if self.listen_port else 8083

View File

@ -25,6 +25,7 @@ from flask_babel import lazy_gettext as N_
from ..epub_helper import create_new_metadata_backup
class TaskBackupMetadata(CalibreTask):
def __init__(self, export_language="en",

View File

@ -110,7 +110,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self._handleSuccess()
self.app_db_session.remove()
def get_books_with_covers(self, book_id=-1):
@staticmethod
def get_books_with_covers(book_id=-1):
filter_exp = (db.Books.id == book_id) if book_id != -1 else True
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
books_cover = calibre_db.session.query(db.Books).filter(db.Books.has_cover == 1).filter(filter_exp).all()

View File

@ -22,6 +22,7 @@ from flask_babel import lazy_gettext as N_
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
class TaskUpload(CalibreTask):
def __init__(self, task_message, book_title):
super(TaskUpload, self).__init__(task_message)

View File

@ -198,6 +198,15 @@ See https://github.com/adobe-type-tools/cmap-resources
<div id="secondaryToolbar" class="secondaryToolbar hidden doorHangerRight">
<div id="secondaryToolbarButtonContainer">
{% if current_user.role_download() %}
<button id="secondaryPrint" class="secondaryToolbarButton visibleMediumView" title="Print" tabindex="52" data-l10n-id="pdfjs-print-button">
<span data-l10n-id="pdfjs-print-button-label">Print</span>
</button>
<button id="secondaryDownload" class="secondaryToolbarButton visibleMediumView" title="Save" tabindex="53" data-l10n-id="pdfjs-save-button">
<span data-l10n-id="pdfjs-save-button-label">Save</span>
</button>
{% endif %}
<div class="horizontalToolbarSeparator"></div>
<button id="presentationMode" class="secondaryToolbarButton" title="Switch to Presentation Mode" tabindex="54" data-l10n-id="pdfjs-presentation-mode-button">
@ -316,9 +325,17 @@ See https://github.com/adobe-type-tools/cmap-resources
<span data-l10n-id="pdfjs-editor-stamp-button-label">Add or edit images</span>
</button>
</div>
{% if current_user.role_download() %}
<div id="editorModeSeparator" class="verticalToolbarSeparator"></div>
<button id="print" class="toolbarButton hiddenMediumView" title="Print" tabindex="41" data-l10n-id="pdfjs-print-button">
<span data-l10n-id="pdfjs-print-button-label">Print</span>
</button>
<button id="download" class="toolbarButton hiddenMediumView" title="Save" tabindex="42" data-l10n-id="pdfjs-save-button">
<span data-l10n-id="pdfjs-save-button-label">Save</span>
</button>
{% endif %}
<div class="verticalToolbarSeparator hiddenMediumView"></div>
<button id="secondaryToolbarToggle" class="toolbarButton" title="Tools" tabindex="43" data-l10n-id="pdfjs-tools-button" aria-expanded="false" aria-controls="secondaryToolbar">

View File

@ -268,6 +268,18 @@ class OAuthProvider(Base):
# anonymous user
class Anonymous(AnonymousUserMixin, UserBase):
def __init__(self):
self.kobo_only_shelves_sync = None
self.view_settings = None
self.allowed_column_value = None
self.allowed_tags = None
self.denied_tags = None
self.kindle_mail = None
self.locale = None
self.default_language = None
self.sidebar_view = None
self.id = None
self.role = None
self.name = None
self.loadSettings()
def loadSettings(self):
@ -325,6 +337,7 @@ class User_Sessions(Base):
session_key = Column(String, default="")
def __init__(self, user_id, session_key):
super().__init__()
self.user_id = user_id
self.session_key = session_key
@ -507,6 +520,7 @@ class RemoteAuthToken(Base):
token_type = Column(Integer, default=0)
def __init__(self):
super().__init__()
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
self.expiration = datetime.datetime.now() + datetime.timedelta(minutes=10) # 10 min from now

View File

@ -52,6 +52,8 @@ class Updater(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.web_server = None
self.config = None
self.paused = False
self.can_run = threading.Event()
self.pause()

View File

@ -45,4 +45,4 @@ comicapi>=2.2.0,<3.3.0
jsonschema>=3.2.0,<4.23.0
# Hide console Window on Windows
pywin32>=220,<310
pywin32>=220,<310 ; sys_platform == 'win32'

33
qodana.yaml Normal file
View File

@ -0,0 +1,33 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-python:latest
exclude:
- name: All
paths:
- cps/static/js/libs

View File

@ -6,16 +6,17 @@ Flask-Login>=0.3.2,<0.6.4
Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<3.1.0
iso-639>=0.4.5,<0.5.0
PyPDF>=3.15.6,<4.1.0
PyPDF>=3.15.6,<4.3.0
pytz>=2016.10
requests>=2.28.0,<2.32.0
SQLAlchemy>=1.3.0,<2.1.0
tornado>=6.3,<6.5
Wand>=0.4.4,<0.7.0
unidecode>=0.04.19,<1.4.0
lxml>=3.8.0,<5.2.0
lxml>=4.9.1,<5.3.0
flask-wtf>=0.14.2,<1.3.0
chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0
Flask-Limiter>=2.3.0,<3.6.0
regex>=2022.3.2,<2024.2.25
regex>=2022.3.2,<2024.6.25
bleach>=6.0.0,<6.2.0

View File

@ -53,12 +53,13 @@ install_requires =
tornado>=6.3,<6.5
Wand>=0.4.4,<0.7.0
unidecode>=0.04.19,<1.4.0
lxml>=3.8.0,<5.2.0
lxml>=4.9.1,<5.2.0
flask-wtf>=0.14.2,<1.3.0
chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0
Flask-Limiter>=2.3.0,<3.6.0
regex>=2022.3.2,<2024.2.25
bleach>=6.0.0,<6.2.0
[options.packages.find]

View File

@ -37,20 +37,20 @@
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3" style="margin-top:50px;">
<p class='text-justify attribute'><strong>Start Time: </strong>2024-05-11 18:39:24</p>
<p class='text-justify attribute'><strong>Start Time: </strong>2024-06-19 18:47:42</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Stop Time: </strong>2024-05-12 01:48:22</p>
<p class='text-justify attribute'><strong>Stop Time: </strong>2024-06-20 01:41:47</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Duration: </strong>5h 59 min</p>
<p class='text-justify attribute'><strong>Duration: </strong>5h 43 min</p>
</div>
</div>
</div>
@ -1945,13 +1945,13 @@ AssertionError: &#39;Test 执 to&#39; != &#39;book&#39;
<tr id="su" class="errorClass">
<tr id="su" class="failClass">
<td>TestLoadMetadata</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">
<a onclick="showClassDetail('c17', 1)">Detail</a>
</td>
@ -1959,26 +1959,26 @@ AssertionError: &#39;Test 执 to&#39; != &#39;book&#39;
<tr id="et17.1" class="none bg-info">
<tr id="ft17.1" class="none bg-danger">
<td>
<div class='testcase'>TestLoadMetadata - test_load_metadata</div>
</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et17.1')">ERROR</a>
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft17.1')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_et17.1" class="popup_window test_output" style="display:block;">
<div id="div_ft17.1" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et17.1').style.display='none'"><span
onclick="document.getElementById('div_ft17.1').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_books_metadata.py&#34;, line 90, in test_load_metadata
elif &#39;https://amazon.com/&#39; == results[20][&#39;source&#39;]:
IndexError: list index out of range</pre>
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_books_metadata.py&#34;, line 173, in test_load_metadata
self.assertGreaterEqual(diff(BytesIO(cover), BytesIO(original_cover), delete_diff_file=True), 0.05)
AssertionError: 0.0 not greater than or equal to 0.05</pre>
</div>
<div class="clearfix"></div>
</div>
@ -3804,43 +3804,50 @@ AssertionError: False is not true</pre>
<tr id="su" class="passClass">
<td>TestPipInstall</td>
<td class="text-center">3</td>
<td class="text-center">3</td>
<tr id="su" class="errorClass">
<td>_FailedTest</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">
<a onclick="showClassDetail('c40', 3)">Detail</a>
<a onclick="showClassDetail('c40', 1)">Detail</a>
</td>
</tr>
<tr id='pt40.1' class='hiddenRow bg-success'>
<tr id="et40.1" class="none bg-info">
<td>
<div class='testcase'>TestPipInstall - test_command_start</div>
<div class='testcase'>_FailedTest - test_pip_install</div>
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
<tr id='pt40.2' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestPipInstall - test_foldername_database_location</div>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et40.1')">ERROR</a>
</div>
<!--css div popup start-->
<div id="div_et40.1" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et40.1').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">ImportError: Failed to import test module: test_pip_install
Traceback (most recent call last):
File &#34;/usr/lib/python3.10/unittest/loader.py&#34;, line 436, in _find_test_path
module = self._get_module_from_name(name)
File &#34;/usr/lib/python3.10/unittest/loader.py&#34;, line 377, in _get_module_from_name
__import__(name)
File &#34;/home/ozzie/Development/calibre-web-test/test/test_pip_install.py&#34;, line 14, in &lt;module&gt;
from build_release import make_release
ModuleNotFoundError: No module named &#39;build_release&#39;</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
<tr id='pt40.3' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestPipInstall - test_module_start</div>
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@ -4421,11 +4428,11 @@ AssertionError: False is not true</pre>
<tr id="su" class="failClass">
<tr id="su" class="skipClass">
<td>TestThumbnails</td>
<td class="text-center">8</td>
<td class="text-center">6</td>
<td class="text-center">1</td>
<td class="text-center">7</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">
@ -4498,31 +4505,11 @@ AssertionError: False is not true</pre>
<tr id="ft50.8" class="none bg-danger">
<tr id='pt50.8' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestThumbnails - test_sideloaded_book</div>
</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft50.8')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft50.8" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft50.8').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_thumbnails.py&#34;, line 326, in test_sideloaded_book
self.assertGreaterEqual(diff(BytesIO(list_cover), BytesIO(new_list_cover), delete_diff_file=True), 0.04)
AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@ -5605,8 +5592,8 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr id='total_row' class="text-center bg-grey">
<td>Total</td>
<td>494</td>
<td>480</td>
<td>492</td>
<td>478</td>
<td>3</td>
<td>1</td>
<td>10</td>
@ -5637,7 +5624,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>Platform</th>
<td>Linux 6.5.0-28-generic #29~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Apr 4 14:39:20 UTC 2 x86_64 x86_64</td>
<td>Linux 6.5.0-41-generic #41~22.04.2-Ubuntu SMP PREEMPT_DYNAMIC Mon Jun 3 11:32:55 UTC 2 x86_64 x86_64</td>
<td>Basic</td>
</tr>
@ -5665,6 +5652,12 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<td>Basic</td>
</tr>
<tr>
<th>bleach</th>
<td>6.1.0</td>
<td>Basic</td>
</tr>
<tr>
<th>chardet</th>
<td>4.0.0</td>
@ -5763,13 +5756,13 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>SQLAlchemy</th>
<td>2.0.30</td>
<td>2.0.31</td>
<td>Basic</td>
</tr>
<tr>
<th>tornado</th>
<td>6.4</td>
<td>6.4.1</td>
<td>Basic</td>
</tr>
@ -5793,7 +5786,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestBackupMetadataGdrive</td>
</tr>
@ -5823,7 +5816,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestCliGdrivedb</td>
</tr>
@ -5853,7 +5846,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestEbookConvertCalibreGDrive</td>
</tr>
@ -5883,7 +5876,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestEbookConvertGDriveKepubify</td>
</tr>
@ -5937,7 +5930,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestEditAuthorsGdrive</td>
</tr>
@ -5973,7 +5966,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestEditBooksOnGdrive</td>
</tr>
@ -6015,7 +6008,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestEmbedMetadataGdrive</td>
</tr>
@ -6045,7 +6038,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
<tr>
<th>google-api-python-client</th>
<td>2.129.0</td>
<td>2.134.0</td>
<td>TestSetupGdrive</td>
</tr>
@ -6135,7 +6128,7 @@ AssertionError: 0.03372577030812325 not greater than or equal to 0.04</pre>
</div>
<script>
drawCircle(480, 3, 1, 10);
drawCircle(478, 3, 1, 10);
showCase(5);
</script>