1
0
mirror of https://github.com/janeczku/calibre-web synced 2025-10-23 03:27:37 +00:00

Update mass edit

Refactored delete User function
Updated testresults
This commit is contained in:
Ozzie Isaacs
2024-12-09 17:08:58 +01:00
parent 89e9958222
commit 08527ae3ce
10 changed files with 473 additions and 372 deletions

View File

@@ -40,7 +40,7 @@ from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError, ArgumentError
from sqlalchemy.sql.expression import func, or_, text
from . import constants, logger, helper, services, cli_param
@@ -386,13 +386,12 @@ def list_users():
@user_login_required
@admin_required
def delete_user():
user_ids = request.form.to_dict(flat=False)
users = None
user_ids = request.get_json().get("userid")
message = ""
if "userid[]" in user_ids:
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all()
elif "userid" in user_ids:
users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all()
try:
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids)).all()
except (ArgumentError):
users = None
count = 0
errors = list()
success = list()
@@ -408,10 +407,10 @@ def delete_user():
errors.append({'type': "danger", 'message': str(ex)})
if count == 1:
log.info("User {} deleted".format(user_ids))
log.info("User {} deleted".format(user_ids[0]))
success = [{'type': "success", 'message': message}]
elif count > 1:
log.info("Users {} deleted".format(user_ids))
log.info("Users {} deleted".format(", ".join([str(user_id) for user_id in user_ids])))
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
success.extend(errors)
return make_response(jsonify(success))
@@ -618,6 +617,8 @@ def load_dialogtexts(element_id):
texts["main"] = _('Do you really want to delete this domain?')
elif element_id == "btndeluser":
texts["main"] = _('Do you really want to delete this user?')
elif element_id == "btndelbook":
texts["main"] = _('Do you really want to delete this book?')
elif element_id == "delete_shelf":
texts["main"] = _('Are you sure you want to delete this shelf?')
elif element_id == "select_locale":
@@ -626,6 +627,10 @@ def load_dialogtexts(element_id):
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
elif element_id == "role":
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
elif element_id == "archive_books":
texts["main"] = _('Are you sure you want to change the archive status for the selected book(s)?')
elif element_id == "read_books":
texts["main"] = _('Are you sure you want to change the read status for the selected book(s)?')
elif element_id == "restrictions":
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
elif element_id == "sidebar_view":

View File

