diff --git a/cps/__init__.py b/cps/__init__.py index 269599ad..0a452b17 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -23,32 +23,22 @@ import sys import os import mimetypes -from babel import Locale as LC -from babel import negotiate_locale -from babel.core import UnknownLocaleError -from flask import request, g from flask import Flask from .MyLoginManager import MyLoginManager -from flask_babel import Babel from flask_principal import Principal -from . import config_sql -from . import logger -from . import cache_buster from .cli import CliParameter from .constants import CONFIG_DIR -from . import ub, db from .reverseproxy import ReverseProxied from .server import WebServer from .dep_check import dependency_check from . import services from .updater import Updater - -try: - import lxml - lxml_present = True -except ImportError: - lxml_present = False +from .babel import babel, BABEL_TRANSLATIONS +from . import config_sql +from . import logger +from . import cache_buster +from . import ub, db try: from flask_wtf.csrf import CSRFProtect @@ -78,6 +68,8 @@ mimetypes.add_type('application/ogg', '.oga') mimetypes.add_type('text/css', '.css') mimetypes.add_type('text/javascript; charset=UTF-8', '.js') +log = logger.create() + app = Flask(__name__) app.config.update( SESSION_COOKIE_HTTPONLY=True, @@ -86,14 +78,8 @@ app.config.update( WTF_CSRF_SSL_STRICT=False ) - lm = MyLoginManager() -babel = Babel() -_BABEL_TRANSLATIONS = set() - -log = logger.create() - config = config_sql._ConfigSQL() cli_param = CliParameter() @@ -120,9 +106,8 @@ def create_app(): cli_param.init() - ub.init_db(os.path.join(CONFIG_DIR, "app.db"), cli_param.user_credentials) + ub.init_db(cli_param.settings_path, cli_param.user_credentials) - # ub.init_db(os.path.join(CONFIG_DIR, "app.db")) # pylint: disable=no-member config_sql.load_configuration(config, ub.session, cli_param) @@ -139,26 +124,26 @@ def create_app(): if sys.version_info < (3, 0): log.info( - '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***') + '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, ' + 'please update your installation to Python3 ***') print( - '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***') + '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, ' + 'please update your installation to Python3 ***') web_server.stop(True) sys.exit(5) - if not lxml_present: - log.info('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***') - print('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***') - web_server.stop(True) - sys.exit(6) if not wtf_present: - log.info('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***') - print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***') + log.info('*** "flask-WTF" is needed for calibre-web to run. ' + 'Please install it using pip: "pip install flask-WTF" ***') + print('*** "flask-WTF" is needed for calibre-web to run. ' + 'Please install it using pip: "pip install flask-WTF" ***') web_server.stop(True) sys.exit(7) for res in dependency_check() + dependency_check(True): - log.info('*** "{}" version does not fit the requirements. Should: {}, Found: {}, please consider installing required version ***' - .format(res['name'], - res['target'], - res['found'])) + log.info('*** "{}" version does not fit the requirements. ' + 'Should: {}, Found: {}, please consider installing required version ***' + .format(res['name'], + res['target'], + res['found'])) app.wsgi_app = ReverseProxied(app.wsgi_app) if os.environ.get('FLASK_DEBUG'): @@ -172,8 +157,8 @@ def create_app(): web_server.init_app(app, config) babel.init_app(app) - _BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations()) - _BABEL_TRANSLATIONS.add('en') + BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations()) + BABEL_TRANSLATIONS.add('en') if services.ldap: services.ldap.init_app(app, config) @@ -185,27 +170,3 @@ def create_app(): return app -@babel.localeselector -def get_locale(): - # if a user is logged in, use the locale from the user settings - user = getattr(g, 'user', None) - if user is not None and hasattr(user, "locale"): - if user.name != 'Guest': # if the account is the guest account bypass the config lang settings - return user.locale - - preferred = list() - if request.accept_languages: - for x in request.accept_languages.values(): - try: - preferred.append(str(LC.parse(x.replace('-', '_')))) - except (UnknownLocaleError, ValueError) as e: - log.debug('Could not parse locale "%s": %s', x, e) - - return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS) - - -'''@babel.timezoneselector -def get_timezone(): - user = getattr(g, 'user', None) - return user.timezone if user else None''' - diff --git a/cps/about.py b/cps/about.py index 92dc41aa..1b68818d 100644 --- a/cps/about.py +++ b/cps/about.py @@ -65,7 +65,7 @@ _VERSIONS = OrderedDict( SQLite=sqlite3.sqlite_version, ) _VERSIONS.update(ret) -_VERSIONS.update(uploader.get_versions(False)) +_VERSIONS.update(uploader.get_versions()) def collect_stats(): diff --git a/cps/admin.py b/cps/admin.py index 46da062e..29f2319e 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -28,11 +28,10 @@ import operator from datetime import datetime, timedelta, time from functools import wraps -from babel import Locale -from babel.dates import format_datetime, format_time, format_timedelta from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response from flask_login import login_required, current_user, logout_user, confirm_login from flask_babel import gettext as _ +from flask_babel import get_locale, format_time, format_datetime, format_timedelta from flask import session as flask_session from sqlalchemy import and_ from sqlalchemy.orm.attributes import flag_modified @@ -40,14 +39,14 @@ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text from . import constants, logger, helper, services, cli -from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, \ +from . import db, calibre_db, ub, web_server, config, updater_thread, babel, gdriveutils, \ kobo_sync_status, schedule from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username, update_thumbnail_cache from .gdriveutils import is_gdrive_ready, gdrive_support from .render_template import render_title_template, get_sidebar_config from .services.worker import WorkerThread -from . import debug_info, _BABEL_TRANSLATIONS +from . import debug_info, BABEL_TRANSLATIONS log = logger.create() @@ -205,9 +204,9 @@ def admin(): all_user = ub.session.query(ub.User).all() email_settings = config.get_mail_settings() - schedule_time = format_time(time(hour=config.schedule_start_time), format="short", locale=locale) + schedule_time = format_time(time(hour=config.schedule_start_time), format="short") t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60) - schedule_duration = format_timedelta(t, format="short", threshold=.99, locale=locale) + schedule_duration = format_timedelta(t, threshold=.99) return render_title_template("admin.html", allUser=all_user, email=email_settings, config=config, commit=commit, feature_support=feature_support, schedule_time=schedule_time, @@ -279,7 +278,7 @@ def view_configuration(): def edit_user_table(): visibility = current_user.view_settings.get('useredit', {}) languages = calibre_db.speaking_language() - translations = babel.list_translations() + [Locale('en')] + translations = [LC('en')] + babel.list_translations() all_user = ub.session.query(ub.User) tags = calibre_db.session.query(db.Tags)\ .join(db.books_tags_link)\ @@ -398,7 +397,7 @@ def delete_user(): @login_required @admin_required def table_get_locale(): - locale = babel.list_translations() + [Locale('en')] + locale = [LC('en')] + babel.list_translations() ret = list() current_locale = get_locale() for loc in locale: @@ -499,7 +498,7 @@ def edit_list_user(param): elif param == 'locale': if user.name == "Guest": raise Exception(_("Guest's Locale is determined automatically and can't be set")) - if vals['value'] in _BABEL_TRANSLATIONS: + if vals['value'] in BABEL_TRANSLATIONS: user.locale = vals['value'] else: raise Exception(_("No Valid Locale Given")) @@ -1668,12 +1667,11 @@ def edit_scheduledtasks(): time_field = list() duration_field = list() - locale = get_locale() for n in range(24): - time_field.append((n , format_time(time(hour=n), format="short", locale=locale))) + time_field.append((n , format_time(time(hour=n), format="short",))) for n in range(5, 65, 5): t = timedelta(hours=n // 60, minutes=n % 60) - duration_field.append((n, format_timedelta(t, format="short", threshold=.99, locale=locale))) + duration_field.append((n, format_timedelta(t, threshold=.9))) return render_title_template("schedule_edit.html", config=content, starttime=time_field, duration=duration_field, title=_(u"Edit Scheduled Tasks Settings")) diff --git a/cps/babel.py b/cps/babel.py new file mode 100644 index 00000000..b0d5c238 --- /dev/null +++ b/cps/babel.py @@ -0,0 +1,30 @@ +from babel import Locale as LC +from babel import negotiate_locale +from flask_babel import Babel +from babel.core import UnknownLocaleError +from flask import request, g + +from . import logger + +log = logger.create() + +babel = Babel() +BABEL_TRANSLATIONS = set() + +@babel.localeselector +def get_locale(): + # if a user is logged in, use the locale from the user settings + user = getattr(g, 'user', None) + if user is not None and hasattr(user, "locale"): + if user.name != 'Guest': # if the account is the guest account bypass the config lang settings + return user.locale + + preferred = list() + if request.accept_languages: + for x in request.accept_languages.values(): + try: + preferred.append(str(LC.parse(x.replace('-', '_')))) + except (UnknownLocaleError, ValueError) as e: + log.debug('Could not parse locale "%s": %s', x, e) + + return negotiate_locale(preferred or ['en'], BABEL_TRANSLATIONS) diff --git a/cps/db.py b/cps/db.py index 69796c51..f28baeca 100644 --- a/cps/db.py +++ b/cps/db.py @@ -43,6 +43,7 @@ from sqlalchemy.sql.expression import and_, true, false, text, func, or_ from sqlalchemy.ext.associationproxy import association_proxy from flask_login import current_user from flask_babel import gettext as _ +from flask_babel import get_locale from flask import flash from . import logger, ub, isoLanguages @@ -898,7 +899,6 @@ class CalibreDB: # Creates for all stored languages a translated speaking name in the array for the UI def speaking_language(self, languages=None, return_all_languages=False, with_count=False, reverse_order=False): - from . import get_locale if with_count: if not languages: diff --git a/cps/editbooks.py b/cps/editbooks.py index 9ac1557b..3ac3dfb8 100755 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -36,11 +36,12 @@ except ImportError: from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask_babel import gettext as _ from flask_babel import lazy_gettext as N_ +from flask_babel import get_locale from flask_login import current_user, login_required from sqlalchemy.exc import OperationalError, IntegrityError # from sqlite3 import OperationalError as sqliteOperationalError from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status -from . import config, get_locale, ub, db +from . import config, ub, db from . import calibre_db from .services.worker import WorkerThread from .tasks.upload import TaskUpload diff --git a/cps/error_handler.py b/cps/error_handler.py index 67252a66..7c003bdb 100644 --- a/cps/error_handler.py +++ b/cps/error_handler.py @@ -17,6 +17,7 @@ # along with this program. If not, see . import traceback + from flask import render_template from werkzeug.exceptions import default_exceptions try: diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index 9990e3db..e2e0a536 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -680,8 +680,3 @@ def get_error_text(client_secrets=None): return 'Callback url (redirect url) is missing in client_secrets.json' if client_secrets: client_secrets.update(filedata['web']) - - -def get_versions(): - return { # 'six': six_version, - 'httplib2': httplib2_version} diff --git a/cps/helper.py b/cps/helper.py index d97d6475..aec14668 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -29,11 +29,11 @@ from tempfile import gettempdir import requests import unidecode -from babel.dates import format_datetime -from babel.units import format_unit + from flask import send_from_directory, make_response, redirect, abort, url_for from flask_babel import gettext as _ from flask_babel import lazy_gettext as N_ +from flask_babel import format_datetime, get_locale from flask_login import current_user from sqlalchemy.sql.expression import true, false, and_, or_, text, func from sqlalchemy.exc import InvalidRequestError, OperationalError @@ -54,7 +54,7 @@ except ImportError: from . import calibre_db, cli from .tasks.convert import TaskConvert -from . import logger, config, get_locale, db, ub, fs +from . import logger, config, db, ub, fs from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES from .subproc_wrapper import process_wait @@ -970,64 +970,6 @@ def json_serial(obj): raise TypeError("Type %s not serializable" % type(obj)) -# helper function for displaying the runtime of tasks -def format_runtime(runtime): - ret_val = "" - if runtime.days: - ret_val = format_unit(runtime.days, 'duration-day', length="long", locale=get_locale()) + ', ' - mins, seconds = divmod(runtime.seconds, 60) - hours, minutes = divmod(mins, 60) - # ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ? - if hours: - ret_val += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds) - elif minutes: - ret_val += '{:2d}:{:02d}s'.format(minutes, seconds) - else: - ret_val += '{:2d}s'.format(seconds) - return ret_val - - -# helper function to apply localize status information in tasklist entries -def render_task_status(tasklist): - renderedtasklist = list() - for __, user, __, task, __ in tasklist: - if user == current_user.name or current_user.role_admin(): - ret = {} - if task.start_time: - ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale()) - ret['runtime'] = format_runtime(task.runtime) - - # localize the task status - if isinstance(task.stat, int): - if task.stat == STAT_WAITING: - ret['status'] = _(u'Waiting') - elif task.stat == STAT_FAIL: - ret['status'] = _(u'Failed') - elif task.stat == STAT_STARTED: - ret['status'] = _(u'Started') - elif task.stat == STAT_FINISH_SUCCESS: - ret['status'] = _(u'Finished') - elif task.stat == STAT_ENDED: - ret['status'] = _(u'Ended') - elif task.stat == STAT_CANCELLED: - ret['status'] = _(u'Cancelled') - else: - ret['status'] = _(u'Unknown Status') - - ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name - ret['progress'] = "{} %".format(int(task.progress * 100)) - ret['user'] = escape(user) # prevent xss - - # Hidden fields - ret['task_id'] = task.id - ret['stat'] = task.stat - ret['is_cancellable'] = task.is_cancellable - - renderedtasklist.append(ret) - - return renderedtasklist - - def tags_filters(): negtags_list = current_user.list_denied_tags() postags_list = current_user.list_allowed_tags() diff --git a/cps/isoLanguages.py b/cps/isoLanguages.py index 50447aca..31e3dade 100644 --- a/cps/isoLanguages.py +++ b/cps/isoLanguages.py @@ -49,7 +49,7 @@ except ImportError: def get_language_names(locale): - return _LANGUAGE_NAMES.get(locale) + return _LANGUAGE_NAMES.get(str(locale)) def get_language_name(locale, lang_code): diff --git a/cps/main.py b/cps/main.py index b960028e..304a244a 100644 --- a/cps/main.py +++ b/cps/main.py @@ -24,6 +24,7 @@ from .shelf import shelf from .remotelogin import remotelogin from .search_metadata import meta from .error_handler import init_errorhandler +from .tasks_status import tasks try: from kobo import kobo, get_kobo_activated @@ -48,16 +49,19 @@ def main(): from .gdrive import gdrive from .editbooks import editbook from .about import about + from .search import search from . import web_server init_errorhandler() + app.register_blueprint(search) + app.register_blueprint(tasks) app.register_blueprint(web) app.register_blueprint(opds) app.register_blueprint(jinjia) app.register_blueprint(about) app.register_blueprint(shelf) - app.register_blueprint(admi) # + app.register_blueprint(admi) app.register_blueprint(remotelogin) app.register_blueprint(meta) app.register_blueprint(gdrive) diff --git a/cps/oauth.py b/cps/oauth.py index f8e5c1fd..0caa61ec 100644 --- a/cps/oauth.py +++ b/cps/oauth.py @@ -19,18 +19,12 @@ from flask import session try: - from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user + from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend + from flask_dance.consumer.storage.sqla import first, _get_real_user from sqlalchemy.orm.exc import NoResultFound - backend_resultcode = False # prevent storing values with this resultcode + backend_resultcode = True # prevent storing values with this resultcode except ImportError: - # fails on flask-dance >1.3, due to renaming - try: - from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend - from flask_dance.consumer.storage.sqla import first, _get_real_user - from sqlalchemy.orm.exc import NoResultFound - backend_resultcode = True # prevent storing values with this resultcode - except ImportError: - pass + pass class OAuthBackend(SQLAlchemyBackend): diff --git a/cps/opds.py b/cps/opds.py index cb8f397e..2b8ab6d6 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -26,15 +26,18 @@ from functools import wraps from flask import Blueprint, request, render_template, Response, g, make_response, abort from flask_login import current_user +from flask_babel import get_locale from sqlalchemy.sql.expression import func, text, or_, and_, true from sqlalchemy.exc import InvalidRequestError, OperationalError from werkzeug.security import check_password_hash -from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages + +from . import constants, logger, config, db, calibre_db, ub, services, isoLanguages from .helper import get_download_link, get_book_cover from .pagination import Pagination from .web import render_read_books from .usermanagement import load_user_from_request from flask_babel import gettext as _ + opds = Blueprint('opds', __name__) log = logger.create() diff --git a/cps/redirect.py b/cps/redirect.py index 8bd68109..9382a205 100644 --- a/cps/redirect.py +++ b/cps/redirect.py @@ -29,7 +29,6 @@ from urllib.parse import urlparse, urljoin - from flask import request, url_for, redirect diff --git a/cps/render_template.py b/cps/render_template.py index d2f40d6c..0750a9c4 100644 --- a/cps/render_template.py +++ b/cps/render_template.py @@ -16,9 +16,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from flask import render_template, request +from flask import render_template, g, abort, request from flask_babel import gettext as _ -from flask import g, abort from werkzeug.local import LocalProxy from flask_login import current_user diff --git a/cps/search.py b/cps/search.py new file mode 100644 index 00000000..429aea17 --- /dev/null +++ b/cps/search.py @@ -0,0 +1,422 @@ +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2022 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +from datetime import datetime + +from flask import Blueprint, request, redirect, url_for, flash +from flask import session as flask_session +from flask_login import current_user +from flask_babel import get_locale, format_date +from flask_babel import gettext as _ +from sqlalchemy.sql.expression import func, not_, and_, or_, text +from sqlalchemy.sql.functions import coalesce + +from . import logger, db, calibre_db, config, ub +from .usermanagement import login_required_if_no_ano +from .render_template import render_title_template +from .pagination import Pagination + +search = Blueprint('search', __name__) + +log = logger.create() + + +@search.route("/search", methods=["GET"]) +@login_required_if_no_ano +def simple_search(): + term = request.args.get("query") + if term: + return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip())) + else: + return render_title_template('search.html', + searchterm="", + result_count=0, + title=_(u"Search"), + page="search") + + +@search.route("/advsearch", methods=['POST']) +@login_required_if_no_ano +def advanced_search(): + values = dict(request.form) + params = ['include_tag', 'exclude_tag', 'include_serie', 'exclude_serie', 'include_shelf', 'exclude_shelf', + 'include_language', 'exclude_language', 'include_extension', 'exclude_extension'] + for param in params: + values[param] = list(request.form.getlist(param)) + flask_session['query'] = json.dumps(values) + return redirect(url_for('web.books_list', data="advsearch", sort_param='stored', query="")) + + +@search.route("/advsearch", methods=['GET']) +@login_required_if_no_ano +def advanced_search_form(): + # Build custom columns names + cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) + return render_prepare_search_form(cc) + + +def adv_search_custom_columns(cc, term, q): + for c in cc: + if c.datatype == "datetime": + custom_start = term.get('custom_column_' + str(c.id) + '_start') + custom_end = term.get('custom_column_' + str(c.id) + '_end') + if custom_start: + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + func.datetime(db.cc_classes[c.id].value) >= func.datetime(custom_start))) + if custom_end: + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end))) + else: + custom_query = term.get('custom_column_' + str(c.id)) + if custom_query != '' and custom_query is not None: + if c.datatype == 'bool': + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + db.cc_classes[c.id].value == (custom_query == "True"))) + elif c.datatype == 'int' or c.datatype == 'float': + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + db.cc_classes[c.id].value == custom_query)) + elif c.datatype == 'rating': + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + db.cc_classes[c.id].value == int(float(custom_query) * 2))) + else: + q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any( + func.lower(db.cc_classes[c.id].value).ilike("%" + custom_query + "%"))) + return q + + +def adv_search_language(q, include_languages_inputs, exclude_languages_inputs): + if current_user.filter_language() != "all": + q = q.filter(db.Books.languages.any(db.Languages.lang_code == current_user.filter_language())) + else: + for language in include_languages_inputs: + q = q.filter(db.Books.languages.any(db.Languages.id == language)) + for language in exclude_languages_inputs: + q = q.filter(not_(db.Books.series.any(db.Languages.id == language))) + return q + + +def adv_search_ratings(q, rating_high, rating_low): + if rating_high: + rating_high = int(rating_high) * 2 + q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high)) + if rating_low: + rating_low = int(rating_low) * 2 + q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low)) + return q + + +def adv_search_read_status(q, read_status): + if read_status: + if config.config_read_column: + try: + if read_status == "True": + q = q.join(db.cc_classes[config.config_read_column], isouter=True) \ + .filter(db.cc_classes[config.config_read_column].value == True) + else: + q = q.join(db.cc_classes[config.config_read_column], isouter=True) \ + .filter(coalesce(db.cc_classes[config.config_read_column].value, False) != True) + except (KeyError, AttributeError): + log.error(u"Custom Column No.%d is not existing in calibre database", config.config_read_column) + flash(_("Custom Column No.%(column)d is not existing in calibre database", + column=config.config_read_column), + category="error") + return q + else: + if read_status == "True": + q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \ + .filter(ub.ReadBook.user_id == int(current_user.id), + ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED) + else: + q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \ + .filter(ub.ReadBook.user_id == int(current_user.id), + coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED) + return q + + +def adv_search_extension(q, include_extension_inputs, exclude_extension_inputs): + for extension in include_extension_inputs: + q = q.filter(db.Books.data.any(db.Data.format == extension)) + for extension in exclude_extension_inputs: + q = q.filter(not_(db.Books.data.any(db.Data.format == extension))) + return q + + +def adv_search_tag(q, include_tag_inputs, exclude_tag_inputs): + for tag in include_tag_inputs: + q = q.filter(db.Books.tags.any(db.Tags.id == tag)) + for tag in exclude_tag_inputs: + q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag))) + return q + + +def adv_search_serie(q, include_series_inputs, exclude_series_inputs): + for serie in include_series_inputs: + q = q.filter(db.Books.series.any(db.Series.id == serie)) + for serie in exclude_series_inputs: + q = q.filter(not_(db.Books.series.any(db.Series.id == serie))) + return q + +def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs): + q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\ + .filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs))) + if len(include_shelf_inputs) > 0: + q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs)) + return q + +def extend_search_term(searchterm, + author_name, + book_title, + publisher, + pub_start, + pub_end, + tags, + rating_high, + rating_low, + read_status, + ): + searchterm.extend((author_name.replace('|', ','), book_title, publisher)) + if pub_start: + try: + searchterm.extend([_(u"Published after ") + + format_date(datetime.strptime(pub_start, "%Y-%m-%d"), + format='medium', locale=get_locale())]) + except ValueError: + pub_start = u"" + if pub_end: + try: + searchterm.extend([_(u"Published before ") + + format_date(datetime.strptime(pub_end, "%Y-%m-%d"), + format='medium', locale=get_locale())]) + except ValueError: + pub_end = u"" + elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf} + for key, db_element in elements.items(): + tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all() + searchterm.extend(tag.name for tag in tag_names) + tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['exclude_' + key])).all() + searchterm.extend(tag.name for tag in tag_names) + language_names = calibre_db.session.query(db.Languages). \ + filter(db.Languages.id.in_(tags['include_language'])).all() + if language_names: + language_names = calibre_db.speaking_language(language_names) + searchterm.extend(language.name for language in language_names) + language_names = calibre_db.session.query(db.Languages). \ + filter(db.Languages.id.in_(tags['exclude_language'])).all() + if language_names: + language_names = calibre_db.speaking_language(language_names) + searchterm.extend(language.name for language in language_names) + if rating_high: + searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)]) + if rating_low: + searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)]) + if read_status: + searchterm.extend([_(u"Read Status = %(status)s", status=read_status)]) + searchterm.extend(ext for ext in tags['include_extension']) + searchterm.extend(ext for ext in tags['exclude_extension']) + # handle custom columns + searchterm = " + ".join(filter(None, searchterm)) + return searchterm, pub_start, pub_end + + +def render_adv_search_results(term, offset=None, order=None, limit=None): + sort = order[0] if order else [db.Books.sort] + pagination = None + + cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) + calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase) + if not config.config_read_column: + query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, ub.ReadBook).select_from(db.Books) + .outerjoin(ub.ReadBook, and_(db.Books.id == ub.ReadBook.book_id, + int(current_user.id) == ub.ReadBook.user_id))) + else: + try: + read_column = cc[config.config_read_column] + query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, read_column.value) + .select_from(db.Books) + .outerjoin(read_column, read_column.book == db.Books.id)) + except (KeyError, AttributeError): + log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column) + # Skip linking read column + query = calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, None) + query = query.outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id, + int(current_user.id) == ub.ArchivedBook.user_id)) + + q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\ + .outerjoin(db.Series)\ + .filter(calibre_db.common_filters(True)) + + # parse multi selects to a complete dict + tags = dict() + elements = ['tag', 'serie', 'shelf', 'language', 'extension'] + for element in elements: + tags['include_' + element] = term.get('include_' + element) + tags['exclude_' + element] = term.get('exclude_' + element) + + author_name = term.get("author_name") + book_title = term.get("book_title") + publisher = term.get("publisher") + pub_start = term.get("publishstart") + pub_end = term.get("publishend") + rating_low = term.get("ratinghigh") + rating_high = term.get("ratinglow") + description = term.get("comment") + read_status = term.get("read_status") + if author_name: + author_name = author_name.strip().lower().replace(',', '|') + if book_title: + book_title = book_title.strip().lower() + if publisher: + publisher = publisher.strip().lower() + + search_term = [] + cc_present = False + for c in cc: + if c.datatype == "datetime": + column_start = term.get('custom_column_' + str(c.id) + '_start') + column_end = term.get('custom_column_' + str(c.id) + '_end') + if column_start: + search_term.extend([u"{} >= {}".format(c.name, + format_date(datetime.strptime(column_start, "%Y-%m-%d").date(), + format='medium', + locale=get_locale()) + )]) + cc_present = True + if column_end: + search_term.extend([u"{} <= {}".format(c.name, + format_date(datetime.strptime(column_end, "%Y-%m-%d").date(), + format='medium', + locale=get_locale()) + )]) + cc_present = True + elif term.get('custom_column_' + str(c.id)): + search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))]) + cc_present = True + + + if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \ + or rating_high or description or cc_present or read_status: + search_term, pub_start, pub_end = extend_search_term(search_term, + author_name, + book_title, + publisher, + pub_start, + pub_end, + tags, + rating_high, + rating_low, + read_status) + if author_name: + q = q.filter(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + author_name + "%"))) + if book_title: + q = q.filter(func.lower(db.Books.title).ilike("%" + book_title + "%")) + if pub_start: + q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start)) + if pub_end: + q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end)) + q = adv_search_read_status(q, read_status) + if publisher: + q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%"))) + q = adv_search_tag(q, tags['include_tag'], tags['exclude_tag']) + q = adv_search_serie(q, tags['include_serie'], tags['exclude_serie']) + q = adv_search_shelf(q, tags['include_shelf'], tags['exclude_shelf']) + q = adv_search_extension(q, tags['include_extension'], tags['exclude_extension']) + q = adv_search_language(q, tags['include_language'], tags['exclude_language']) + q = adv_search_ratings(q, rating_high, rating_low) + + if description: + q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%"))) + + # search custom columns + try: + q = adv_search_custom_columns(cc, term, q) + except AttributeError as ex: + log.debug_or_exception(ex) + flash(_("Error on search for custom columns, please restart Calibre-Web"), category="error") + + q = q.order_by(*sort).all() + flask_session['query'] = json.dumps(term) + ub.store_combo_ids(q) + result_count = len(q) + 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) + else: + offset = 0 + limit_all = result_count + entries = calibre_db.order_authors(q[offset:limit_all], list_return=True, combined=True) + return render_title_template('search.html', + adv_searchterm=search_term, + pagination=pagination, + entries=entries, + result_count=result_count, + title=_(u"Advanced Search"), page="advsearch", + order=order[1]) + + +def render_prepare_search_form(cc): + # prepare data for search-form + tags = calibre_db.session.query(db.Tags)\ + .join(db.books_tags_link)\ + .join(db.Books)\ + .filter(calibre_db.common_filters()) \ + .group_by(text('books_tags_link.tag'))\ + .order_by(db.Tags.name).all() + series = calibre_db.session.query(db.Series)\ + .join(db.books_series_link)\ + .join(db.Books)\ + .filter(calibre_db.common_filters()) \ + .group_by(text('books_series_link.series'))\ + .order_by(db.Series.name)\ + .filter(calibre_db.common_filters()).all() + shelves = ub.session.query(ub.Shelf)\ + .filter(or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == int(current_user.id)))\ + .order_by(ub.Shelf.name).all() + extensions = calibre_db.session.query(db.Data)\ + .join(db.Books)\ + .filter(calibre_db.common_filters()) \ + .group_by(db.Data.format)\ + .order_by(db.Data.format).all() + if current_user.filter_language() == u"all": + languages = calibre_db.speaking_language() + else: + languages = None + return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions, + series=series,shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch") + + +def render_search_results(term, offset=None, order=None, limit=None): + join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series + entries, result_count, pagination = calibre_db.get_search_results(term, + config, + offset, + order, + limit, + False, + *join) + return render_title_template('search.html', + searchterm=term, + pagination=pagination, + query=term, + adv_searchterm=term, + entries=entries, + result_count=result_count, + title=_(u"Search"), + page="search", + order=order[1]) + + diff --git a/cps/search_metadata.py b/cps/search_metadata.py index 0070e78f..ae95a28e 100644 --- a/cps/search_metadata.py +++ b/cps/search_metadata.py @@ -22,17 +22,17 @@ import inspect import json import os import sys -# from time import time - +from dataclasses import asdict from flask import Blueprint, Response, request, url_for from flask_login import current_user from flask_login import login_required +from flask_babel import get_locale from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.orm.attributes import flag_modified from cps.services.Metadata import Metadata -from . import constants, get_locale, logger, ub, web_server +from . import constants, logger, ub, web_server # current_milli_time = lambda: int(round(time() * 1000)) diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index e65d314a..bc7e928a 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -55,7 +55,8 @@ class TaskConvert(CalibreTask): def run(self, worker_thread): self.worker_thread = worker_thread if config.config_use_google_drive: - worker_db = db.CalibreDB(expire_on_commit=False) + worker_db = db.CalibreDB() + worker_db.init_db(expire_on_commit=False) cur_book = worker_db.get_book(self.book_id) self.title = cur_book.title data = worker_db.get_book_format(self.book_id, self.settings['old_book_format']) @@ -104,7 +105,8 @@ class TaskConvert(CalibreTask): def _convert_ebook_format(self): error_message = None - local_db = db.CalibreDB(expire_on_commit=False) + local_db = db.CalibreDB() + local_db.init_db(expire_on_commit=False) file_path = self.file_path book_id = self.book_id format_old_ext = u'.' + self.settings['old_book_format'].lower() diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index dcfd4226..2ecc57c8 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -68,7 +68,8 @@ class TaskGenerateCoverThumbnails(CalibreTask): self.log = logger.create() self.book_id = book_id self.app_db_session = ub.get_new_session_instance() - self.calibre_db = db.CalibreDB(expire_on_commit=False) + self.calibre_db = db.CalibreDB() + self.calibre_db.init_db(expire_on_commit=False) self.cache = fs.FileSystem() self.resolutions = [ constants.COVER_THUMBNAIL_SMALL, @@ -238,7 +239,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): super(TaskGenerateSeriesThumbnails, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() - self.calibre_db = db.CalibreDB(expire_on_commit=False) + self.calibre_db = db.CalibreDB() + self.calibre_db.init_db(expire_on_commit=False) self.cache = fs.FileSystem() self.resolutions = [ constants.COVER_THUMBNAIL_SMALL, @@ -448,7 +450,8 @@ class TaskClearCoverThumbnailCache(CalibreTask): super(TaskClearCoverThumbnailCache, self).__init__(task_message) self.log = logger.create() self.book_id = book_id - self.calibre_db = db.CalibreDB(expire_on_commit=False) + self.calibre_db = db.CalibreDB() + self.calibre_db.init_db(expire_on_commit=False) self.app_db_session = ub.get_new_session_instance() self.cache = fs.FileSystem() diff --git a/cps/tasks_status.py b/cps/tasks_status.py new file mode 100644 index 00000000..ca9b5796 --- /dev/null +++ b/cps/tasks_status.py @@ -0,0 +1,95 @@ +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2022 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from markupsafe import escape + +from flask import Blueprint, jsonify +from flask_login import login_required, current_user +from flask_babel import gettext as _ +from flask_babel import get_locale, format_datetime +from babel.units import format_unit + +from . import logger +from .render_template import render_title_template +from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS + +tasks = Blueprint('tasks', __name__) + +log = logger.create() + + +@tasks.route("/ajax/emailstat") +@login_required +def get_email_status_json(): + tasks = WorkerThread.getInstance().tasks + return jsonify(render_task_status(tasks)) + + +@tasks.route("/tasks") +@login_required +def get_tasks_status(): + # if current user admin, show all email, otherwise only own emails + tasks = WorkerThread.getInstance().tasks + answer = render_task_status(tasks) + return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks") + + +# helper function to apply localize status information in tasklist entries +def render_task_status(tasklist): + rendered_tasklist = list() + for __, user, __, task in tasklist: + if user == current_user.name or current_user.role_admin(): + ret = {} + if task.start_time: + ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale()) + ret['runtime'] = format_runtime(task.runtime) + + # localize the task status + if isinstance(task.stat, int): + if task.stat == STAT_WAITING: + ret['status'] = _(u'Waiting') + elif task.stat == STAT_FAIL: + ret['status'] = _(u'Failed') + elif task.stat == STAT_STARTED: + ret['status'] = _(u'Started') + elif task.stat == STAT_FINISH_SUCCESS: + ret['status'] = _(u'Finished') + else: + ret['status'] = _(u'Unknown Status') + + ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) + ret['progress'] = "{} %".format(int(task.progress * 100)) + ret['user'] = escape(user) # prevent xss + rendered_tasklist.append(ret) + + return rendered_tasklist + + +# helper function for displaying the runtime of tasks +def format_runtime(runtime): + ret_val = "" + if runtime.days: + ret_val = format_unit(runtime.days, 'duration-day', length="long", locale=get_locale()) + ', ' + minutes, seconds = divmod(runtime.seconds, 60) + hours, minutes = divmod(minutes, 60) + # ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ? + if hours: + ret_val += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds) + elif minutes: + ret_val += '{:2d}:{:02d}s'.format(minutes, seconds) + else: + ret_val += '{:2d}s'.format(seconds) + return ret_val diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 42012937..7502514a 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -41,7 +41,7 @@ {{instance}} {% if g.user.is_authenticated or g.allow_anonymous %} -