mirror of
https://github.com/janeczku/calibre-web
synced 2024-11-24 10:37:23 +00:00
Merge branch 'master' into Develop
# Conflicts: # cps/services/SyncToken.py # cps/static/js/kthoom.js # cps/templates/readcbr.html
This commit is contained in:
commit
cfa309f0d1
@ -33,7 +33,7 @@ from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||||
from flask import Markup
|
||||
from markupsafe import Markup
|
||||
from flask_login import login_required, current_user, logout_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||||
|
@ -663,7 +663,7 @@ class CalibreDB:
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine))
|
||||
bind=cls.engine, future=True))
|
||||
for inst in cls.instances:
|
||||
inst.init_session()
|
||||
|
||||
|
@ -25,16 +25,15 @@ from datetime import datetime
|
||||
import json
|
||||
from shutil import copyfile
|
||||
from uuid import uuid4
|
||||
from markupsafe import escape # dependency of flask
|
||||
from markupsafe import escape, Markup # dependency of flask
|
||||
from functools import wraps
|
||||
import re
|
||||
|
||||
try:
|
||||
from lxml.html.clean import clean_html, Cleaner
|
||||
except ImportError:
|
||||
clean_html = None
|
||||
|
||||
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
|
||||
from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from flask_babel import get_locale
|
||||
|
@ -21,10 +21,11 @@ import zipfile
|
||||
from lxml import etree
|
||||
|
||||
from . import isoLanguages, cover
|
||||
from . import config
|
||||
from . import config, logger
|
||||
from .helper import split_authors
|
||||
from .constants import BookMeta
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||
if cover_file is None:
|
||||
@ -49,15 +50,20 @@ def get_epub_layout(book, book_data):
|
||||
}
|
||||
file_path = os.path.normpath(os.path.join(config.config_calibre_dir, book.path, book_data.name + "." + book_data.format.lower()))
|
||||
|
||||
try:
|
||||
epubZip = zipfile.ZipFile(file_path)
|
||||
txt = epubZip.read('META-INF/container.xml')
|
||||
tree = etree.fromstring(txt)
|
||||
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epubZip.read(cfname)
|
||||
|
||||
tree = etree.fromstring(cf)
|
||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
||||
|
||||
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=ns)
|
||||
except (etree.XMLSyntaxError, KeyError, IndexError) as e:
|
||||
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
||||
layout = []
|
||||
|
||||
if len(layout) == 0:
|
||||
return None
|
||||
|
@ -732,28 +732,27 @@ def delete_book(book, calibrepath, book_format):
|
||||
return delete_book_file(book, calibrepath, book_format)
|
||||
|
||||
|
||||
def get_cover_on_failure(use_generic_cover):
|
||||
if use_generic_cover:
|
||||
def get_cover_on_failure():
|
||||
try:
|
||||
return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
|
||||
except PermissionError:
|
||||
log.error("No permission to access generic_cover.jpg file.")
|
||||
abort(403)
|
||||
abort(404)
|
||||
|
||||
|
||||
def get_book_cover(book_id, resolution=None):
|
||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
|
||||
return get_book_cover_internal(book, resolution=resolution)
|
||||
|
||||
|
||||
# Called only by kobo sync -> cover not found should be answered with 404 and not with default cover
|
||||
def get_book_cover_with_uuid(book_uuid, resolution=None):
|
||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||
return get_book_cover_internal(book, use_generic_cover_on_failure=False, resolution=resolution)
|
||||
if not book:
|
||||
return # allows kobo.HandleCoverImageRequest to proxy request
|
||||
return get_book_cover_internal(book, resolution=resolution)
|
||||
|
||||
|
||||
def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None):
|
||||
def get_book_cover_internal(book, resolution=None):
|
||||
if book and book.has_cover:
|
||||
|
||||
# Send the book cover thumbnail if it exists in cache
|
||||
@ -769,16 +768,16 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
|
||||
if config.config_use_google_drive:
|
||||
try:
|
||||
if not gd.is_gdrive_ready():
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
path = gd.get_cover_via_gdrive(book.path)
|
||||
if path:
|
||||
return redirect(path)
|
||||
else:
|
||||
log.error('{}/cover.jpg not found on Google Drive'.format(book.path))
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
|
||||
# Send the book cover from the Calibre directory
|
||||
else:
|
||||
@ -786,9 +785,9 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
|
||||
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
|
||||
return send_from_directory(cover_file_path, "cover.jpg")
|
||||
else:
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
else:
|
||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||
return get_cover_on_failure()
|
||||
|
||||
|
||||
def get_book_cover_thumbnail(book, resolution):
|
||||
@ -811,7 +810,7 @@ def get_series_thumbnail_on_failure(series_id, resolution):
|
||||
.filter(db.Books.has_cover == 1) \
|
||||
.first()
|
||||
|
||||
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
|
||||
return get_book_cover_internal(book, resolution=resolution)
|
||||
|
||||
|
||||
def get_series_cover_thumbnail(series_id, resolution=None):
|
||||
|
26
cps/kobo.py
26
cps/kobo.py
@ -930,20 +930,26 @@ def get_current_bookmark_response(current_bookmark):
|
||||
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
||||
if not book_cover:
|
||||
if config.config_kobo_proxy:
|
||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
||||
try:
|
||||
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
|
||||
except ValueError:
|
||||
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=resolution)
|
||||
if book_cover:
|
||||
log.debug("Serving local cover image of book %s" % book_uuid)
|
||||
return book_cover
|
||||
|
||||
if not config.config_kobo_proxy:
|
||||
log.debug("Returning 404 for cover image of unknown book %s" % book_uuid)
|
||||
# additional proxy request make no sense, -> direct return
|
||||
return abort(404)
|
||||
|
||||
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
|
||||
return redirect(KOBO_IMAGEHOST_URL +
|
||||
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
||||
width=width,
|
||||
height=height), 307)
|
||||
else:
|
||||
log.debug("Cover for unknown book: %s requested" % book_uuid)
|
||||
# additional proxy request make no sense, -> direct return
|
||||
return make_response(jsonify({}))
|
||||
log.debug("Cover request received for book %s" % book_uuid)
|
||||
return book_cover
|
||||
|
||||
|
||||
@kobo.route("")
|
||||
|
@ -98,7 +98,7 @@ class Amazon(Metadata):
|
||||
try:
|
||||
match.authors = [next(
|
||||
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
||||
x.findAll(text=True))).strip()
|
||||
x.findAll(string=True))).strip()
|
||||
for x in soup2.findAll("span", attrs={"class": "author"})]
|
||||
except (AttributeError, TypeError, StopIteration):
|
||||
match.authors = ""
|
||||
|
@ -102,7 +102,7 @@ class LubimyCzytac(Metadata):
|
||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||
TAGS = "//nav[@aria-label='breadcrumb']//a[contains(@href,'/ksiazki/k/')]/text()"
|
||||
TAGS = "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
||||
|
||||
RATING = "//meta[@property='books:rating:value']/@content"
|
||||
COVER = "//meta[@property='og:image']/@content"
|
||||
|
@ -7279,6 +7279,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
float: right
|
||||
}
|
||||
|
||||
body.blur #main-nav + #scnd-nav .create-shelf, body.blur #main-nav + .col-sm-2 #scnd-nav .create-shelf {
|
||||
float: none;
|
||||
margin: 5px 0 10px -10px;
|
||||
}
|
||||
|
||||
#main-nav + #scnd-nav .nav-head.hidden-xs {
|
||||
display: list-item !important;
|
||||
width: 225px
|
||||
|
@ -40,6 +40,7 @@ $(".sendbtn-form").click(function() {
|
||||
$.ajax({
|
||||
method: 'post',
|
||||
url: $(this).data('href'),
|
||||
data: {csrf_token: $("input[name='csrf_token']").val()},
|
||||
success: function (data) {
|
||||
handleResponse(data)
|
||||
}
|
||||
|
@ -71,6 +71,7 @@ var settings = {
|
||||
fitMode: kthoom.Key.B,
|
||||
theme: "light",
|
||||
direction: 0, // 0 = Left to Right, 1 = Right to Left
|
||||
nextPage: 0, // 0 = Reset to Top, 1 = Remember Position
|
||||
scrollbar: 1, // 0 = Hide Scrollbar, 1 = Show Scrollbar
|
||||
pageDisplay: 0 // 0 = Single Page, 1 = Long Strip
|
||||
};
|
||||
@ -206,7 +207,7 @@ function initProgressClick() {
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromArrayBuffer(ab) {
|
||||
function loadFromArrayBuffer(ab, lastCompletion = 0) {
|
||||
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
||||
loadArchiveFormats(['rar', 'zip', 'tar'], function() {
|
||||
// Open the file as an archive
|
||||
@ -246,7 +247,7 @@ function loadFromArrayBuffer(ab) {
|
||||
} else {
|
||||
$("#left").show();
|
||||
}
|
||||
updatePage();
|
||||
updatePage(lastCompletion);
|
||||
}
|
||||
} else {
|
||||
totalImages--;
|
||||
@ -261,6 +262,13 @@ function loadFromArrayBuffer(ab) {
|
||||
}
|
||||
|
||||
function scrollTocToActive() {
|
||||
// Scroll to the thumbnail in the TOC on page change
|
||||
$("#tocView").stop().animate({
|
||||
scrollTop: $("#tocView a.active").position().top
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function updatePage() {
|
||||
$(".page").text((currentImage + 1 ) + "/" + totalImages);
|
||||
|
||||
// Mark the current page in the TOC
|
||||
@ -272,19 +280,20 @@ function scrollTocToActive() {
|
||||
// Set it to active
|
||||
.addClass("active");
|
||||
|
||||
// Scroll to the thumbnail in the TOC on page change
|
||||
$("#tocView").stop().animate({
|
||||
scrollTop: $("#tocView a.active").position().top
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function updatePage() {
|
||||
scrollTocToActive();
|
||||
scrollCurrentImageIntoView();
|
||||
updateProgress();
|
||||
pageDisplayUpdate();
|
||||
setTheme();
|
||||
|
||||
if (imageFiles[currentImage]) {
|
||||
setImage(imageFiles[currentImage].dataURI);
|
||||
} else {
|
||||
setImage("loading");
|
||||
}
|
||||
|
||||
$("body").toggleClass("dark-theme", settings.theme === "dark");
|
||||
$("#mainContent").toggleClass("disabled-scrollbar", settings.scrollbar === 0);
|
||||
|
||||
kthoom.setSettings();
|
||||
kthoom.saveSettings();
|
||||
}
|
||||
@ -359,6 +368,7 @@ function setImage(url, _canvas) {
|
||||
img.onerror = function() {
|
||||
canvas.width = innerWidth - 100;
|
||||
canvas.height = 300;
|
||||
updateScale(true);
|
||||
x.fillStyle = "black";
|
||||
x.font = "50px sans-serif";
|
||||
x.strokeStyle = "black";
|
||||
@ -412,6 +422,8 @@ function setImage(url, _canvas) {
|
||||
scrollTo(0, 0);
|
||||
x.drawImage(img, 0, 0);
|
||||
|
||||
updateScale(false);
|
||||
|
||||
canvas.style.display = "";
|
||||
$("body").css("overflowY", "");
|
||||
x.restore();
|
||||
@ -450,6 +462,9 @@ function showPrevPage() {
|
||||
currentImage++;
|
||||
} else {
|
||||
updatePage();
|
||||
if (settings.nextPage === 0) {
|
||||
$("#mainContent").scrollTop(0);
|
||||
}
|
||||
}
|
||||
updateDirectionButtons();
|
||||
}
|
||||
@ -461,6 +476,9 @@ function showNextPage() {
|
||||
currentImage--;
|
||||
} else {
|
||||
updatePage();
|
||||
if (settings.nextPage === 0) {
|
||||
$("#mainContent").scrollTop(0);
|
||||
}
|
||||
}
|
||||
updateDirectionButtons();
|
||||
}
|
||||
@ -477,7 +495,7 @@ function scrollCurrentImageIntoView() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateScale() {
|
||||
function updateScale(clear) {
|
||||
var canvasArray = $("#mainContent > canvas");
|
||||
var maxheight = innerHeight - 50;
|
||||
|
||||
@ -486,7 +504,7 @@ function updateScale() {
|
||||
canvasArray.css("maxWidth", "");
|
||||
canvasArray.css("maxHeight", "");
|
||||
|
||||
if(settings.pageDisplay === 0) {
|
||||
if(!clear) {
|
||||
canvasArray.addClass("hide");
|
||||
pageDisplayUpdate();
|
||||
}
|
||||
@ -653,7 +671,7 @@ function init(filename) {
|
||||
request.responseType = "arraybuffer";
|
||||
request.addEventListener("load", function() {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
loadFromArrayBuffer(request.response);
|
||||
loadFromArrayBuffer(request.response, currentImage);
|
||||
} else {
|
||||
console.warn(request.statusText, request.responseText);
|
||||
}
|
||||
@ -708,7 +726,7 @@ function init(filename) {
|
||||
}
|
||||
|
||||
updatePage();
|
||||
updateScale();
|
||||
updateScale(false);
|
||||
});
|
||||
|
||||
// Close modal
|
||||
@ -721,6 +739,9 @@ function init(filename) {
|
||||
$("#thumbnails").on("click", "a", function() {
|
||||
currentImage = $(this).data("page") - 1;
|
||||
updatePage();
|
||||
if (settings.nextPage === 0) {
|
||||
$("#mainContent").scrollTop(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen mode
|
||||
|
@ -43,7 +43,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_user.kindle_mail and entry.email_share_list %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if entry.email_share_list.__len__() == 1 %}
|
||||
@ -69,6 +68,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if entry.reader_list and current_user.role_viewer() %}
|
||||
<div class="btn-group" role="group">
|
||||
{% if entry.reader_list|length > 1 %}
|
||||
|
@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
@ -20,23 +21,6 @@
|
||||
<script src="{{ url_for('static', filename='js/libs/screenfull.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/compress/uncompress.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/kthoom.js') }}"></script>
|
||||
<script>
|
||||
var updateArrows = function() {
|
||||
if ($('input[name="direction"]:checked').val() === "0") {
|
||||
$("#prev_page_key").html("←");
|
||||
$("#next_page_key").html("→");
|
||||
} else {
|
||||
$("#prev_page_key").html("→");
|
||||
$("#next_page_key").html("←");
|
||||
}
|
||||
};
|
||||
document.onreadystatechange = function () {
|
||||
if (document.readyState == "complete") {
|
||||
init("{{ url_for('web.serve_book', book_id=comicfile, book_format=extension) }}");
|
||||
updateArrows();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
@ -77,8 +61,8 @@
|
||||
<div id="mainContent" tabindex="-1">
|
||||
<div id="mainText" style="display:none"></div>
|
||||
</div>
|
||||
<div id="left" class="arrow" style="display:none" onclick="showLeftPage()">‹</div>
|
||||
<div id="right" class="arrow" style="display:none" onclick="showRightPage()">›</div>
|
||||
<div id="left" class="arrow" style="display:none" onclick="showLeftPage(); setBookmark();">‹</div>
|
||||
<div id="right" class="arrow" style="display:none" onclick="showRightPage(); setBookmark();">›</div>
|
||||
</div>
|
||||
|
||||
<div class="modal md-effect-1" id="settings-modal">
|
||||
@ -171,6 +155,15 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{_('Next Page')}}:</th>
|
||||
<td>
|
||||
<div class="inputs">
|
||||
<label for="resetToTop"><input type="radio" id="resetToTop" name="nextPage" value="0" /> {{_('Reset to Top')}}</label>
|
||||
<label for="rememberPosition"><input type="radio" id="rememberPosition" name="nextPage" value="1" /> {{_('Remember Position')}}</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{_('Scrollbar')}}:</th>
|
||||
<td>
|
||||
@ -188,6 +181,56 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay"></div>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<script>
|
||||
var updateArrows = function () {
|
||||
if ($('input[name="direction"]:checked').val() === "0") {
|
||||
$("#prev_page_key").html("←");
|
||||
$("#next_page_key").html("→");
|
||||
} else {
|
||||
$("#prev_page_key").html("→");
|
||||
$("#next_page_key").html("←");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
function setBookmark() {
|
||||
// get csrf_token
|
||||
let csrf_token = $("input[name='csrf_token']").val();
|
||||
//This sends a bookmark update to calibreweb.
|
||||
$.ajax(calibre.bookmarkUrl, {
|
||||
method: "post",
|
||||
data: {
|
||||
csrf_token: csrf_token,
|
||||
bookmark: currentImage
|
||||
}
|
||||
}).fail(function (xhr, status, error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
window.calibre = {
|
||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
||||
bookUrl: "{{ url_for('static', filename=comicfile) }}/",
|
||||
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=comicfile, book_format=extension.upper()) }}",
|
||||
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||
useBookmarks: "{{ current_user.is_authenticated | tojson }}"
|
||||
};
|
||||
if (calibre.useBookmarks) {
|
||||
currentImage = eval(calibre.bookmark);
|
||||
if (typeof currentImage !== 'number'){
|
||||
currentImage = 0;
|
||||
}
|
||||
}
|
||||
|
||||
document.onreadystatechange = function () {
|
||||
if (document.readyState == "complete") {
|
||||
init("{{ url_for('web.serve_book', book_id=comicfile, book_format=extension) }}");
|
||||
updateArrows();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
$('input[name="direction"]').change(function() {
|
||||
updateArrows();
|
||||
|
@ -88,7 +88,7 @@ def process(tmp_file_path, original_file_name, original_file_extension, rar_exec
|
||||
log.warning('cannot parse metadata, using default: %s', ex)
|
||||
|
||||
if not meta.title.strip():
|
||||
meta = original_file_name
|
||||
meta = meta._replace(title=original_file_name)
|
||||
if not meta.author.strip() or meta.author.lower() == 'unknown':
|
||||
meta = meta._replace(author=_('Unknown'))
|
||||
return meta
|
||||
|
@ -1561,7 +1561,7 @@ def read_book(book_id, book_format):
|
||||
title = title + " #" + '{0:.2f}'.format(book.series_index).rstrip('0').rstrip('.')
|
||||
log.debug("Start comic reader for %d", book_id)
|
||||
return render_title_template('readcbr.html', comicfile=all_name, title=title,
|
||||
extension=fileExt)
|
||||
extension=fileExt, bookmark=bookmark)
|
||||
log.debug("Selected book is unavailable. File does not exist or is not accessible")
|
||||
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
|
||||
category="error")
|
||||
|
Loading…
Reference in New Issue
Block a user