@@ -73,17 +73,18 @@ def edit_required(f):
return inner
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
@editbook.route("/ajax/deletebook", methods=["POST"])
@user_login_required
def delete_book_from_details(book_id):
return delete_book_from_table(book_id, "", True) # , mimetype='application/json')
def delete_books_ajax():
book_ids = request.get_json().get("bookid")
return check_delete_book(book_ids, "", True)
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@user_login_required
def delete_book_ajax(book_id, book_format):
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
def delete_book(book_id, book_format):
return check_delete_book(book_id, book_format, False, request.form.to_dict().get('location', ""))
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
@@ -213,7 +214,7 @@ def table_get_custom_enum(c_id):
@login_required_if_no_ano
@edit_required
def edit_list_book(param):
vals = request.form.to_dict()
vals = request.get_json() # form.to_dict(flat=False)
return edit_book_param(param, vals)
@editbook.route("/ajax/editselectedbooks", methods=['POST'])
@@ -233,10 +234,8 @@ def edit_selected_books():
comments = d.get('comments')
checkA = d.get('checkA')
if len(selections) != 0:
for book_id in selections:
vals = {
"pk": book_id,
"pk": selections,
"value": None,
"checkA": checkA,
}
@@ -268,7 +267,6 @@ def edit_selected_books():
vals['value'] = comments
edit_book_param('comments', vals)
return json.dumps({'success': True})
return ""
# Separated from /editbooks so that /editselectedbooks can also use this
#
@@ -284,94 +282,96 @@ def edit_selected_books():
@login_required_if_no_ano
@edit_required
def edit_book_param(param, vals):
book = calibre_db.get_book(vals['pk'])
elements = vals.get('pk',[])
ret = {}
for elem in elements:
book = calibre_db.get_book(elem)
calibre_db.create_functions(config)
sort_param = ""
ret = ""
try:
if param == 'series_index':
edit_book_series_index(vals['value'], book)
ret = make_response(jsonify(success=True, newValue=book.series_index))
ret = jsonify(success=True, newValue=book.series_index)
elif param == 'tags':
edit_book_tags(vals['value'], book)
ret = make_response(jsonify(success=True, newValue=', '.join([tag.name for tag in book.tags])))
ret = jsonify(success=True, newValue=', '.join([tag.name for tag in book.tags]))
elif param == 'series':
edit_book_series(vals['value'], book)
ret = make_response(jsonify(success=True, newValue=', '.join([serie.name for serie in book.series])))
ret = jsonify(success=True, newValue=', '.join([serie.name for serie in book.series]))
elif param == 'publishers':
edit_book_publisher(vals['value'], book)
ret = make_response(jsonify(success=True,
newValue=', '.join([publisher.name for publisher in book.publishers])))
ret = jsonify(success=True,
newValue=', '.join([publisher.name for publisher in book.publishers]))
elif param == 'languages':
invalid = list()
edit_book_languages(vals['value'], book, invalid=invalid)
if invalid:
ret = make_response(jsonify(success=False,
msg='Invalid languages in request: {}'.format(','.join(invalid))))
ret = jsonify(success=False, msg='Invalid languages in request: {}'.format(','.join(invalid)))
else:
lang_names = list()
for lang in book.languages:
lang_names.append(isoLanguages.get_language_name(get_locale(), lang.lang_code))
ret = make_response(jsonify(success=True, newValue=', '.join(lang_names)))
ret = jsonify(success=True, newValue=', '.join(lang_names))
elif param == 'author_sort':
book.author_sort = vals['value']
ret = make_response(jsonify(success=True, newValue=book.author_sort))
ret = jsonify(success=True, newValue=book.author_sort)
elif param == 'title':
sort_param = book.sort
if handle_title_on_edit(book, vals.get('value', "")):
rename_error = helper.update_dir_structure(book.id, config.get_book_path())
if not rename_error:
ret = make_response(jsonify(success=True, newValue=book.title))
ret = jsonify(success=True, newValue=book.title)
else:
ret = make_response(jsonify(success=False, msg=rename_error))
ret = jsonify(success=False, msg=rename_error)
elif param == 'sort':
book.sort = vals['value']
ret = make_response(jsonify(success=True,newValue=book.sort))
ret = jsonify(success=True,newValue=book.sort)
elif param == 'comments':
edit_book_comments(vals['value'], book)
ret = make_response(jsonify(success=True, newValue=book.comments[0].text))
ret = jsonify(success=True, newValue=book.comments[0].text)
elif param == 'authors':
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == True)
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
if not rename_error:
ret = make_response(jsonify(
ret = jsonify(
success=True,
newValue=' & '.join([author.replace('|', ',') for author in input_authors])))
newValue=' & '.join([author.replace('|', ',') for author in input_authors]))
else:
ret = make_response(jsonify(success=False, msg=rename_error))
ret = jsonify(success=False, msg=rename_error)
elif param == 'is_archived':
is_archived = change_archived_books(book.id, vals['value'] == "True",
message="Book {} archive bit set to: {}".format(book.id, vals['value']))
message="Book {} archive bit set to: {}".format(book.id,
vals['value']))
if is_archived:
kobo_sync_status.remove_synced_book(book.id)
return ""
continue
elif param == 'read_status':
ret = helper.edit_book_read_status(book.id, vals['value'] == "True")
if ret:
return ret, 400
error = helper.edit_book_read_status(book.id, vals['value'] == "True")
if error:
return error, 400
continue
elif param.startswith("custom_column_"):
new_val = dict()
new_val[param] = vals['value']
edit_single_cc_data(book.id, book, param[14:], new_val)
# ToDo: Very hacky find better solution
if vals['value'] in ["True", "False"]:
ret = ""
ret = {}
else:
ret = make_response(jsonify(success=True, newValue=vals['value']))
ret = jsonify(success=True, newValue=vals['value'])
else:
return _("Parameter not found"), 400
book.last_modified = datetime.now(timezone.utc)
calibre_db.session.commit()
# revert change for sort if automatic fields link is deactivated
if param == 'title' and vals.get('checkT') == "false":
if param == 'title' and vals.get('checkT') == False:
book.sort = sort_param
calibre_db.session.commit()
except (OperationalError, IntegrityError, StaleDataError) as e:
except (OperationalError, IntegrityError, StaleDataError, AttributeError) as e:
calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e))
ret = make_response(jsonify(success=False,
msg='Database error: {}'.format(e.orig if hasattr(e, "orig") else e)))
ret = jsonify(success=False, msg='Database error: {}'.format(e.orig if hasattr(e, "orig") else e))
return ret
@@ -433,16 +433,6 @@ def archive_selected_books():
return json.dumps({'success': True})
return ""
@editbook.route("/ajax/deleteselectedbooks", methods=['POST'])
@user_login_required
@edit_required
def delete_selected_books():
vals = request.get_json().get('selections')
if vals:
for book_id in vals:
delete_book_from_table(book_id, "", True)
return json.dumps({'success': True})
return ""
@editbook.route("/ajax/readselectedbooks", methods=['POST'])
@user_login_required
@@ -498,7 +488,7 @@ def merge_list_book():
element.format,
element.uncompressed_size,
to_name))
delete_book_from_table(from_book.id, "", True)
check_delete_book([from_book.id], "", True)
return make_response(jsonify(success=True))
return ""
@@ -968,50 +958,56 @@ def delete_whole_book(book_id, book):
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
def render_delete_book_result(book_format, json_response, warning, book_id, location=""):
def render_delete_book_result(book_format, book_id, location=""):
if book_format:
if json_response:
return jsonify([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "success",
"format": book_format,
"message": _('Book Format Successfully Deleted')}])
else:
flash(_('Book Format Successfully Deleted'), category="success")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else:
if json_response:
return jsonify([warning, {"location": get_redirect_location(location, "web.index"),
"type": "success",
"format": book_format,
"message": _('Book Successfully Deleted')}])
else:
flash(_('Book Successfully Deleted'), category="success")
return redirect(get_redirect_location(location, "web.index"))
def delete_book_from_table(book_id, book_format, json_response, location=""):
warning = {}
def check_delete_book(book_id, book_format, json_response, location=""):
if current_user.role_delete_books():
if json_response:
# if json response is set, it's possible to delete more than one book, but never a format is deleted
res = list()
for b in book_id:
ret = delete_book_from_table(b)
if ret:
res.extend([ret])
if len(res) == 0:
return [{"location": get_redirect_location(location, "web.index"),
"type": "success",
"format": "",
"message": _('Book Successfully Deleted')}]
return jsonify(res)
else:
return delete_book_from_UI(book_id, book_format, location)
message = _("You are missing permissions to delete books")
if json_response:
try:
return jsonify({"location": url_for("edit-book.show_edit_book", book_id=int(book_id)),
"type": "danger",
"format": "",
"message": message})
except TypeError as e:
return jsonify({"location": url_for("web.index"), "type": "danger", "format": "",
"message": str(e)})
else:
flash(message, category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
def delete_book_from_UI(book_id, book_format, location=""):
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper())
if not result:
if json_response:
return jsonify([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": error}])
else:
flash(error, category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
if error:
if json_response:
warning = {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "warning",
"format": "",
"message": error}
else:
flash(error, category="warning")
if not book_format:
delete_whole_book(book_id, book)
@@ -1024,28 +1020,45 @@ def delete_book_from_table(book_id, book_format, json_response, location=""):
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
if json_response:
return jsonify([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}])
else:
flash(str(ex), category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else:
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
return render_delete_book_result(book_format, json_response, warning, book_id, location)
message = _("You are missing permissions to delete books")
if json_response:
return jsonify({"location": url_for("edit-book.show_edit_book", book_id=book_id),
return render_delete_book_result(book_format, book_id, location)
def delete_book_from_table(book_id):
book = calibre_db.get_book(book_id)
if book:
try:
result, error = helper.delete_book(book, config.get_book_path(), book_format="")
if not result:
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": message})
"message": error}
delete_whole_book(book_id, book)
calibre_db.session.commit()
if error:
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "warning",
"format": "",
"message": error}
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}
else:
flash(message, category="error")
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
return {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": _('Book with id "{}" could not be deleted: not found'.format(book_id))}
def render_edit_book(book_id):

View File

@@ -307,7 +307,9 @@ def edit_book_read_status(book_id, read_status=None):
if not config.config_read_column:
book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id),
ub.ReadBook.book_id == book_id)).first()
if book:
if not book:
read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id)
book = read_book
if read_status is None:
if book.read_status == ub.ReadBook.STATUS_FINISHED:
book.read_status = ub.ReadBook.STATUS_UNREAD
@@ -315,10 +317,6 @@ def edit_book_read_status(book_id, read_status=None):
book.read_status = ub.ReadBook.STATUS_FINISHED
else:
book.read_status = ub.ReadBook.STATUS_FINISHED if read_status == True else ub.ReadBook.STATUS_UNREAD
else:
read_book = ub.ReadBook(user_id=current_user.id, book_id=book_id)
read_book.read_status = ub.ReadBook.STATUS_FINISHED
book = read_book
if not book.kobo_reading_state:
kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id)
kobo_reading_state.current_bookmark = ub.KoboBookmark()

View File

@@ -56,7 +56,7 @@ def remove_synced_book(book_id, all=False, session=None):
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()
if not archived_book and (state == True or state == None):
if not archived_book: # and (state == True or state == None):
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

View File

@@ -229,10 +229,12 @@ $("#delete_confirm").click(function(event) {
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
} else {
if (ajaxResponse) {
path = getPath() + "/ajax/delete/" + deleteId;
$.ajax({
url: getPath() + "/ajax/deletebook",
method: "post",
url: path,
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"bookid": [deleteId]}),
timeout: 900,
success:function(data) {
data.forEach(function(item) {

View File

@@ -119,18 +119,17 @@ $(function() {
$("#edit_selected_books").attr("aria-disabled", true);
}
if (selections.length < 1) {
$("#delete_selection").addClass("disabled");
$("#delete_selection").attr("aria-disabled", true);
// $("#book_delete_selection").addClass("disabled");
// $("#book_delete_selection").attr("aria-disabled", true);
$("#table_xchange").addClass("disabled");
$("#table_xchange").attr("aria-disabled", true);
} else {
$("#delete_selection").removeClass("disabled");
$("#delete_selection").attr("aria-disabled", false);
// $("#book_delete_selection").removeClass("disabled");
// $("#book_delete_selection").attr("aria-disabled", false);
$("#table_xchange").removeClass("disabled");
$("#table_xchange").attr("aria-disabled", false);
}
handle_header_buttons();
});
// Small block to initialize the state of the author/title sort inputs in metadata form
@@ -153,7 +152,7 @@ $(function() {
})
/////
$("#delete_selection").click(function() {
$("#book_delete_selection").click(function () {
$("#books-table").bootstrapTable("uncheckAll");
});
@@ -162,7 +161,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/mergebooks",
url: getPath() + "/ajax/mergebooks",
data: JSON.stringify({"Merge_books":selections}),
success: function success() {
$("#books-table").bootstrapTable("refresh");
@@ -181,7 +180,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/simulatemerge",
url: getPath() + "/ajax/simulatemerge",
data: JSON.stringify({"Merge_books":selections}),
success: function success(booTitles) {
$('#merge_from').empty();
@@ -207,7 +206,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/editselectedbooks",
url: getPath() + "/ajax/editselectedbooks",
data: JSON.stringify({
"selections": selections,
"title": $("#title_input").val(),
@@ -250,7 +249,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/displayselectedbooks",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-archive-selected-books').empty();
@@ -262,12 +261,12 @@ $(function() {
});
});
$(document).on('click', '#archive_selected_confirm', function(event) {
/*$(document).on('click', '#archive_selected_confirm', function(event) {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/archiveselectedbooks",
url: getPath() + "/ajax/archiveselectedbooks",
data: JSON.stringify({"selections":selections, "archive": true}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
@@ -286,7 +285,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/displayselectedbooks",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-unarchive-selected-books').empty();
@@ -303,7 +302,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/archiveselectedbooks",
url: getPath() + "/ajax/archiveselectedbooks",
data: JSON.stringify({"selections":selections, "archive": false}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
@@ -322,7 +321,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/displayselectedbooks",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-delete-selected-books').empty();
@@ -339,7 +338,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/deleteselectedbooks",
url: getPath() + "/ajax/deleteselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
@@ -358,7 +357,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/displayselectedbooks",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-read-selected-books').empty();
@@ -375,7 +374,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/readselectedbooks",
url: getPath() + "/ajax/readselectedbooks",
data: JSON.stringify({"selections":selections, "markAsRead": true}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
@@ -394,7 +393,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/displayselectedbooks",
url: getPath() + "/ajax/displayselectedbooks",
data: JSON.stringify({"selections":selections}),
success: function success(booTitles) {
$('#display-unread-selected-books').empty();
@@ -411,21 +410,21 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/readselectedbooks",
url: getPath() + "/ajax/readselectedbooks",
data: JSON.stringify({"selections":selections, "markAsRead": false}),
success: function success(booTitles) {
$("#books-table").bootstrapTable("refresh");
$("#books-table").bootstrapTable("uncheckAll");
}
});
});
});*/
$("#table_xchange").click(function() {
$.ajax({
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/xchange",
url: getPath() + "/ajax/xchange",
data: JSON.stringify({"xchange":selections}),
success: function success() {
$("#books-table").bootstrapTable("refresh");
@@ -442,6 +441,10 @@ $(function() {
editable: {
mode: "inline",
emptytext: "<span class='glyphicon glyphicon-plus'></span>",
ajaxOptions: {
contentType: "application/json; charset=utf-8",
dataType: "json",
},
success: function (response, __) {
if (!response.success) return response.msg;
return {newValue: response.newValue};
@@ -449,7 +452,8 @@ $(function() {
params: function (params) {
params.checkA = $('#autoupdate_authorsort').prop('checked');
params.checkT = $('#autoupdate_titlesort').prop('checked');
return params
params.pk = [params.pk];
return JSON.stringify(params);
}
}
};
@@ -483,7 +487,7 @@ $(function() {
searchAlign: "left",
showSearchButton : true,
searchOnEnterKey: true,
checkboxHeader: false,
checkboxHeader: true,
maintainMetaData: true,
responseHandler: responseHandler,
columns: column,
@@ -497,7 +501,7 @@ $(function() {
$.ajax({
method:"get",
dataType: "json",
url: window.location.pathname + "/../ajax/sort_value/" + field + "/" + row.id,
url: getPath() + "/ajax/sort_value/" + field + "/" + row.id,
success: function success(data) {
var key = Object.keys(data)[0];
$("#books-table").bootstrapTable("updateCellByUniqueId", {
@@ -509,6 +513,66 @@ $(function() {
});
}
},
onPostBody () {
// Remove all checkboxes from Headers for showing the texts in the column selector
$('.columns [data-field]').each(function(){
var elText = $(this).next().text();
$(this).next().empty();
var index = elText.lastIndexOf('\n', elText.length - 2);
if ( index > -1) {
elText = elText.substr(index);
}
$(this).next().text(elText);
});
},
onPostHeader() {
$(".form-check").each(function () {
var item = $(this).parent();
var parent = item.parent().parent();
if (parent.prop('nodeName') === "TH") {
item.prependTo(parent);
}
});
if ($(".button_head").length) {
if (!$._data($(".button_head").get(0), "events")) {
$(".button_head").on("click", function () {
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
confirmDialog(
"btndelbook",
"GeneralDeleteModal",
0,
function () {
$.ajax({
method: "post",
url: getPath() + "/ajax/deletebook",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"bookid": result}),
success: function (data) {
selections = selections.filter((el) => !result.includes(el));
handleListServerResponse(data);
},
error: function (data) {
handleListServerResponse([{type: "danger", message: data.responseText}])
},
});
}
);
});
}
}
if ($(".check_head").length) {
if (!$._data($(".check_head").get(0), "events")) {
$(".check_head").on("change", function () {
var val = $(this).data("set");
var name = $(this).data("name");
var data = $(this).data("val");
bookCheckboxHeader(val, name, data);
});
}
}
},
// eslint-disable-next-line no-unused-vars
onColumnSwitch: function (field, checked) {
var visible = $("#books-table").bootstrapTable("getVisibleColumns");
@@ -525,10 +589,16 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/table_settings",
url: getPath() + "/ajax/table_settings",
data: "{" + st + "}",
});
handle_header_buttons();
},
onLoadSuccess: function() {
$("input:radio.check_head:checked").each(function () {
$(this).prop('checked', false);
});
}
});
$("#domain_allow_submit").click(function(event) {
@@ -537,7 +607,7 @@ $(function() {
$(this).closest("form").submit();
$.ajax ({
method:"get",
url: window.location.pathname + "/../../ajax/domainlist/1",
url: getPath() + "/ajax/domainlist/1",
async: true,
timeout: 900,
success:function(data) {
@@ -558,7 +628,7 @@ $(function() {
$(this).closest("form").submit();
$.ajax ({
method:"get",
url: window.location.pathname + "/../../ajax/domainlist/0",
url: getPath() + "/ajax/domainlist/0",
async: true,
timeout: 900,
success:function(data) {
@@ -576,12 +646,12 @@ $(function() {
function domainHandle(domainId) {
$.ajax({
method:"post",
url: window.location.pathname + "/../../ajax/deletedomain",
url: getPath() + "/ajax/deletedomain",
data: {"domainid":domainId}
});
$.ajax({
method:"get",
url: window.location.pathname + "/../../ajax/domainlist/1",
url: getPath() + "/ajax/domainlist/1",
async: true,
timeout: 900,
success:function(data) {
@@ -590,7 +660,7 @@ $(function() {
});
$.ajax({
method:"get",
url: window.location.pathname + "/../../ajax/domainlist/0",
url: getPath() + "/ajax/domainlist/0",
async: true,
timeout: 900,
success:function(data) {
@@ -826,7 +896,7 @@ $(function() {
method:"post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../../ajax/user_table_settings",
url: getPath() + "/ajax/user_table_settings",
data: "{" + st + "}",
});
handle_header_buttons();
@@ -852,8 +922,8 @@ $(function() {
function handle_header_buttons () {
if (selections.length < 1) {
$("#user_delete_selection").addClass("disabled");
$("#user_delete_selection").attr("aria-disabled", true);
$(".mass_selection").addClass("disabled");
$(".mass_selection").attr("aria-disabled", true);
$(".check_head").attr("aria-disabled", true);
$(".check_head").attr("disabled", true);
$(".check_head").prop('checked', false);
@@ -865,8 +935,8 @@ function handle_header_buttons () {
$(".multi_selector").attr("disabled", true);
$(".header_select").attr("disabled", true);
} else {
$("#user_delete_selection").removeClass("disabled");
$("#user_delete_selection").attr("aria-disabled", false);
$(".mass_selection").removeClass("disabled");
$(".mass_selection").attr("aria-disabled", false);
$(".check_head").attr("aria-disabled", false);
$(".check_head").removeAttr("disabled");
$(".button_head").attr("aria-disabled", false);
@@ -875,8 +945,10 @@ function handle_header_buttons () {
$(".multi_head").removeClass("hidden");
$(".multi_selector").attr("aria-disabled", false);
$(".multi_selector").removeAttr("disabled");
$('.multi_selector').selectpicker('refresh');
$(".header_select").removeAttr("disabled");
if (typeof $.fn.selectpicker === "function") {
$('.multi_selector').selectpicker('refresh');
}
}
}
@@ -1045,7 +1117,7 @@ function move_header_elements() {
function () {
$.ajax({
method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "value": values, "action": val},
success: function (data) {
handleListServerResponse(data);
@@ -1059,7 +1131,6 @@ function move_header_elements() {
});
}
}
$("#user_delete_selection").click(function () {
$("#user-table").bootstrapTable("uncheckAll");
});
@@ -1090,8 +1161,10 @@ function move_header_elements() {
function () {
$.ajax({
method: "post",
url: window.location.pathname + "/../../ajax/deleteuser",
data: {"userid": result},
url: getPath() + "/ajax/deleteuser",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"userid": result}),
success: function (data) {
selections = selections.filter((el) => !result.includes(el));
handleListServerResponse(data);
@@ -1117,7 +1190,7 @@ function handleListServerResponse (data) {
'</div>');
});
}
$("#user-table").bootstrapTable("refresh");
$(".table.table-striped").bootstrapTable("refresh");
}
function checkboxChange(checkbox, userId, field, field_index) {
@@ -1132,20 +1205,21 @@ function checkboxChange(checkbox, userId, field, field_index) {
});
}
function BookCheckboxChange(checkbox, userId, field) {
function BookCheckboxChange(checkbox, bookId, field) {
var value = checkbox.checked ? "True" : "False";
var element = checkbox;
$.ajax({
method: "post",
url: getPath() + "/ajax/editbooks/" + field,
data: {"pk": userId, "value": value},
data: JSON.stringify({"pk": [bookId], "value": value}),
contentType: "application/json; charset=utf-8",
dataType: "json",
error: function(data) {
element.checked = !element.checked;
handleListServerResponse([{type:"danger", message:data.responseText}])
},
success: handleListServerResponse
});
console.log("test");
}
function selectHeader(element, field) {
@@ -1154,7 +1228,7 @@ function selectHeader(element, field) {
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({
method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "value": element.value},
error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}])
@@ -1167,12 +1241,35 @@ function selectHeader(element, field) {
}
}
function bookCheckboxHeader(CheckboxState, text, field_index) {
confirmDialog(text, "GeneralChangeModal", 0, function() {
var result = $('#books-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({
method: "post",
url: getPath() + "/ajax/editbooks/" + field_index,
data: JSON.stringify({"pk": result, "field_index": field_index, "value": CheckboxState}),
contentType: "application/json; charset=utf-8",
dataType: "json",
error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}])
},
success: function (data) {
handleListServerResponse (data, true)
},
});
},function() {
$("input:radio.check_head:checked").each(function() {
$(this).prop('checked', false);
});
});
}
function checkboxHeader(CheckboxState, field, field_index) {
confirmDialog(field, "GeneralChangeModal", 0, function() {
var result = $('#user-table').bootstrapTable('getSelections').map(a => a.id);
$.ajax({
method: "post",
url: window.location.pathname + "/../../ajax/editlistusers/" + field,
url: getPath() + "/ajax/editlistusers/" + field,
data: {"pk": result, "field_index": field_index, "value": CheckboxState},
error: function (data) {
handleListServerResponse([{type:"danger", message:data.responseText}])
@@ -1196,8 +1293,10 @@ function deleteUser(a,id){
function() {
$.ajax({
method:"post",
url: window.location.pathname + "/../../ajax/deleteuser",
data: {"userid":id},
url: getPath() + "/ajax/deleteuser",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"userid": [id]}),
success: function (data) {
userId = parseInt(id, 10);
selections = selections.filter(item => item !== userId);
@@ -1224,8 +1323,10 @@ function storeLocation() {
function user_handle (userId) {
$.ajax({
method:"post",
url: window.location.pathname + "/../../ajax/deleteuser",
data: {"userid":userId}
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({"userid": [userId]}),
url: getPath() + "/ajax/deleteuser",
});
$("#user-table").bootstrapTable("refresh");
}
@@ -1233,6 +1334,5 @@ function user_handle (userId) {
function shorten_html(value, response) {
if(value) {
$(this).html("[...]");
// value.split('\n').slice(0, 2).join("") +
}
}

View File

@@ -15,27 +15,28 @@
{%- endmacro %}
{% macro book_checkbox_row(parameter, show_text, sort) -%}
<th data-name="{{parameter}}" data-field="{{parameter}}" data-switchable="false"
<th data-name="{{parameter}}" data-field="{{parameter}}"
{% if sort %}data-sortable="true" {% endif %}
data-visible="{{visiblility.get(parameter)}}"
data-formatter="bookCheckboxFormatter">
{% if parameter == "is_archived" %}
<div class="btn btn-default disabled" id="archive_selected_books" aria-disabled="true">
{{_('Archive selected books')}}
<div class="form-check">
<div>
<input type="radio" class="check_head" data-set="True" data-val="{{ parameter }}" name="options_archive_selected_books" id="false_archive_selected_books" data-name="archive_books" disabled>{{_('Archive selected books')}}
</div>
<div>
<input type="radio" class="check_head" data-set="False" data-val="{{ parameter }}" name="options_unarchive_selected_books" data-name="archive_books" disabled>{{_('Unarchive selected books')}}
</div>
<br>
<div class="btn btn-default disabled" id="unarchive_selected_books" aria-disabled="true">
{{_('Unarchive selected books')}}
</div>
<br>
{% elif parameter == "read_status" %}
<div class="btn btn-default disabled" id="read_selected_books" aria-disabled="true">
{{_('Mark selected books as read')}}
<div class="form-check">
<div>
<input type="radio" class="check_head" data-set="True" data-val="{{ parameter }}" name="options_read_selected_books" id="false_read_selected_books" data-name="read_books" disabled>{{_('Mark selected books as read')}}
</div>
<div>
<input type="radio" class="check_head" data-set="False" data-val="{{ parameter }}" name="options_unread_selected_books" data-name="read_books" disabled>{{_('Mark selected books as unread')}}</div>
</div>
</div>
<br>
<div class="btn btn-default disabled" id="unread_selected_books" aria-disabled="true">
{{_('Mark selected books as unread')}}</div>
<br>
{% endif %}
{{show_text}}
</th>
@@ -55,7 +56,7 @@
<div class="btn btn-default disabled" id="merge_books" aria-disabled="true">
{{_('Merge selected books')}}
</div>
<div class="btn btn-default disabled" id="delete_selection" aria-disabled="true">
<div class="btn btn-default disabled mass_selection" id="book_delete_selection" aria-disabled="true">
{{_('Clear selections')}}
</div>
<div class="btn btn-default disabled" id="edit_selected_books" aria-disabled="true">
@@ -82,7 +83,7 @@
<thead>
<tr>
{% if current_user.role_edit() %}
<th data-field="state" data-checkbox="true" data-sortable="true"></th>
<th data-field="state" data-checkbox="true" data-visible="true" data-sortable="true"></th>
{% endif %}
<th data-field="id" id="id" data-visible="false" data-switchable="false"></th>
{{ text_table_row('title', _('Enter Title'),_('Title'), true, true) }}
@@ -123,9 +124,9 @@
{% endfor %}
{% if current_user.role_delete_books() and current_user.role_edit()%}
<th data-align="right" data-formatter="EbookActions" data-switchable="false">
<div class="btn btn-default disabled" id="delete_selected_books" aria-disabled="true">
<div><div class="btn btn-default button_head disabled" aria-disabled="true">
{{_('Delete selected books')}}
</div>
</div></div>
<br>
{{_('Delete')}}
</th>
@@ -137,6 +138,8 @@
{% endblock %}
{% block modal %}
{{ delete_book(current_user.role_delete_books()) }}
{{ delete_confirm_modal() }}
{{ change_confirm_modal() }}
{% if current_user.role_edit() %}
<div class="modal fade" id="mergeModal" role="dialog" aria-labelledby="metaMergeLabel">
<div class="modal-dialog">
@@ -162,7 +165,7 @@
</div>
</div>
<div class="modal fade" id="delete_selected_modal" role="dialog" aria-labelledby="metaDeleteSelectedLabel">
<!--div class="modal fade" id="delete_selected_modal" role="dialog" aria-labelledby="metaDeleteSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
@@ -180,7 +183,7 @@
</div>
</div>
</div>
</div>
</div-->
<div class="modal fade" id="archive_selected_modal" role="dialog" aria-labelledby="metaArchiveSelectedLabel">
<div class="modal-dialog">
@@ -265,7 +268,7 @@
<div class="modal fade" id="edit_selected_modal" role="dialog" aria-labelledby="metaEditSelectedLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header text-center">
<div class="modal-header bg-info text-center">
<span>{{_('Edit Metadata')}}</span>
</div>
<div class="modal-body">

View File

@@ -121,7 +121,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-xs-12 col-sm-12">
<div class="row">
<div class="btn btn-default disabled" id="user_delete_selection" aria-disabled="true">{{_('Remove Selections')}}</div>
<div class="btn btn-default disabled mass_selection" id="user_delete_selection" aria-disabled="true">{{_('Clear selections')}}</div>
</div>
</div>
<table id="user-table" class="table table-no-bordered table-striped"

View File

@@ -71,13 +71,13 @@ content-type = "text/markdown"
gdrive = [
"google-api-python-client>=1.7.11,<2.200.0",
"gevent>20.6.0,<24.3.0",
"greenlet>=0.4.17,<3.1.0",
"greenlet>=0.4.17,<3.2.0",
"httplib2>=0.9.2,<0.23.0",
"oauth2client>=4.0.0,<4.1.4",
"uritemplate>=3.0.0,<4.2.0",
"pyasn1-modules>=0.0.8,<0.5.0",
"pyasn1>=0.1.9,<0.7.0",
"PyDrive2>=1.3.1,<1.20.0",
"PyDrive2>=1.3.1,<1.22.0",
"PyYAML>=3.12,<6.1",
"rsa>=3.4.2,<4.10.0",
]

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-12-06 17:23:58</p>
<p class='text-justify attribute'><strong>Start Time: </strong>2024-12-12 21:32:32</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-12-07 00:45:19</p>
<p class='text-justify attribute'><strong>Stop Time: </strong>2024-12-13 04:26:53</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>6h 11 min</p>
<p class='text-justify attribute'><strong>Duration: </strong>5h 49 min</p>
</div>
</div>
</div>
@@ -2074,11 +2074,11 @@ IndexError: list index out of range</pre>
<tr id="su" class="failClass">
<tr id="su" class="passClass">
<td>TestEditBooksOnGdrive</td>
<td class="text-center">18</td>
<td class="text-center">16</td>
<td class="text-center">2</td>
<td class="text-center">18</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">
@@ -2205,31 +2205,11 @@ IndexError: list index out of range</pre>
<tr id="ft19.14" class="none bg-danger">
<tr id='pt19.14' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestEditBooksOnGdrive - test_edit_rating</div>
</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft19.14')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft19.14" 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_ft19.14').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_ebooks_gdrive.py&#34;, line 632, in test_edit_rating
self.assertEqual(4, values[&#39;rating&#39;])
AssertionError: 4 != 0</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@@ -2261,31 +2241,11 @@ AssertionError: 4 != 0</pre>
<tr id="ft19.18" class="none bg-danger">
<tr id='pt19.18' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestEditBooksOnGdrive - test_watch_metadata</div>
</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft19.18')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft19.18" 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_ft19.18').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_ebooks_gdrive.py&#34;, line 976, in test_watch_metadata
self.assertNotIn(&#39;series&#39;, book)
AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;reader&#39;: [], &#39;title&#39;: &#39;testbook&#39;, &#39;author&#39;: [&#39;John Döe&#39;], &#39;rating&#39;: 0, &#39;languages&#39;: [&#39;English&#39;], &#39;identifier&#39;: [], &#39;cover&#39;: &#39;/cover/5/og?c=1733511155&#39;, &#39;tag&#39;: [], &#39;publisher&#39;: [&#39;Randomhäus&#39;], &#39;pubdate&#39;: &#39;Jan 19, 2017&#39;, &#39;comment&#39;: &#39;Lorem ipsum dolor sit amet, consectetuer adipiscing elit.Aenean commodo ligula eget dolor.Aenean massa.Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem.Nulla consequat massa quis enim.Donec pede justo, fringilla vel, aliquet nec, vulputate&#39;, &#39;add_shelf&#39;: [], &#39;del_shelf&#39;: [], &#39;edit_enable&#39;: True, &#39;kindle&#39;: None, &#39;kindlebtn&#39;: None, &#39;download&#39;: [&#39;EPUB\n (6.7 kB)&#39;], &#39;read&#39;: False, &#39;archived&#39;: False, &#39;series_all&#39;: &#39;Book 1 of test&#39;, &#39;series_index&#39;: &#39;1&#39;, &#39;series&#39;: &#39;test&#39;, &#39;cust_columns&#39;: []}</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@@ -3579,11 +3539,11 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr id="su" class="passClass">
<tr id="su" class="failClass">
<td>TestMergeBooksList</td>
<td class="text-center">2</td>
<td class="text-center">2</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">
@@ -3602,11 +3562,31 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr id='pt37.2' class='hiddenRow bg-success'>
<tr id="ft37.2" class="none bg-danger">
<td>
<div class='testcase'>TestMergeBooksList - test_delete_book</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft37.2')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft37.2" 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_ft37.2').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_merge_books_list.py&#34;, line 67, in test_delete_book
self.assertTrue(self.check_element_on_page((By.ID, &#34;flash_warning&#34;)))
AssertionError: False is not true</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
@@ -5841,8 +5821,8 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr id='total_row' class="text-center bg-grey">
<td>Total</td>
<td>523</td>
<td>513</td>
<td>2</td>
<td>514</td>
<td>1</td>
<td>1</td>
<td>7</td>
<td>&nbsp;</td>
@@ -6046,7 +6026,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestBackupMetadataGdrive</td>
</tr>
@@ -6076,7 +6056,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestCliGdrivedb</td>
</tr>
@@ -6106,7 +6086,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestEbookConvertCalibreGDrive</td>
</tr>
@@ -6136,7 +6116,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestEbookConvertGDriveKepubify</td>
</tr>
@@ -6184,7 +6164,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestEditAuthorsGdrive</td>
</tr>
@@ -6220,7 +6200,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestEditBooksOnGdrive</td>
</tr>
@@ -6262,7 +6242,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestEmbedMetadataGdrive</td>
</tr>
@@ -6292,7 +6272,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.154.0</td>
<td>2.155.0</td>
<td>TestSetupGdrive</td>
</tr>
@@ -6388,7 +6368,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
</div>
<script>
drawCircle(513, 2, 1, 7);
drawCircle(514, 1, 1, 7);
showCase(5);
</script>