From 774b9ae12d43542caeafdfd181fdf4dbe44c21c0 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sat, 19 Dec 2020 00:49:36 -0600 Subject: [PATCH 001/220] Added thumbnail task and database table --- .gitignore | 1 + cps.py | 4 ++ cps/constants.py | 1 + cps/helper.py | 9 ++- cps/tasks/thumbnail.py | 154 +++++++++++++++++++++++++++++++++++++++++ cps/thumbnails.py | 63 +++++++++++++++++ cps/ub.py | 36 +++++++++- 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 cps/tasks/thumbnail.py create mode 100644 cps/thumbnails.py diff --git a/.gitignore b/.gitignore index f06dcd44..cef58094 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ vendor/ # calibre-web *.db *.log +cps/cache .idea/ *.bak diff --git a/cps.py b/cps.py index 50ab0076..e90a38d9 100755 --- a/cps.py +++ b/cps.py @@ -43,6 +43,7 @@ from cps.gdrive import gdrive from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.error_handler import init_errorhandler +from cps.thumbnails import generate_thumbnails try: from cps.kobo import kobo, get_kobo_activated @@ -78,6 +79,9 @@ def main(): app.register_blueprint(kobo_auth) if oauth_available: app.register_blueprint(oauth) + + generate_thumbnails() + success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/constants.py b/cps/constants.py index c1bcbe59..0a9f9cd5 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -33,6 +33,7 @@ else: STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') +CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') if HOME_CONFIG: home_dir = os.path.join(os.path.expanduser("~"),".calibre-web") diff --git a/cps/helper.py b/cps/helper.py index da5ea2b3..6fc6b02a 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -551,6 +551,11 @@ def get_book_cover_with_uuid(book_uuid, def get_book_cover_internal(book, use_generic_cover_on_failure): if book and book.has_cover: + # if thumbnails.cover_thumbnail_exists_for_book(book): + # thumbnail = ub.session.query(ub.Thumbnail).filter(ub.Thumbnail.book_id == book.id).first() + # return send_from_directory(thumbnails.get_thumbnail_cache_dir(), thumbnail.filename) + # else: + # WorkerThread.add(None, TaskThumbnail(book, _(u'Generating cover thumbnail for: ' + book.title))) if config.config_use_google_drive: try: if not gd.is_gdrive_ready(): @@ -561,8 +566,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): else: log.error('%s/cover.jpg not found on Google Drive', book.path) return get_cover_on_failure(use_generic_cover_on_failure) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) return get_cover_on_failure(use_generic_cover_on_failure) else: cover_file_path = os.path.join(config.config_calibre_dir, book.path) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py new file mode 100644 index 00000000..c452ab41 --- /dev/null +++ b/cps/tasks/thumbnail.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +import os + +from cps import config, db, gdriveutils, logger, ub +from cps.constants import CACHE_DIR as _CACHE_DIR +from cps.services.worker import CalibreTask +from datetime import datetime, timedelta +from sqlalchemy import func + +try: + from wand.image import Image + use_IM = True +except (ImportError, RuntimeError) as e: + use_IM = False + +THUMBNAIL_RESOLUTION_1X = 1.0 +THUMBNAIL_RESOLUTION_2X = 2.0 + + +class TaskThumbnail(CalibreTask): + def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): + super(TaskThumbnail, self).__init__(task_message) + self.limit = limit + self.log = logger.create() + self.app_db_session = ub.get_new_session_instance() + self.worker_db = db.CalibreDB(expire_on_commit=False) + + def run(self, worker_thread): + if self.worker_db.session and use_IM: + thumbnails = self.get_thumbnail_book_ids() + thumbnail_book_ids = list(map(lambda t: t.book_id, thumbnails)) + self.log.info(','.join([str(elem) for elem in thumbnail_book_ids])) + self.log.info(len(thumbnail_book_ids)) + + books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) + + count = len(books_without_thumbnails) + for i, book in enumerate(books_without_thumbnails): + thumbnails = self.get_thumbnails_for_book(thumbnails, book) + if thumbnails: + for thumbnail in thumbnails: + self.update_book_thumbnail(book, thumbnail) + + else: + self.create_book_thumbnail(book, THUMBNAIL_RESOLUTION_1X) + self.create_book_thumbnail(book, THUMBNAIL_RESOLUTION_2X) + + self.progress = (1.0 / count) * i + + self._handleSuccess() + self.app_db_session.close() + + def get_thumbnail_book_ids(self): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .group_by(ub.Thumbnail.book_id)\ + .having(func.min(ub.Thumbnail.expiration) > datetime.utcnow())\ + .all() + + def get_books_without_thumbnails(self, thumbnail_book_ids): + return self.worker_db.session\ + .query(db.Books)\ + .filter(db.Books.has_cover == 1)\ + .filter(db.Books.id.notin_(thumbnail_book_ids))\ + .limit(self.limit)\ + .all() + + def get_thumbnails_for_book(self, thumbnails, book): + results = list() + for thumbnail in thumbnails: + if thumbnail.book_id == book.id: + results.append(thumbnail) + + return results + + def update_book_thumbnail(self, book, thumbnail): + thumbnail.expiration = datetime.utcnow() + timedelta(days=30) + + try: + self.app_db_session.commit() + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self._handleError(u'Error updating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def create_book_thumbnail(self, book, resolution): + thumbnail = ub.Thumbnail() + thumbnail.book_id = book.id + thumbnail.resolution = resolution + + self.app_db_session.add(thumbnail) + try: + self.app_db_session.commit() + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self._handleError(u'Error creating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def generate_book_thumbnail(self, book, thumbnail): + if book and thumbnail: + if config.config_use_google_drive: + self.log.info('google drive thumbnail') + else: + book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') + if os.path.isfile(book_cover_filepath): + with Image(filename=book_cover_filepath) as img: + height = self.get_thumbnail_height(thumbnail) + if img.height > height: + width = self.get_thumbnail_width(height, img) + img.resize(width=width, height=height, filter='lanczos') + img.save(filename=self.get_thumbnail_cache_path(thumbnail)) + + def get_thumbnail_height(self, thumbnail): + return int(225 * thumbnail.resolution) + + def get_thumbnail_width(self, height, img): + percent = (height / float(img.height)) + return int((float(img.width) * float(percent))) + + def get_thumbnail_cache_dir(self): + if not os.path.isdir(_CACHE_DIR): + os.makedirs(_CACHE_DIR) + + if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): + os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) + + return os.path.join(_CACHE_DIR, 'thumbnails') + + def get_thumbnail_cache_path(self, thumbnail): + if thumbnail: + return os.path.join(self.get_thumbnail_cache_dir(), thumbnail.filename) + return None + + @property + def name(self): + return "Thumbnail" diff --git a/cps/thumbnails.py b/cps/thumbnails.py new file mode 100644 index 00000000..6ccff56f --- /dev/null +++ b/cps/thumbnails.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +import os + +from . import logger, ub +from .constants import CACHE_DIR as _CACHE_DIR +from .services.worker import WorkerThread +from .tasks.thumbnail import TaskThumbnail + +from datetime import datetime + +THUMBNAIL_RESOLUTION_1X = 1.0 +THUMBNAIL_RESOLUTION_2X = 2.0 + +log = logger.create() + + +def get_thumbnail_cache_dir(): + if not os.path.isdir(_CACHE_DIR): + os.makedirs(_CACHE_DIR) + + if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): + os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) + + return os.path.join(_CACHE_DIR, 'thumbnails') + + +def get_thumbnail_cache_path(thumbnail): + if thumbnail: + return os.path.join(get_thumbnail_cache_dir(), thumbnail.filename) + + return None + + +def cover_thumbnail_exists_for_book(book): + if book and book.has_cover: + thumbnail = ub.session.query(ub.Thumbnail).filter(ub.Thumbnail.book_id == book.id).first() + if thumbnail and thumbnail.expiration > datetime.utcnow(): + thumbnail_path = get_thumbnail_cache_path(thumbnail) + return thumbnail_path and os.path.isfile(thumbnail_path) + + return False + + +def generate_thumbnails(): + WorkerThread.add(None, TaskThumbnail()) diff --git a/cps/ub.py b/cps/ub.py index dbc3b419..4500160f 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -40,13 +40,14 @@ except ImportError: oauth_support = False from sqlalchemy import create_engine, exc, exists, event from sqlalchemy import Column, ForeignKey -from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON +from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON, Numeric from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session from werkzeug.security import generate_password_hash -from . import constants +from . import cli, constants session = None @@ -434,6 +435,28 @@ class RemoteAuthToken(Base): return '' % self.id +class Thumbnail(Base): + __tablename__ = 'thumbnail' + + id = Column(Integer, primary_key=True) + book_id = Column(Integer) + uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) + format = Column(String, default='jpeg') + resolution = Column(Numeric(precision=2, scale=1, asdecimal=False), default=1.0) + expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) + + @hybrid_property + def extension(self): + if self.format == 'jpeg': + return 'jpg' + else: + return self.format + + @hybrid_property + def filename(self): + return self.uuid + '.' + self.extension + + # Migrate database to current version, has to be updated after every database change. Currently migration from # everywhere to current should work. Migration is done by checking if relevant columns are existing, and than adding # rows with SQL commands @@ -451,6 +474,8 @@ def migrate_Database(session): KoboStatistics.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "archived_book"): ArchivedBook.__table__.create(bind=engine) + if not engine.dialect.has_table(engine.connect(), "thumbnail"): + Thumbnail.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "registration"): ReadBook.__table__.create(bind=engine) with engine.connect() as conn: @@ -676,6 +701,13 @@ def init_db(app_db_path): create_anonymous_user(session) +def get_new_session_instance(): + new_engine = create_engine(u'sqlite:///{0}'.format(cli.settingspath), echo=False) + new_session = scoped_session(sessionmaker()) + new_session.configure(bind=new_engine) + return new_session + + def dispose(): global session From 21fce9a5b5b004c1fcf16aa74e96d27f7e4bde0f Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sat, 19 Dec 2020 02:58:40 -0600 Subject: [PATCH 002/220] Added background scheduler and scheduled thumbnail generation job --- cps.py | 4 --- cps/__init__.py | 9 ++++- cps/services/background_scheduler.py | 52 ++++++++++++++++++++++++++++ cps/tasks/thumbnail.py | 12 +++---- cps/thumbnails.py | 4 --- requirements.txt | 1 + 6 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 cps/services/background_scheduler.py diff --git a/cps.py b/cps.py index e90a38d9..50ab0076 100755 --- a/cps.py +++ b/cps.py @@ -43,7 +43,6 @@ from cps.gdrive import gdrive from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.error_handler import init_errorhandler -from cps.thumbnails import generate_thumbnails try: from cps.kobo import kobo, get_kobo_activated @@ -79,9 +78,6 @@ def main(): app.register_blueprint(kobo_auth) if oauth_available: app.register_blueprint(oauth) - - generate_thumbnails() - success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/__init__.py b/cps/__init__.py index 1a7dc868..fa85e15c 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -36,6 +36,8 @@ from flask_principal import Principal from . import config_sql, logger, cache_buster, cli, ub, db from .reverseproxy import ReverseProxied from .server import WebServer +from .services.background_scheduler import BackgroundScheduler +from .tasks.thumbnail import TaskThumbnail mimetypes.init() @@ -95,7 +97,7 @@ def create_app(): app.instance_path = app.instance_path.decode('utf-8') if os.environ.get('FLASK_DEBUG'): - cache_buster.init_cache_busting(app) + cache_buster.init_cache_busting(app) log.info('Starting Calibre Web...') Principal(app) @@ -115,8 +117,13 @@ def create_app(): config.config_goodreads_api_secret, config.config_use_goodreads) + scheduler = BackgroundScheduler() + # Generate 100 book cover thumbnails every 5 minutes + scheduler.add_task(user=None, task=lambda: TaskThumbnail(config=config, limit=100), trigger='interval', minutes=5) + return app + @babel.localeselector def get_locale(): # if a user is logged in, use the locale from the user settings diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py new file mode 100644 index 00000000..efa57379 --- /dev/null +++ b/cps/services/background_scheduler.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +import atexit + +from .. import logger +from .worker import WorkerThread +from apscheduler.schedulers.background import BackgroundScheduler as BScheduler + + +class BackgroundScheduler: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(BackgroundScheduler, cls).__new__(cls) + + scheduler = BScheduler() + atexit.register(lambda: scheduler.shutdown()) + + cls.log = logger.create() + cls.scheduler = scheduler + cls.scheduler.start() + + return cls._instance + + def add(self, func, trigger, **trigger_args): + self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) + + def add_task(self, user, task, trigger, **trigger_args): + def scheduled_task(): + worker_task = task() + self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) + WorkerThread.add(user, worker_task) + + self.add(func=scheduled_task, trigger=trigger, **trigger_args) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index c452ab41..378b688e 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -19,7 +19,7 @@ from __future__ import division, print_function, unicode_literals import os -from cps import config, db, gdriveutils, logger, ub +from cps import db, logger, ub from cps.constants import CACHE_DIR as _CACHE_DIR from cps.services.worker import CalibreTask from datetime import datetime, timedelta @@ -36,8 +36,9 @@ THUMBNAIL_RESOLUTION_2X = 2.0 class TaskThumbnail(CalibreTask): - def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): + def __init__(self, config, limit=100, task_message=u'Generating cover thumbnails'): super(TaskThumbnail, self).__init__(task_message) + self.config = config self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -47,9 +48,6 @@ class TaskThumbnail(CalibreTask): if self.worker_db.session and use_IM: thumbnails = self.get_thumbnail_book_ids() thumbnail_book_ids = list(map(lambda t: t.book_id, thumbnails)) - self.log.info(','.join([str(elem) for elem in thumbnail_book_ids])) - self.log.info(len(thumbnail_book_ids)) - books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) count = len(books_without_thumbnails) @@ -116,10 +114,10 @@ class TaskThumbnail(CalibreTask): def generate_book_thumbnail(self, book, thumbnail): if book and thumbnail: - if config.config_use_google_drive: + if self.config.config_use_google_drive: self.log.info('google drive thumbnail') else: - book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') + book_cover_filepath = os.path.join(self.config.config_calibre_dir, book.path, 'cover.jpg') if os.path.isfile(book_cover_filepath): with Image(filename=book_cover_filepath) as img: height = self.get_thumbnail_height(thumbnail) diff --git a/cps/thumbnails.py b/cps/thumbnails.py index 6ccff56f..89e68f50 100644 --- a/cps/thumbnails.py +++ b/cps/thumbnails.py @@ -57,7 +57,3 @@ def cover_thumbnail_exists_for_book(book): return thumbnail_path and os.path.isfile(thumbnail_path) return False - - -def generate_thumbnails(): - WorkerThread.add(None, TaskThumbnail()) diff --git a/requirements.txt b/requirements.txt index f154dd5b..9d0a8654 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +APScheduler==3.6.3 Babel>=1.3, <2.9 Flask-Babel>=0.11.1,<2.1.0 Flask-Login>=0.3.2,<0.5.1 From e48bdf9d5a038d169046c0e5352d3c9d074769c6 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sun, 20 Dec 2020 03:11:21 -0600 Subject: [PATCH 003/220] Display thumbnails on the frontend, generate thumbnails from google drive --- cps.py | 5 ++++ cps/__init__.py | 6 ----- cps/helper.py | 37 ++++++++++++++++++++--------- cps/schedule.py | 34 +++++++++++++++++++++++++++ cps/tasks/thumbnail.py | 44 +++++++++++++++++++++++++---------- cps/templates/author.html | 2 +- cps/templates/book_cover.html | 8 +++++++ cps/templates/book_edit.html | 4 +++- cps/templates/detail.html | 3 ++- cps/templates/discover.html | 3 ++- cps/templates/fragment.html | 1 + cps/templates/grid.html | 3 ++- cps/templates/index.html | 5 ++-- cps/templates/layout.html | 1 + cps/templates/search.html | 3 ++- cps/templates/shelf.html | 3 ++- cps/thumbnails.py | 9 ++----- cps/ub.py | 4 ++-- cps/web.py | 11 ++++----- 19 files changed, 133 insertions(+), 53 deletions(-) create mode 100644 cps/schedule.py create mode 100644 cps/templates/book_cover.html diff --git a/cps.py b/cps.py index 50ab0076..d63771eb 100755 --- a/cps.py +++ b/cps.py @@ -43,6 +43,7 @@ from cps.gdrive import gdrive from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.error_handler import init_errorhandler +from cps.schedule import register_jobs try: from cps.kobo import kobo, get_kobo_activated @@ -78,6 +79,10 @@ def main(): app.register_blueprint(kobo_auth) if oauth_available: app.register_blueprint(oauth) + + # Register scheduled jobs + register_jobs() + success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/__init__.py b/cps/__init__.py index fa85e15c..6a6d361a 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -36,8 +36,6 @@ from flask_principal import Principal from . import config_sql, logger, cache_buster, cli, ub, db from .reverseproxy import ReverseProxied from .server import WebServer -from .services.background_scheduler import BackgroundScheduler -from .tasks.thumbnail import TaskThumbnail mimetypes.init() @@ -117,10 +115,6 @@ def create_app(): config.config_goodreads_api_secret, config.config_use_goodreads) - scheduler = BackgroundScheduler() - # Generate 100 book cover thumbnails every 5 minutes - scheduler.add_task(user=None, task=lambda: TaskThumbnail(config=config, limit=100), trigger='interval', minutes=5) - return app diff --git a/cps/helper.py b/cps/helper.py index 6fc6b02a..d3420a11 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -52,7 +52,7 @@ except ImportError: from . import calibre_db from .tasks.convert import TaskConvert -from . import logger, config, get_locale, db, ub +from . import logger, config, get_locale, db, thumbnails, ub from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR from .subproc_wrapper import process_wait @@ -538,24 +538,27 @@ def get_cover_on_failure(use_generic_cover): return None -def get_book_cover(book_id): +def get_book_cover(book_id, resolution=1): book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) - return get_book_cover_internal(book, use_generic_cover_on_failure=True) + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) -def get_book_cover_with_uuid(book_uuid, - use_generic_cover_on_failure=True): +def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): book = calibre_db.get_book_by_uuid(book_uuid) return get_book_cover_internal(book, use_generic_cover_on_failure) -def get_book_cover_internal(book, use_generic_cover_on_failure): +def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=1, disable_thumbnail=False): if book and book.has_cover: - # if thumbnails.cover_thumbnail_exists_for_book(book): - # thumbnail = ub.session.query(ub.Thumbnail).filter(ub.Thumbnail.book_id == book.id).first() - # return send_from_directory(thumbnails.get_thumbnail_cache_dir(), thumbnail.filename) - # else: - # WorkerThread.add(None, TaskThumbnail(book, _(u'Generating cover thumbnail for: ' + book.title))) + + # Send the book cover thumbnail if it exists in cache + if not disable_thumbnail: + thumbnail = get_book_cover_thumbnail(book, resolution) + if thumbnail: + if os.path.isfile(thumbnails.get_thumbnail_cache_path(thumbnail)): + return send_from_directory(thumbnails.get_thumbnail_cache_dir(), thumbnail.filename) + + # Send the book cover from Google Drive if configured if config.config_use_google_drive: try: if not gd.is_gdrive_ready(): @@ -569,6 +572,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): except Exception as ex: log.debug_or_exception(ex) return get_cover_on_failure(use_generic_cover_on_failure) + + # Send the book cover from the Calibre directory else: cover_file_path = os.path.join(config.config_calibre_dir, book.path) if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): @@ -579,6 +584,16 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): return get_cover_on_failure(use_generic_cover_on_failure) +def get_book_cover_thumbnail(book, resolution=1): + if book and book.has_cover: + return ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book.id)\ + .filter(ub.Thumbnail.resolution == resolution)\ + .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .first() + + # saves book cover from url def save_cover_from_url(url, book_path): try: diff --git a/cps/schedule.py b/cps/schedule.py new file mode 100644 index 00000000..5d2c94b9 --- /dev/null +++ b/cps/schedule.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals + +from . import logger +from .services.background_scheduler import BackgroundScheduler +from .tasks.thumbnail import TaskThumbnail + +log = logger.create() + + +def register_jobs(): + scheduler = BackgroundScheduler() + + # Generate 100 book cover thumbnails every 5 minutes + scheduler.add_task(user=None, task=lambda: TaskThumbnail(limit=100), trigger='interval', minutes=5) + + # TODO: validate thumbnail scheduled task diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 378b688e..4e0c6db4 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -19,11 +19,13 @@ from __future__ import division, print_function, unicode_literals import os -from cps import db, logger, ub +from cps import config, db, gdriveutils, logger, ub from cps.constants import CACHE_DIR as _CACHE_DIR from cps.services.worker import CalibreTask +from cps.thumbnails import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X from datetime import datetime, timedelta from sqlalchemy import func +from urllib.request import urlopen try: from wand.image import Image @@ -31,14 +33,10 @@ try: except (ImportError, RuntimeError) as e: use_IM = False -THUMBNAIL_RESOLUTION_1X = 1.0 -THUMBNAIL_RESOLUTION_2X = 2.0 - class TaskThumbnail(CalibreTask): - def __init__(self, config, limit=100, task_message=u'Generating cover thumbnails'): + def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): super(TaskThumbnail, self).__init__(task_message) - self.config = config self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -114,17 +112,39 @@ class TaskThumbnail(CalibreTask): def generate_book_thumbnail(self, book, thumbnail): if book and thumbnail: - if self.config.config_use_google_drive: - self.log.info('google drive thumbnail') - else: - book_cover_filepath = os.path.join(self.config.config_calibre_dir, book.path, 'cover.jpg') - if os.path.isfile(book_cover_filepath): - with Image(filename=book_cover_filepath) as img: + if config.config_use_google_drive: + if not gdriveutils.is_gdrive_ready(): + raise Exception('Google Drive is configured but not ready') + + web_content_link = gdriveutils.get_cover_via_gdrive(book.path) + if not web_content_link: + raise Exception('Google Drive cover url not found') + + stream = None + try: + stream = urlopen(web_content_link) + with Image(file=stream) as img: height = self.get_thumbnail_height(thumbnail) if img.height > height: width = self.get_thumbnail_width(height, img) img.resize(width=width, height=height, filter='lanczos') img.save(filename=self.get_thumbnail_cache_path(thumbnail)) + except Exception as ex: + # Bubble exception to calling function + raise ex + finally: + stream.close() + else: + book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') + if not os.path.isfile(book_cover_filepath): + raise Exception('Book cover file not found') + + with Image(filename=book_cover_filepath) as img: + height = self.get_thumbnail_height(thumbnail) + if img.height > height: + width = self.get_thumbnail_width(height, img) + img.resize(width=width, height=height, filter='lanczos') + img.save(filename=self.get_thumbnail_cache_path(thumbnail)) def get_thumbnail_height(self, thumbnail): return int(225 * thumbnail.resolution) diff --git a/cps/templates/author.html b/cps/templates/author.html index 7887aa4a..24ce876a 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -36,7 +36,7 @@
diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html new file mode 100644 index 00000000..878c14a8 --- /dev/null +++ b/cps/templates/book_cover.html @@ -0,0 +1,8 @@ +{% macro book_cover_image(book_id, book_title) -%} + {{ book_title }} +{%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 003b33f9..881fa8ff 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -1,9 +1,11 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %} {% if book %}
- {{ book.title }} + {{ book_cover_image(book.id, book.title) }} +
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 503d1dbd..d3615563 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,7 +4,8 @@
- {{ entry.title }} + {{ book_cover_image(entry.id, entry.title) }} +
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index 3c858feb..33bafbbe 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -8,7 +9,7 @@
{% if entry.has_cover is defined %} - {{ entry.title }} + {{ book_cover_image(entry.id, entry.title) }} {% endif %}
diff --git a/cps/templates/fragment.html b/cps/templates/fragment.html index 1421ea6a..901dd193 100644 --- a/cps/templates/fragment.html +++ b/cps/templates/fragment.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %}
{% block body %}{% endblock %}
diff --git a/cps/templates/grid.html b/cps/templates/grid.html index ce2c05ac..bc3ca4a2 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}

{{_(title)}}

@@ -28,7 +29,7 @@
diff --git a/cps/templates/index.html b/cps/templates/index.html index 1db73c89..c536884f 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %} {% if g.user.show_detail_random() %} @@ -8,7 +9,7 @@
@@ -82,7 +83,7 @@
diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 3b89a7ce..5df471a9 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -1,4 +1,5 @@ {% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal %} +{% from 'book_cover.html' import book_cover_image %} diff --git a/cps/templates/search.html b/cps/templates/search.html index aedb6f45..56b12154 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -43,7 +44,7 @@
{% if entry.has_cover is defined %} - {{ entry.title }} + {{ book_cover_image(entry.id, entry.title) }} {% endif %}
diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index f7e3c1ae..7a678ea6 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -30,7 +31,7 @@
diff --git a/cps/thumbnails.py b/cps/thumbnails.py index 89e68f50..ea7aac86 100644 --- a/cps/thumbnails.py +++ b/cps/thumbnails.py @@ -21,13 +21,11 @@ import os from . import logger, ub from .constants import CACHE_DIR as _CACHE_DIR -from .services.worker import WorkerThread -from .tasks.thumbnail import TaskThumbnail from datetime import datetime -THUMBNAIL_RESOLUTION_1X = 1.0 -THUMBNAIL_RESOLUTION_2X = 2.0 +THUMBNAIL_RESOLUTION_1X = 1 +THUMBNAIL_RESOLUTION_2X = 2 log = logger.create() @@ -35,17 +33,14 @@ log = logger.create() def get_thumbnail_cache_dir(): if not os.path.isdir(_CACHE_DIR): os.makedirs(_CACHE_DIR) - if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) - return os.path.join(_CACHE_DIR, 'thumbnails') def get_thumbnail_cache_path(thumbnail): if thumbnail: return os.path.join(get_thumbnail_cache_dir(), thumbnail.filename) - return None diff --git a/cps/ub.py b/cps/ub.py index 4500160f..0b5a65e7 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -40,7 +40,7 @@ except ImportError: oauth_support = False from sqlalchemy import create_engine, exc, exists, event from sqlalchemy import Column, ForeignKey -from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON, Numeric +from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.attributes import flag_modified @@ -442,7 +442,7 @@ class Thumbnail(Base): book_id = Column(Integer) uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) format = Column(String, default='jpeg') - resolution = Column(Numeric(precision=2, scale=1, asdecimal=False), default=1.0) + resolution = Column(SmallInteger, default=1) expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) @hybrid_property diff --git a/cps/web.py b/cps/web.py index 4baf82cb..27a5849b 100644 --- a/cps/web.py +++ b/cps/web.py @@ -1171,14 +1171,17 @@ def advanced_search_form(): @web.route("/cover/") +@web.route("/cover//") @login_required_if_no_ano -def get_cover(book_id): - return get_book_cover(book_id) +def get_cover(book_id, resolution=1): + return get_book_cover(book_id, resolution) + @web.route("/robots.txt") def get_robots(): return send_from_directory(constants.STATIC_DIR, "robots.txt") + @web.route("/show//", defaults={'anyname': 'None'}) @web.route("/show///") @login_required_if_no_ano @@ -1205,7 +1208,6 @@ def serve_book(book_id, book_format, anyname): return send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + book_format) - @web.route("/download//", defaults={'anyname': 'None'}) @web.route("/download///") @login_required_if_no_ano @@ -1387,9 +1389,6 @@ def logout(): return redirect(url_for('web.login')) - - - # ################################### Users own configuration ######################################################### From 541fc7e14ebe3716fce58593a2ff3f5b23403e3d Mon Sep 17 00:00:00 2001 From: mmonkey Date: Tue, 22 Dec 2020 17:49:21 -0600 Subject: [PATCH 004/220] fixed thumbnail generate tasks, added thumbnail cleanup task, added reconnect db scheduled job --- cps/fs.py | 61 ++++++++++++++ cps/helper.py | 7 +- cps/schedule.py | 18 ++++- cps/services/worker.py | 17 +++- cps/tasks/thumbnail.py | 177 +++++++++++++++++++++++++++++++---------- cps/thumbnails.py | 54 ------------- cps/ub.py | 28 ++++--- 7 files changed, 245 insertions(+), 117 deletions(-) create mode 100644 cps/fs.py delete mode 100644 cps/thumbnails.py diff --git a/cps/fs.py b/cps/fs.py new file mode 100644 index 00000000..699d5991 --- /dev/null +++ b/cps/fs.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +from .constants import CACHE_DIR +from os import listdir, makedirs, remove +from os.path import isdir, isfile, join +from shutil import rmtree + +CACHE_TYPE_THUMBNAILS = 'thumbnails' + + +class FileSystem: + _instance = None + _cache_dir = CACHE_DIR + + def __new__(cls): + if cls._instance is None: + cls._instance = super(FileSystem, cls).__new__(cls) + return cls._instance + + def get_cache_dir(self, cache_type=None): + if not isdir(self._cache_dir): + makedirs(self._cache_dir) + + if cache_type and not isdir(join(self._cache_dir, cache_type)): + makedirs(join(self._cache_dir, cache_type)) + + return join(self._cache_dir, cache_type) if cache_type else self._cache_dir + + def get_cache_file_path(self, filename, cache_type=None): + return join(self.get_cache_dir(cache_type), filename) if filename else None + + def list_cache_files(self, cache_type=None): + path = self.get_cache_dir(cache_type) + return [file for file in listdir(path) if isfile(join(path, file))] + + def delete_cache_dir(self, cache_type=None): + if not cache_type and isdir(self._cache_dir): + rmtree(self._cache_dir) + if cache_type and isdir(join(self._cache_dir, cache_type)): + rmtree(join(self._cache_dir, cache_type)) + + def delete_cache_file(self, filename, cache_type=None): + if isfile(join(self.get_cache_dir(cache_type), filename)): + remove(join(self.get_cache_dir(cache_type), filename)) diff --git a/cps/helper.py b/cps/helper.py index d3420a11..271ab3e9 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -52,7 +52,7 @@ except ImportError: from . import calibre_db from .tasks.convert import TaskConvert -from . import logger, config, get_locale, db, thumbnails, ub +from . import logger, config, get_locale, db, fs, ub from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR from .subproc_wrapper import process_wait @@ -555,8 +555,9 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=1, di if not disable_thumbnail: thumbnail = get_book_cover_thumbnail(book, resolution) if thumbnail: - if os.path.isfile(thumbnails.get_thumbnail_cache_path(thumbnail)): - return send_from_directory(thumbnails.get_thumbnail_cache_dir(), thumbnail.filename) + cache = fs.FileSystem() + if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) # Send the book cover from Google Drive if configured if config.config_use_google_drive: diff --git a/cps/schedule.py b/cps/schedule.py index 5d2c94b9..5c658e41 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -18,9 +18,9 @@ from __future__ import division, print_function, unicode_literals -from . import logger +from . import config, db, logger, ub from .services.background_scheduler import BackgroundScheduler -from .tasks.thumbnail import TaskThumbnail +from .tasks.thumbnail import TaskCleanupCoverThumbnailCache, TaskGenerateCoverThumbnails log = logger.create() @@ -29,6 +29,16 @@ def register_jobs(): scheduler = BackgroundScheduler() # Generate 100 book cover thumbnails every 5 minutes - scheduler.add_task(user=None, task=lambda: TaskThumbnail(limit=100), trigger='interval', minutes=5) + scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=100), trigger='interval', minutes=5) - # TODO: validate thumbnail scheduled task + # Cleanup book cover cache every day at 4am + scheduler.add_task(user=None, task=lambda: TaskCleanupCoverThumbnailCache(), trigger='cron', hour=4) + + # Reconnect metadata.db every 4 hours + scheduler.add(func=reconnect_db_job, trigger='interval', hours=4) + + +def reconnect_db_job(): + log.info('Running background task: reconnect to calibre database') + calibre_db = db.CalibreDB() + calibre_db.reconnect_db(config, ub.app_DB_path) diff --git a/cps/services/worker.py b/cps/services/worker.py index 072674a0..2b6816db 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -35,7 +35,6 @@ def _get_main_thread(): raise Exception("main thread not found?!") - class ImprovedQueue(queue.Queue): def to_list(self): """ @@ -45,7 +44,8 @@ class ImprovedQueue(queue.Queue): with self.mutex: return list(self.queue) -#Class for all worker tasks in the background + +# Class for all worker tasks in the background class WorkerThread(threading.Thread): _instance = None @@ -127,6 +127,10 @@ class WorkerThread(threading.Thread): # CalibreTask.start() should wrap all exceptions in it's own error handling item.task.start(self) + # remove self_cleanup tasks from list + if item.task.self_cleanup: + self.dequeued.remove(item) + self.queue.task_done() @@ -141,6 +145,7 @@ class CalibreTask: self.end_time = None self.message = message self.id = uuid.uuid4() + self.self_cleanup = False @abc.abstractmethod def run(self, worker_thread): @@ -209,6 +214,14 @@ class CalibreTask: # todo: throw error if outside of [0,1] self._progress = x + @property + def self_cleanup(self): + return self._self_cleanup + + @self_cleanup.setter + def self_cleanup(self, is_self_cleanup): + self._self_cleanup = is_self_cleanup + def _handleError(self, error_message): self.stat = STAT_FAIL self.progress = 1 diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 4e0c6db4..f61eb4a7 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -19,13 +19,15 @@ from __future__ import division, print_function, unicode_literals import os -from cps import config, db, gdriveutils, logger, ub -from cps.constants import CACHE_DIR as _CACHE_DIR +from cps import config, db, fs, gdriveutils, logger, ub from cps.services.worker import CalibreTask -from cps.thumbnails import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X from datetime import datetime, timedelta from sqlalchemy import func -from urllib.request import urlopen + +try: + from urllib.request import urlopen +except ImportError as e: + from urllib2 import urlopen try: from wand.image import Image @@ -33,73 +35,92 @@ try: except (ImportError, RuntimeError) as e: use_IM = False +THUMBNAIL_RESOLUTION_1X = 1 +THUMBNAIL_RESOLUTION_2X = 2 -class TaskThumbnail(CalibreTask): + +class TaskGenerateCoverThumbnails(CalibreTask): def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): - super(TaskThumbnail, self).__init__(task_message) + super(TaskGenerateCoverThumbnails, self).__init__(task_message) + self.self_cleanup = True self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() - self.worker_db = db.CalibreDB(expire_on_commit=False) + self.calibre_db = db.CalibreDB(expire_on_commit=False) + self.cache = fs.FileSystem() + self.resolutions = [ + THUMBNAIL_RESOLUTION_1X, + THUMBNAIL_RESOLUTION_2X + ] def run(self, worker_thread): - if self.worker_db.session and use_IM: - thumbnails = self.get_thumbnail_book_ids() - thumbnail_book_ids = list(map(lambda t: t.book_id, thumbnails)) + if self.calibre_db.session and use_IM: + expired_thumbnails = self.get_expired_thumbnails() + thumbnail_book_ids = self.get_thumbnail_book_ids() books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) count = len(books_without_thumbnails) for i, book in enumerate(books_without_thumbnails): - thumbnails = self.get_thumbnails_for_book(thumbnails, book) - if thumbnails: - for thumbnail in thumbnails: - self.update_book_thumbnail(book, thumbnail) - - else: - self.create_book_thumbnail(book, THUMBNAIL_RESOLUTION_1X) - self.create_book_thumbnail(book, THUMBNAIL_RESOLUTION_2X) + for resolution in self.resolutions: + expired_thumbnail = self.get_expired_thumbnail_for_book_and_resolution( + book, + resolution, + expired_thumbnails + ) + if expired_thumbnail: + self.update_book_thumbnail(book, expired_thumbnail) + else: + self.create_book_thumbnail(book, resolution) self.progress = (1.0 / count) * i self._handleSuccess() - self.app_db_session.close() + self.app_db_session.remove() + + def get_expired_thumbnails(self): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.expiration < datetime.utcnow())\ + .all() def get_thumbnail_book_ids(self): return self.app_db_session\ - .query(ub.Thumbnail)\ + .query(ub.Thumbnail.book_id)\ .group_by(ub.Thumbnail.book_id)\ .having(func.min(ub.Thumbnail.expiration) > datetime.utcnow())\ - .all() + .distinct() def get_books_without_thumbnails(self, thumbnail_book_ids): - return self.worker_db.session\ + return self.calibre_db.session\ .query(db.Books)\ .filter(db.Books.has_cover == 1)\ .filter(db.Books.id.notin_(thumbnail_book_ids))\ .limit(self.limit)\ .all() - def get_thumbnails_for_book(self, thumbnails, book): - results = list() - for thumbnail in thumbnails: - if thumbnail.book_id == book.id: - results.append(thumbnail) + def get_expired_thumbnail_for_book_and_resolution(self, book, resolution, expired_thumbnails): + for thumbnail in expired_thumbnails: + if thumbnail.book_id == book.id and thumbnail.resolution == resolution: + return thumbnail - return results + return None def update_book_thumbnail(self, book, thumbnail): + thumbnail.generated_at = datetime.utcnow() thumbnail.expiration = datetime.utcnow() + timedelta(days=30) try: self.app_db_session.commit() self.generate_book_thumbnail(book, thumbnail) except Exception as ex: + self.log.info(u'Error updating book thumbnail: ' + str(ex)) self._handleError(u'Error updating book thumbnail: ' + str(ex)) self.app_db_session.rollback() def create_book_thumbnail(self, book, resolution): thumbnail = ub.Thumbnail() thumbnail.book_id = book.id + thumbnail.format = 'jpeg' thumbnail.resolution = resolution self.app_db_session.add(thumbnail) @@ -107,6 +128,7 @@ class TaskThumbnail(CalibreTask): self.app_db_session.commit() self.generate_book_thumbnail(book, thumbnail) except Exception as ex: + self.log.info(u'Error creating book thumbnail: ' + str(ex)) self._handleError(u'Error creating book thumbnail: ' + str(ex)) self.app_db_session.rollback() @@ -128,9 +150,12 @@ class TaskThumbnail(CalibreTask): if img.height > height: width = self.get_thumbnail_width(height, img) img.resize(width=width, height=height, filter='lanczos') - img.save(filename=self.get_thumbnail_cache_path(thumbnail)) + img.format = thumbnail.format + filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + img.save(filename=filename) except Exception as ex: # Bubble exception to calling function + self.log.info(u'Error generating thumbnail file: ' + str(ex)) raise ex finally: stream.close() @@ -144,7 +169,9 @@ class TaskThumbnail(CalibreTask): if img.height > height: width = self.get_thumbnail_width(height, img) img.resize(width=width, height=height, filter='lanczos') - img.save(filename=self.get_thumbnail_cache_path(thumbnail)) + img.format = thumbnail.format + filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + img.save(filename=filename) def get_thumbnail_height(self, thumbnail): return int(225 * thumbnail.resolution) @@ -153,20 +180,88 @@ class TaskThumbnail(CalibreTask): percent = (height / float(img.height)) return int((float(img.width) * float(percent))) - def get_thumbnail_cache_dir(self): - if not os.path.isdir(_CACHE_DIR): - os.makedirs(_CACHE_DIR) + @property + def name(self): + return "GenerateCoverThumbnails" - if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): - os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) - return os.path.join(_CACHE_DIR, 'thumbnails') +class TaskCleanupCoverThumbnailCache(CalibreTask): + def __init__(self, task_message=u'Validating cover thumbnail cache'): + super(TaskCleanupCoverThumbnailCache, 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.cache = fs.FileSystem() - def get_thumbnail_cache_path(self, thumbnail): - if thumbnail: - return os.path.join(self.get_thumbnail_cache_dir(), thumbnail.filename) - return None + def run(self, worker_thread): + cached_thumbnail_files = self.cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) + + # Expire thumbnails in the database if the cached file is missing + # This case will happen if a user deletes the cache dir or cached files + if self.app_db_session: + self.expire_missing_thumbnails(cached_thumbnail_files) + self.progress = 0.33 + + # Delete thumbnails in the database if the book has been removed + # This case will happen if a book is removed in Calibre and the metadata.db file is updated in the filesystem + if self.app_db_session and self.calibre_db: + book_ids = self.get_book_ids() + self.delete_thumbnails_for_missing_books(book_ids) + self.progress = 0.66 + + # Delete extraneous cached thumbnail files + # This case will happen if a book was deleted and the thumbnail OR the metadata.db file was changed externally + if self.app_db_session: + db_thumbnail_files = self.get_thumbnail_filenames() + self.delete_extraneous_thumbnail_files(cached_thumbnail_files, db_thumbnail_files) + + self._handleSuccess() + self.app_db_session.remove() + + def expire_missing_thumbnails(self, filenames): + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.filename.notin_(filenames))\ + .update({"expiration": datetime.utcnow()}, synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(u'Error expiring thumbnails for missing cache files: ' + str(ex)) + self._handleError(u'Error expiring thumbnails for missing cache files: ' + str(ex)) + self.app_db_session.rollback() + + def get_book_ids(self): + results = self.calibre_db.session\ + .query(db.Books.id)\ + .filter(db.Books.has_cover == 1)\ + .distinct() + + return [value for value, in results] + + def delete_thumbnails_for_missing_books(self, book_ids): + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.notin_(book_ids))\ + .delete(synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(str(ex)) + self._handleError(u'Error deleting thumbnails for missing books: ' + str(ex)) + self.app_db_session.rollback() + + def get_thumbnail_filenames(self): + results = self.app_db_session\ + .query(ub.Thumbnail.filename)\ + .all() + + return [thumbnail for thumbnail, in results] + + def delete_extraneous_thumbnail_files(self, cached_thumbnail_files, db_thumbnail_files): + extraneous_files = list(set(cached_thumbnail_files).difference(db_thumbnail_files)) + for file in extraneous_files: + self.cache.delete_cache_file(file, fs.CACHE_TYPE_THUMBNAILS) @property def name(self): - return "Thumbnail" + return "CleanupCoverThumbnailCache" diff --git a/cps/thumbnails.py b/cps/thumbnails.py deleted file mode 100644 index ea7aac86..00000000 --- a/cps/thumbnails.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- - -# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) -# Copyright (C) 2020 mmonkey -# -# 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 __future__ import division, print_function, unicode_literals -import os - -from . import logger, ub -from .constants import CACHE_DIR as _CACHE_DIR - -from datetime import datetime - -THUMBNAIL_RESOLUTION_1X = 1 -THUMBNAIL_RESOLUTION_2X = 2 - -log = logger.create() - - -def get_thumbnail_cache_dir(): - if not os.path.isdir(_CACHE_DIR): - os.makedirs(_CACHE_DIR) - if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): - os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) - return os.path.join(_CACHE_DIR, 'thumbnails') - - -def get_thumbnail_cache_path(thumbnail): - if thumbnail: - return os.path.join(get_thumbnail_cache_dir(), thumbnail.filename) - return None - - -def cover_thumbnail_exists_for_book(book): - if book and book.has_cover: - thumbnail = ub.session.query(ub.Thumbnail).filter(ub.Thumbnail.book_id == book.id).first() - if thumbnail and thumbnail.expiration > datetime.utcnow(): - thumbnail_path = get_thumbnail_cache_path(thumbnail) - return thumbnail_path and os.path.isfile(thumbnail_path) - - return False diff --git a/cps/ub.py b/cps/ub.py index 0b5a65e7..30abd728 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -18,6 +18,7 @@ # along with this program. If not, see . from __future__ import division, print_function, unicode_literals +import atexit import os import sys import datetime @@ -42,12 +43,11 @@ from sqlalchemy import create_engine, exc, exists, event from sqlalchemy import Column, ForeignKey from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session from werkzeug.security import generate_password_hash -from . import cli, constants +from . import cli, constants, logger session = None @@ -435,6 +435,14 @@ class RemoteAuthToken(Base): return '' % self.id +def filename(context): + file_format = context.get_current_parameters()['format'] + if file_format == 'jpeg': + return context.get_current_parameters()['uuid'] + '.jpg' + else: + return context.get_current_parameters()['uuid'] + '.' + file_format + + class Thumbnail(Base): __tablename__ = 'thumbnail' @@ -443,19 +451,10 @@ class Thumbnail(Base): uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) format = Column(String, default='jpeg') resolution = Column(SmallInteger, default=1) + filename = Column(String, default=filename) + generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) - @hybrid_property - def extension(self): - if self.format == 'jpeg': - return 'jpg' - else: - return self.format - - @hybrid_property - def filename(self): - return self.uuid + '.' + self.extension - # Migrate database to current version, has to be updated after every database change. Currently migration from # everywhere to current should work. Migration is done by checking if relevant columns are existing, and than adding @@ -705,6 +704,9 @@ def get_new_session_instance(): new_engine = create_engine(u'sqlite:///{0}'.format(cli.settingspath), echo=False) new_session = scoped_session(sessionmaker()) new_session.configure(bind=new_engine) + + atexit.register(lambda: new_session.remove() if new_session else True) + return new_session From 626051e4892ef42d65e16f01932ae8ce48342c22 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Wed, 23 Dec 2020 03:25:25 -0600 Subject: [PATCH 005/220] Added clear cache button to admin settings, updated cache busting for book cover images --- cps/admin.py | 19 +++++++- cps/editbooks.py | 2 + cps/helper.py | 23 +++++++--- cps/jinjia.py | 6 +++ cps/schedule.py | 12 +---- cps/static/css/caliBlur.css | 82 ++++++++++++++++++++++++++++------- cps/static/js/main.js | 12 +++++ cps/tasks/database.py | 49 +++++++++++++++++++++ cps/tasks/thumbnail.py | 58 ++++++++++++++++++++++++- cps/templates/admin.html | 36 +++++++++++---- cps/templates/author.html | 2 +- cps/templates/book_cover.html | 8 ++-- cps/templates/book_edit.html | 3 +- cps/templates/detail.html | 3 +- cps/templates/discover.html | 2 +- cps/templates/grid.html | 2 +- cps/templates/index.html | 4 +- cps/templates/search.html | 2 +- cps/templates/shelf.html | 2 +- cps/web.py | 8 +++- 20 files changed, 278 insertions(+), 57 deletions(-) create mode 100644 cps/tasks/database.py diff --git a/cps/admin.py b/cps/admin.py index 8b3ca247..dbc1b708 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -38,7 +38,7 @@ from sqlalchemy import and_ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_ -from . import constants, logger, helper, services +from . import constants, logger, helper, services, fs from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash from .gdriveutils import is_gdrive_ready, gdrive_support @@ -157,6 +157,23 @@ def shutdown(): return json.dumps(showtext), 400 +@admi.route("/clear-cache") +@login_required +@admin_required +def clear_cache(): + cache_type = request.args.get('cache_type'.strip()) + showtext = {} + + if cache_type == fs.CACHE_TYPE_THUMBNAILS: + log.info('clearing cover thumbnail cache') + showtext['text'] = _(u'Cleared cover thumbnail cache') + helper.clear_cover_thumbnail_cache() + return json.dumps(showtext) + + showtext['text'] = _(u'Unknown command') + return json.dumps(showtext) + + @admi.route("/admin/view") @login_required @admin_required diff --git a/cps/editbooks.py b/cps/editbooks.py index 08ee93b1..6d26ebca 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -595,6 +595,7 @@ def upload_cover(request, book): abort(403) ret, message = helper.save_cover(requested_file, book.path) if ret is True: + helper.clear_cover_thumbnail_cache(book.id) return True else: flash(message, category="error") @@ -684,6 +685,7 @@ def edit_book(book_id): if result is True: book.has_cover = 1 modif_date = True + helper.clear_cover_thumbnail_cache(book.id) else: flash(error, category="error") diff --git a/cps/helper.py b/cps/helper.py index 271ab3e9..0b0c675f 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -58,6 +58,7 @@ from .constants import STATIC_DIR as _STATIC_DIR from .subproc_wrapper import process_wait from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS from .tasks.mail import TaskEmail +from .tasks.thumbnail import TaskClearCoverThumbnailCache log = logger.create() @@ -525,6 +526,7 @@ def update_dir_stucture(book_id, calibrepath, first_author=None, orignal_filepat def delete_book(book, calibrepath, book_format): + clear_cover_thumbnail_cache(book.id) if config.config_use_google_drive: return delete_book_gdrive(book, book_format) else: @@ -538,9 +540,9 @@ def get_cover_on_failure(use_generic_cover): return None -def get_book_cover(book_id, resolution=1): +def get_book_cover(book_id): 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, use_generic_cover_on_failure=True) def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): @@ -548,11 +550,19 @@ def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): return get_book_cover_internal(book, use_generic_cover_on_failure) -def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=1, disable_thumbnail=False): +def get_cached_book_cover(cache_id): + parts = cache_id.split('_') + book_uuid = parts[0] if len(parts) else None + resolution = parts[2] if len(parts) > 2 else None + book = calibre_db.get_book_by_uuid(book_uuid) if book_uuid else None + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) + + +def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): if book and book.has_cover: # Send the book cover thumbnail if it exists in cache - if not disable_thumbnail: + if resolution: thumbnail = get_book_cover_thumbnail(book, resolution) if thumbnail: cache = fs.FileSystem() @@ -585,7 +595,7 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=1, di return get_cover_on_failure(use_generic_cover_on_failure) -def get_book_cover_thumbnail(book, resolution=1): +def get_book_cover_thumbnail(book, resolution): if book and book.has_cover: return ub.session\ .query(ub.Thumbnail)\ @@ -846,3 +856,6 @@ def get_download_link(book_id, book_format, client): else: abort(404) + +def clear_cover_thumbnail_cache(book_id=None): + WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id)) diff --git a/cps/jinjia.py b/cps/jinjia.py index 688d1fba..bf81c059 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -128,8 +128,14 @@ def formatseriesindex_filter(series_index): return series_index return 0 + @jinjia.app_template_filter('uuidfilter') def uuidfilter(var): return uuid4() +@jinjia.app_template_filter('book_cover_cache_id') +def book_cover_cache_id(book, resolution=None): + timestamp = int(book.last_modified.timestamp() * 1000) + cache_bust = str(book.uuid) + '_' + str(timestamp) + return cache_bust if not resolution else cache_bust + '_' + str(resolution) diff --git a/cps/schedule.py b/cps/schedule.py index 5c658e41..f349a231 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -18,12 +18,10 @@ from __future__ import division, print_function, unicode_literals -from . import config, db, logger, ub from .services.background_scheduler import BackgroundScheduler +from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskCleanupCoverThumbnailCache, TaskGenerateCoverThumbnails -log = logger.create() - def register_jobs(): scheduler = BackgroundScheduler() @@ -35,10 +33,4 @@ def register_jobs(): scheduler.add_task(user=None, task=lambda: TaskCleanupCoverThumbnailCache(), trigger='cron', hour=4) # Reconnect metadata.db every 4 hours - scheduler.add(func=reconnect_db_job, trigger='interval', hours=4) - - -def reconnect_db_job(): - log.info('Running background task: reconnect to calibre database') - calibre_db = db.CalibreDB() - calibre_db.reconnect_db(config, ub.app_DB_path) + scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='interval', hours=4) diff --git a/cps/static/css/caliBlur.css b/cps/static/css/caliBlur.css index d085608d..da5d2933 100644 --- a/cps/static/css/caliBlur.css +++ b/cps/static/css/caliBlur.css @@ -5167,7 +5167,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head pointer-events: none } -#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { +#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #ClearCacheDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { cursor: pointer } @@ -5254,7 +5254,7 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d margin-bottom: 20px } -body.admin:not(.modal-open) .btn-default { +body.admin .btn-default { margin-bottom: 10px } @@ -5485,7 +5485,7 @@ body.admin.modal-open .navbar { z-index: 0 !important } -#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal { +#RestartDialog, #ShutdownDialog, #StatusDialog, #ClearCacheDialog, #deleteModal { top: 0; overflow: hidden; padding-top: 70px; @@ -5495,7 +5495,7 @@ body.admin.modal-open .navbar { background: rgba(0, 0, 0, .5) } -#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before { +#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #ClearCacheDialog:before, #deleteModal:before { content: "\E208"; padding-right: 10px; display: block; @@ -5517,18 +5517,18 @@ body.admin.modal-open .navbar { z-index: 99 } -#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { +#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0) } -#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { +#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { width: 450px; margin: auto } -#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { +#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { max-height: calc(100% - 90px); -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); box-shadow: 0 5px 15px rgba(0, 0, 0, .5); @@ -5539,7 +5539,7 @@ body.admin.modal-open .navbar { width: 450px } -#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { +#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { padding: 15px 20px; border-radius: 3px 3px 0 0; line-height: 1.71428571; @@ -5552,7 +5552,7 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { padding-right: 10px; font-size: 18px; color: #999; @@ -5576,6 +5576,11 @@ body.admin.modal-open .navbar { font-family: plex-icons-new, serif } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before { + content: "\EA15"; + font-family: plex-icons-new, serif +} + #deleteModal > .modal-dialog > .modal-content > .modal-header:before { content: "\EA6D"; font-family: plex-icons-new, serif @@ -5599,6 +5604,12 @@ body.admin.modal-open .navbar { font-size: 20px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:after { + content: "Clear Cover Thumbnail Cache"; + display: inline-block; + font-size: 20px +} + #deleteModal > .modal-dialog > .modal-content > .modal-header:after { content: "Delete Book"; display: inline-block; @@ -5629,7 +5640,17 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body { + padding: 20px 20px 10px; + font-size: 16px; + line-height: 1.6em; + font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif; + color: #eee; + background: #282828; + text-align: left +} + +#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { padding: 20px 20px 0 0; font-size: 16px; line-height: 1.6em; @@ -5638,7 +5659,7 @@ body.admin.modal-open .navbar { background: #282828 } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { float: right; z-index: 9; position: relative; @@ -5674,6 +5695,18 @@ body.admin.modal-open .navbar { border-radius: 3px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > #clear_cache { + float: right; + z-index: 9; + position: relative; + margin: 25px 0 0 10px; + min-width: 80px; + padding: 10px 18px; + font-size: 16px; + line-height: 1.33; + border-radius: 3px +} + #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; @@ -5694,11 +5727,15 @@ body.admin.modal-open .navbar { margin: 55px 0 0 10px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache) { + margin: 25px 0 0 10px +} + #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { margin: 0 0 0 10px } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { background-color: hsla(0, 0%, 100%, .3) } @@ -5732,6 +5769,21 @@ body.admin.modal-open .navbar { box-shadow: 0 5px 15px rgba(0, 0, 0, .5) } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body:after { + content: ''; + position: absolute; + width: 100%; + height: 72px; + background-color: #323232; + border-radius: 0 0 3px 3px; + left: 0; + margin-top: 10px; + z-index: 0; + border-top: 1px solid #222; + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + box-shadow: 0 5px 15px rgba(0, 0, 0, .5) +} + #deleteButton { position: fixed; top: 60px; @@ -7322,11 +7374,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. background-color: transparent !important } - #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { + #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { max-width: calc(100vw - 40px) } - #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { + #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { max-width: calc(100vw - 40px); left: 0 } @@ -7476,7 +7528,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. padding: 30px 15px } - #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { + #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { left: auto; right: 34px } diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 3fbaed88..d8c1863f 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -405,6 +405,18 @@ $(function() { } }); }); + $("#clear_cache").click(function () { + $("#spinner3").show(); + $.ajax({ + dataType: "json", + url: window.location.pathname + "/../../clear-cache", + data: {"cache_type":"thumbnails"}, + success: function(data) { + $("#spinner3").hide(); + $("#ClearCacheDialog").modal("hide"); + } + }); + }); // Init all data control handlers to default $("input[data-control]").trigger("change"); diff --git a/cps/tasks/database.py b/cps/tasks/database.py new file mode 100644 index 00000000..11f0186d --- /dev/null +++ b/cps/tasks/database.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals + +from cps import config, logger +from cps.services.worker import CalibreTask + +try: + from urllib.request import urlopen +except ImportError as e: + from urllib2 import urlopen + + +class TaskReconnectDatabase(CalibreTask): + def __init__(self, task_message=u'Reconnecting Calibre database'): + super(TaskReconnectDatabase, self).__init__(task_message) + self.log = logger.create() + self.listen_address = config.get_config_ipaddress() + self.listen_port = config.config_port + + def run(self, worker_thread): + address = self.listen_address if self.listen_address else 'localhost' + port = self.listen_port if self.listen_port else 8083 + + try: + urlopen('http://' + address + ':' + str(port) + '/reconnect') + self._handleSuccess() + except Exception as ex: + self._handleError(u'Unable to reconnect Calibre database: ' + str(ex)) + + @property + def name(self): + return "Reconnect Database" diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index f61eb4a7..b541bc70 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -42,7 +42,6 @@ THUMBNAIL_RESOLUTION_2X = 2 class TaskGenerateCoverThumbnails(CalibreTask): def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): super(TaskGenerateCoverThumbnails, self).__init__(task_message) - self.self_cleanup = True self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -186,7 +185,7 @@ class TaskGenerateCoverThumbnails(CalibreTask): class TaskCleanupCoverThumbnailCache(CalibreTask): - def __init__(self, task_message=u'Validating cover thumbnail cache'): + def __init__(self, task_message=u'Cleaning up cover thumbnail cache'): super(TaskCleanupCoverThumbnailCache, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -265,3 +264,58 @@ class TaskCleanupCoverThumbnailCache(CalibreTask): @property def name(self): return "CleanupCoverThumbnailCache" + + +class TaskClearCoverThumbnailCache(CalibreTask): + def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): + super(TaskClearCoverThumbnailCache, self).__init__(task_message) + self.log = logger.create() + self.book_id = book_id + self.app_db_session = ub.get_new_session_instance() + self.cache = fs.FileSystem() + + def run(self, worker_thread): + if self.app_db_session: + if self.book_id: + thumbnails = self.get_thumbnails_for_book(self.book_id) + for thumbnail in thumbnails: + self.expire_and_delete_thumbnail(thumbnail) + else: + self.expire_and_delete_all_thumbnails() + + self._handleSuccess() + self.app_db_session.remove() + + def get_thumbnails_for_book(self, book_id): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book_id)\ + .all() + + def expire_and_delete_thumbnail(self, thumbnail): + thumbnail.expiration = datetime.utcnow() + + try: + self.app_db_session.commit() + self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + except Exception as ex: + self.log.info(u'Error expiring book thumbnail: ' + str(ex)) + self._handleError(u'Error expiring book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def expire_and_delete_all_thumbnails(self): + self.app_db_session\ + .query(ub.Thumbnail)\ + .update({'expiration': datetime.utcnow()}) + + try: + self.app_db_session.commit() + self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) + except Exception as ex: + self.log.info(u'Error expiring book thumbnails: ' + str(ex)) + self._handleError(u'Error expiring book thumbnails: ' + str(ex)) + self.app_db_session.rollback() + + @property + def name(self): + return "ClearCoverThumbnailCache" diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 1ef64157..e3d20db3 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -139,15 +139,18 @@
-
+

{{_('Administration')}}

- - -
-
-
{{_('Reconnect Calibre Database')}}
-
{{_('Restart')}}
-
{{_('Shutdown')}}
+ + +
+
+
{{_('Reconnect Calibre Database')}}
+
{{_('Clear Cover Thumbnail Cache')}}
+
+
+
{{_('Restart')}}
+
{{_('Shutdown')}}
@@ -226,4 +229,21 @@
+ {% endblock %} diff --git a/cps/templates/author.html b/cps/templates/author.html index 24ce876a..3cf3fb4b 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -36,7 +36,7 @@
diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html index 878c14a8..3f0ed154 100644 --- a/cps/templates/book_cover.html +++ b/cps/templates/book_cover.html @@ -1,8 +1,8 @@ -{% macro book_cover_image(book_id, book_title) -%} +{% macro book_cover_image(book, book_title) -%} {{ book_title }} {%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 881fa8ff..369b8d05 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -4,8 +4,7 @@ {% if book %}
- {{ book_cover_image(book.id, book.title) }} - + {{ book_cover_image(book, book.title) }}
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index d3615563..cdf6ab2b 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,8 +4,7 @@
- {{ book_cover_image(entry.id, entry.title) }} - + {{ book_cover_image(entry, entry.title) }}
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index 33bafbbe..5d4666f6 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -9,7 +9,7 @@ diff --git a/cps/templates/grid.html b/cps/templates/grid.html index bc3ca4a2..67594b4e 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -29,7 +29,7 @@
diff --git a/cps/templates/index.html b/cps/templates/index.html index c536884f..531a535c 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -9,7 +9,7 @@
@@ -83,7 +83,7 @@
diff --git a/cps/templates/search.html b/cps/templates/search.html index 56b12154..8e0cf668 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -44,7 +44,7 @@ diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 7a678ea6..bebc0b1f 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -31,7 +31,7 @@
diff --git a/cps/web.py b/cps/web.py index 27a5849b..16c14fcf 100644 --- a/cps/web.py +++ b/cps/web.py @@ -50,7 +50,7 @@ from . import babel, db, ub, config, get_locale, app from . import calibre_db, shelf from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .helper import check_valid_domain, render_task_status, \ - get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ + get_cc_columns, get_book_cover, get_cached_book_cover, get_download_link, send_mail, generate_random_password, \ send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password from .pagination import Pagination from .redirect import redirect_back @@ -1177,6 +1177,12 @@ def get_cover(book_id, resolution=1): return get_book_cover(book_id, resolution) +@web.route("/cached-cover/") +@login_required_if_no_ano +def get_cached_cover(cache_id): + return get_cached_book_cover(cache_id) + + @web.route("/robots.txt") def get_robots(): return send_from_directory(constants.STATIC_DIR, "robots.txt") From 242a2767a1e374a938de2f1b3fdb7cb2175c5fd1 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Thu, 24 Dec 2020 02:35:32 -0600 Subject: [PATCH 006/220] Added thumbnail urls to book cover srcsets with cache busting ids --- cps/db.py | 3 +- cps/helper.py | 38 +++++++++++++++ cps/jinjia.py | 16 +++++++ cps/schedule.py | 10 ++-- cps/tasks/thumbnail.py | 61 ++++++++++++++++++++---- cps/templates/author.html | 2 +- cps/templates/book_cover.html | 19 +++++--- cps/templates/book_edit.html | 2 +- cps/templates/detail.html | 2 +- cps/templates/discover.html | 2 +- cps/templates/grid.html | 2 +- cps/templates/index.html | 4 +- cps/templates/search.html | 2 +- cps/templates/shelf.html | 2 +- cps/web.py | 89 +++++++++++++++++++++++++---------- 15 files changed, 199 insertions(+), 55 deletions(-) diff --git a/cps/db.py b/cps/db.py index 2e428f72..8b8db10a 100644 --- a/cps/db.py +++ b/cps/db.py @@ -609,7 +609,8 @@ class CalibreDB(): randm = self.session.query(Books) \ .filter(self.common_filters(allow_show_archived)) \ .order_by(func.random()) \ - .limit(self.config.config_random_books) + .limit(self.config.config_random_books) \ + .all() else: randm = false() off = int(int(pagesize) * (page - 1)) diff --git a/cps/helper.py b/cps/helper.py index 0b0c675f..c33a69e1 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -533,6 +533,21 @@ def delete_book(book, calibrepath, book_format): return delete_book_file(book, calibrepath, book_format) +def get_thumbnails_for_books(books): + books_with_covers = list(filter(lambda b: b.has_cover, books)) + book_ids = list(map(lambda b: b.id, books_with_covers)) + return ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.in_(book_ids))\ + .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .all() + + +def get_thumbnails_for_book_series(series): + books = list(map(lambda s: s[0], series)) + return get_thumbnails_for_books(books) + + def get_cover_on_failure(use_generic_cover): if use_generic_cover: return send_from_directory(_STATIC_DIR, "generic_cover.jpg") @@ -558,6 +573,29 @@ def get_cached_book_cover(cache_id): return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) +def get_cached_book_cover_thumbnail(cache_id): + parts = cache_id.split('_') + thumbnail_uuid = parts[0] if len(parts) else None + thumbnail = None + if thumbnail_uuid: + thumbnail = ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.uuid == thumbnail_uuid)\ + .first() + + if thumbnail and thumbnail.expiration > datetime.utcnow(): + cache = fs.FileSystem() + if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) + + elif thumbnail: + book = calibre_db.get_book(thumbnail.book_id) + return get_book_cover_internal(book, use_generic_cover_on_failure=True) + + else: + return get_cover_on_failure(True) + + def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): if book and book.has_cover: diff --git a/cps/jinjia.py b/cps/jinjia.py index bf81c059..b2479adc 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -139,3 +139,19 @@ def book_cover_cache_id(book, resolution=None): timestamp = int(book.last_modified.timestamp() * 1000) cache_bust = str(book.uuid) + '_' + str(timestamp) return cache_bust if not resolution else cache_bust + '_' + str(resolution) + + +@jinjia.app_template_filter('get_book_thumbnails') +def get_book_thumbnails(book_id, thumbnails=None): + return list(filter(lambda t: t.book_id == book_id, thumbnails)) if book_id > -1 and thumbnails else list() + + +@jinjia.app_template_filter('get_book_thumbnail_srcset') +def get_book_thumbnail_srcset(thumbnails): + srcset = list() + for thumbnail in thumbnails: + timestamp = int(thumbnail.generated_at.timestamp() * 1000) + cache_id = str(thumbnail.uuid) + '_' + str(timestamp) + url = url_for('web.get_cached_cover_thumbnail', cache_id=cache_id) + srcset.append(url + ' ' + str(thumbnail.resolution) + 'x') + return ', '.join(srcset) diff --git a/cps/schedule.py b/cps/schedule.py index f349a231..7ee43410 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -20,17 +20,17 @@ from __future__ import division, print_function, unicode_literals from .services.background_scheduler import BackgroundScheduler from .tasks.database import TaskReconnectDatabase -from .tasks.thumbnail import TaskCleanupCoverThumbnailCache, TaskGenerateCoverThumbnails +from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails def register_jobs(): scheduler = BackgroundScheduler() # Generate 100 book cover thumbnails every 5 minutes - scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=100), trigger='interval', minutes=5) + scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=100), trigger='cron', minute='*/5') - # Cleanup book cover cache every day at 4am - scheduler.add_task(user=None, task=lambda: TaskCleanupCoverThumbnailCache(), trigger='cron', hour=4) + # Cleanup book cover cache every 6 hours + scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', minute='15', hour='*/6') # Reconnect metadata.db every 4 hours - scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='interval', hours=4) + scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', minute='5', hour='*/4') diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index b541bc70..e9df170e 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -59,6 +59,10 @@ class TaskGenerateCoverThumbnails(CalibreTask): books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) count = len(books_without_thumbnails) + if count == 0: + # Do not display this task on the frontend if there are no covers to update + self.self_cleanup = True + for i, book in enumerate(books_without_thumbnails): for resolution in self.resolutions: expired_thumbnail = self.get_expired_thumbnail_for_book_and_resolution( @@ -71,6 +75,7 @@ class TaskGenerateCoverThumbnails(CalibreTask): else: self.create_book_thumbnail(book, resolution) + self.message(u'Generating cover thumbnail {0} of {1}'.format(i, count)) self.progress = (1.0 / count) * i self._handleSuccess() @@ -181,12 +186,12 @@ class TaskGenerateCoverThumbnails(CalibreTask): @property def name(self): - return "GenerateCoverThumbnails" + return "ThumbnailsGenerate" -class TaskCleanupCoverThumbnailCache(CalibreTask): - def __init__(self, task_message=u'Cleaning up cover thumbnail cache'): - super(TaskCleanupCoverThumbnailCache, self).__init__(task_message) +class TaskSyncCoverThumbnailCache(CalibreTask): + def __init__(self, task_message=u'Syncing cover thumbnail cache'): + super(TaskSyncCoverThumbnailCache, 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) @@ -199,14 +204,23 @@ class TaskCleanupCoverThumbnailCache(CalibreTask): # This case will happen if a user deletes the cache dir or cached files if self.app_db_session: self.expire_missing_thumbnails(cached_thumbnail_files) - self.progress = 0.33 + self.progress = 0.25 # Delete thumbnails in the database if the book has been removed # This case will happen if a book is removed in Calibre and the metadata.db file is updated in the filesystem if self.app_db_session and self.calibre_db: book_ids = self.get_book_ids() self.delete_thumbnails_for_missing_books(book_ids) - self.progress = 0.66 + self.progress = 0.50 + + # Expire thumbnails in the database if their corresponding book has been updated since they were generated + # This case will happen if the book was updated externally + if self.app_db_session and self.cache: + books = self.get_books_updated_in_the_last_day() + book_ids = list(map(lambda b: b.id, books)) + thumbnails = self.get_thumbnails_for_updated_books(book_ids) + self.expire_thumbnails_for_updated_book(books, thumbnails) + self.progress = 0.75 # Delete extraneous cached thumbnail files # This case will happen if a book was deleted and the thumbnail OR the metadata.db file was changed externally @@ -261,9 +275,40 @@ class TaskCleanupCoverThumbnailCache(CalibreTask): for file in extraneous_files: self.cache.delete_cache_file(file, fs.CACHE_TYPE_THUMBNAILS) + def get_books_updated_in_the_last_day(self): + return self.calibre_db.session\ + .query(db.Books)\ + .filter(db.Books.has_cover == 1)\ + .filter(db.Books.last_modified > datetime.utcnow() - timedelta(days=1, hours=1))\ + .all() + + def get_thumbnails_for_updated_books(self, book_ids): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.in_(book_ids))\ + .all() + + def expire_thumbnails_for_updated_book(self, books, thumbnails): + thumbnail_ids = list() + for book in books: + for thumbnail in thumbnails: + if thumbnail.book_id == book.id and thumbnail.generated_at < book.last_modified: + thumbnail_ids.append(thumbnail.id) + + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.id.in_(thumbnail_ids)) \ + .update({"expiration": datetime.utcnow()}, synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(u'Error expiring thumbnails for updated books: ' + str(ex)) + self._handleError(u'Error expiring thumbnails for updated books: ' + str(ex)) + self.app_db_session.rollback() + @property def name(self): - return "CleanupCoverThumbnailCache" + return "ThumbnailsSync" class TaskClearCoverThumbnailCache(CalibreTask): @@ -318,4 +363,4 @@ class TaskClearCoverThumbnailCache(CalibreTask): @property def name(self): - return "ClearCoverThumbnailCache" + return "ThumbnailsClear" diff --git a/cps/templates/author.html b/cps/templates/author.html index 3cf3fb4b..e5acdd2f 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -36,7 +36,7 @@
diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html index 3f0ed154..c5281797 100644 --- a/cps/templates/book_cover.html +++ b/cps/templates/book_cover.html @@ -1,8 +1,13 @@ -{% macro book_cover_image(book, book_title) -%} - {{ book_title }} +{% macro book_cover_image(book, thumbnails) -%} + {%- set book_title = book.title if book.title else book.name -%} + {% set srcset = thumbnails|get_book_thumbnail_srcset if thumbnails|length else '' %} + {%- if srcset|length -%} + {{ book_title }} + {%- else -%} + {{ book_title }} + {%- endif -%} {%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 369b8d05..c538d5ca 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -4,7 +4,7 @@ {% if book %}
- {{ book_cover_image(book, book.title) }} + {{ book_cover_image(book, book.id|get_book_thumbnails(thumbnails)) }}
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index cdf6ab2b..671186c7 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,7 +4,7 @@
- {{ book_cover_image(entry, entry.title) }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }}
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index 5d4666f6..c5c12db2 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -9,7 +9,7 @@ diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 67594b4e..0f669d51 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -29,7 +29,7 @@
diff --git a/cps/templates/index.html b/cps/templates/index.html index 531a535c..2be15ba7 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -9,7 +9,7 @@
@@ -83,7 +83,7 @@
diff --git a/cps/templates/search.html b/cps/templates/search.html index 8e0cf668..a5871afb 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -44,7 +44,7 @@ diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index bebc0b1f..cb55c50c 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -31,7 +31,7 @@
diff --git a/cps/web.py b/cps/web.py index 16c14fcf..c400b96e 100644 --- a/cps/web.py +++ b/cps/web.py @@ -49,9 +49,10 @@ from . import constants, logger, isoLanguages, services from . import babel, db, ub, config, get_locale, app from . import calibre_db, shelf from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download -from .helper import check_valid_domain, render_task_status, \ - get_cc_columns, get_book_cover, get_cached_book_cover, get_download_link, send_mail, generate_random_password, \ - send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password +from .helper import check_valid_domain, render_task_status, get_cc_columns, get_book_cover, get_cached_book_cover, \ + get_cached_book_cover_thumbnail, get_thumbnails_for_books, get_thumbnails_for_book_series, get_download_link, \ + send_mail, generate_random_password, send_registration_mail, check_send_to_kindle, check_read_formats, \ + tags_filters, reset_password from .pagination import Pagination from .redirect import redirect_back from .usermanagement import login_required_if_no_ano @@ -386,16 +387,18 @@ def render_books_list(data, sort, book_id, page): db.Books, db.Books.ratings.any(db.Ratings.rating > 9), order) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - id=book_id, title=_(u"Top Rated Books"), page="rated") + id=book_id, title=_(u"Top Rated Books"), page="rated", thumbnails=thumbnails) else: abort(404) elif data == "discover": if current_user.check_visibility(constants.SIDEBAR_RANDOM): entries, __, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, [func.randomblob(2)]) pagination = Pagination(1, config.config_books_per_page, config.config_books_per_page) + thumbnails = get_thumbnails_for_books(entries) return render_title_template('discover.html', entries=entries, pagination=pagination, id=book_id, - title=_(u"Discover (Random Books)"), page="discover") + title=_(u"Discover (Random Books)"), page="discover", thumbnails=thumbnails) else: abort(404) elif data == "unread": @@ -433,8 +436,9 @@ def render_books_list(data, sort, book_id, page): else: website = data or "newest" entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, order) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Books"), page=website) + title=_(u"Books"), page=website, thumbnails=thumbnails) def render_hot_books(page): @@ -458,8 +462,9 @@ def render_hot_books(page): ub.delete_download(book.Downloads.book_id) numBooks = entries.__len__() pagination = Pagination(page, config.config_books_per_page, numBooks) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Hot Books (Most Downloaded)"), page="hot") + title=_(u"Hot Books (Most Downloaded)"), page="hot", thumbnails=thumbnails) else: abort(404) @@ -490,12 +495,14 @@ def render_downloaded_books(page, order): .filter(db.Books.id == book.id).first(): ub.delete_download(book.id) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Downloaded books by %(user)s",user=current_user.nickname), - page="download") + page="download", + thumbnails=thumbnails) else: abort(404) @@ -521,9 +528,10 @@ def render_author_books(page, author_id, order): author_info = services.goodreads_support.get_author_info(author_name) other_books = services.goodreads_support.get_other_books(author_info, entries) + thumbnails = get_thumbnails_for_books(entries) return render_title_template('author.html', entries=entries, pagination=pagination, id=author_id, title=_(u"Author: %(name)s", name=author_name), author=author_info, - other_books=other_books, page="author") + other_books=other_books, page="author", thumbnails=thumbnails) def render_publisher_books(page, book_id, order): @@ -535,8 +543,10 @@ def render_publisher_books(page, book_id, order): [db.Series.name, order[0], db.Books.series_index], db.books_series_link, db.Series) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher") + title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher", + thumbnails=thumbnails) else: abort(404) @@ -548,8 +558,10 @@ def render_series_books(page, book_id, order): db.Books, db.Books.series.any(db.Series.id == book_id), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Series: %(serie)s", serie=name.name), page="series") + title=_(u"Series: %(serie)s", serie=name.name), page="series", + thumbnails=thumbnails) else: abort(404) @@ -561,8 +573,10 @@ def render_ratings_books(page, book_id, order): db.Books.ratings.any(db.Ratings.id == book_id), [order[0]]) if name and name.rating <= 10: + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings") + title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings", + thumbnails=thumbnails) else: abort(404) @@ -574,8 +588,10 @@ def render_formats_books(page, book_id, order): db.Books, db.Books.data.any(db.Data.format == book_id.upper()), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"File format: %(format)s", format=name.format), page="formats") + title=_(u"File format: %(format)s", format=name.format), page="formats", + thumbnails=thumbnails) else: abort(404) @@ -588,8 +604,10 @@ def render_category_books(page, book_id, order): db.Books.tags.any(db.Tags.id == book_id), [order[0], db.Series.name, db.Books.series_index], db.books_series_link, db.Series) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Category: %(name)s", name=name.name), page="category") + title=_(u"Category: %(name)s", name=name.name), page="category", + thumbnails=thumbnails) else: abort(404) @@ -607,8 +625,9 @@ def render_language_books(page, name, order): db.Books, db.Books.languages.any(db.Languages.lang_code == name), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=name, - title=_(u"Language: %(name)s", name=lang_name), page="language") + title=_(u"Language: %(name)s", name=lang_name), page="language", thumbnails=thumbnails) def render_read_books(page, are_read, as_xml=False, order=None): @@ -652,8 +671,10 @@ def render_read_books(page, are_read, as_xml=False, order=None): else: name = _(u'Unread Books') + ' (' + str(pagination.total_count) + ')' pagename = "unread" + + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename) + title=name, page=pagename, thumbnails=thumbnails) def render_archived_books(page, order): @@ -676,8 +697,9 @@ def render_archived_books(page, order): name = _(u'Archived Books') + ' (' + str(len(archived_book_ids)) + ')' pagename = "archived" + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename) + title=name, page=pagename, thumbnails=thumbnails) def render_prepare_search_form(cc): @@ -710,6 +732,7 @@ def render_prepare_search_form(cc): def render_search_results(term, offset=None, order=None, limit=None): entries, result_count, pagination = calibre_db.get_search_results(term, offset, order, limit) + thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', searchterm=term, pagination=pagination, @@ -718,7 +741,8 @@ def render_search_results(term, offset=None, order=None, limit=None): entries=entries, result_count=result_count, title=_(u"Search"), - page="search") + page="search", + thumbnails=thumbnails) # ################################### View Books list ################################################################## @@ -748,6 +772,7 @@ def books_table(): return render_title_template('book_table.html', title=_(u"Books List"), page="book_table", visiblility=visibility) + @web.route("/ajax/listbooks") @login_required def list_books(): @@ -780,6 +805,7 @@ def list_books(): response.headers["Content-Type"] = "application/json; charset=utf-8" return response + @web.route("/ajax/table_settings", methods=['POST']) @login_required def update_table_settings(): @@ -834,6 +860,7 @@ def publisher_list(): charlist = calibre_db.session.query(func.upper(func.substr(db.Publishers.name, 1, 1)).label('char')) \ .join(db.books_publishers_link).join(db.Books).filter(calibre_db.common_filters()) \ .group_by(func.upper(func.substr(db.Publishers.name, 1, 1))).all() + return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=charlist, title=_(u"Publishers"), page="publisherlist", data="publisher") else: @@ -865,8 +892,10 @@ def series_list(): .join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters()) \ .group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all() + thumbnails = get_thumbnails_for_book_series(entries) return render_title_template('grid.html', entries=entries, folder='web.books_list', charlist=charlist, - title=_(u"Series"), page="serieslist", data="series", bodyClass="grid-view") + title=_(u"Series"), page="serieslist", data="series", bodyClass="grid-view", + thumbnails=thumbnails) else: abort(404) @@ -1150,13 +1179,16 @@ def render_adv_search_results(term, offset=None, order=None, limit=None): else: offset = 0 limit_all = result_count + + thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', adv_searchterm=searchterm, pagination=pagination, entries=q[offset:limit_all], result_count=result_count, - title=_(u"Advanced Search"), page="advsearch") - + title=_(u"Advanced Search"), + page="advsearch", + thumbnails=thumbnails) @web.route("/advsearch", methods=['GET']) @@ -1171,10 +1203,9 @@ def advanced_search_form(): @web.route("/cover/") -@web.route("/cover//") @login_required_if_no_ano -def get_cover(book_id, resolution=1): - return get_book_cover(book_id, resolution) +def get_cover(book_id): + return get_book_cover(book_id) @web.route("/cached-cover/") @@ -1183,6 +1214,12 @@ def get_cached_cover(cache_id): return get_cached_book_cover(cache_id) +@web.route("/cached-cover-thumbnail/") +@login_required_if_no_ano +def get_cached_cover_thumbnail(cache_id): + return get_cached_book_cover_thumbnail(cache_id) + + @web.route("/robots.txt") def get_robots(): return send_from_directory(constants.STATIC_DIR, "robots.txt") @@ -1591,6 +1628,7 @@ def show_book(book_id): if media_format.format.lower() in constants.EXTENSIONS_AUDIO: audioentries.append(media_format.format.lower()) + thumbnails = get_thumbnails_for_books([entries]) return render_title_template('detail.html', entry=entries, audioentries=audioentries, @@ -1602,7 +1640,8 @@ def show_book(book_id): is_archived=is_archived, kindle_list=kindle_list, reader_list=reader_list, - page="book") + page="book", + thumbnails=thumbnails) else: log.debug(u"Error opening eBook. File does not exist or file is not accessible") flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") From eef21759cd56c28560ee2007726fab18c8595df5 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Thu, 24 Dec 2020 03:00:26 -0600 Subject: [PATCH 007/220] Fix generate thumbnail task messages, don't load thumbnails when the cache file has been deleted --- cps/helper.py | 4 ++++ cps/tasks/thumbnail.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cps/helper.py b/cps/helper.py index c33a69e1..add1b067 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -536,9 +536,13 @@ def delete_book(book, calibrepath, book_format): def get_thumbnails_for_books(books): books_with_covers = list(filter(lambda b: b.has_cover, books)) book_ids = list(map(lambda b: b.id, books_with_covers)) + cache = fs.FileSystem() + thumbnail_files = cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) + return ub.session\ .query(ub.Thumbnail)\ .filter(ub.Thumbnail.book_id.in_(book_ids))\ + .filter(ub.Thumbnail.filename.in_(thumbnail_files))\ .filter(ub.Thumbnail.expiration > datetime.utcnow())\ .all() diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index e9df170e..70ddc06b 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -75,7 +75,7 @@ class TaskGenerateCoverThumbnails(CalibreTask): else: self.create_book_thumbnail(book, resolution) - self.message(u'Generating cover thumbnail {0} of {1}'.format(i, count)) + self.message = u'Generating cover thumbnail {0} of {1}'.format(i + 1, count) self.progress = (1.0 / count) * i self._handleSuccess() From 8cc06683df25684793d1d6154a74a3b11408d980 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Mon, 4 Jan 2021 12:28:05 -0600 Subject: [PATCH 008/220] only python3 supported now --- cps/tasks/database.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cps/tasks/database.py b/cps/tasks/database.py index 11f0186d..62208c0c 100644 --- a/cps/tasks/database.py +++ b/cps/tasks/database.py @@ -20,11 +20,7 @@ from __future__ import division, print_function, unicode_literals from cps import config, logger from cps.services.worker import CalibreTask - -try: - from urllib.request import urlopen -except ImportError as e: - from urllib2 import urlopen +from urllib.request import urlopen class TaskReconnectDatabase(CalibreTask): From 2c8d055ca4e9b92842615516834bb2472348b15e Mon Sep 17 00:00:00 2001 From: mmonkey Date: Mon, 4 Jan 2021 12:36:40 -0600 Subject: [PATCH 009/220] support python2.7 for the mean time --- cps/tasks/database.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cps/tasks/database.py b/cps/tasks/database.py index 62208c0c..11f0186d 100644 --- a/cps/tasks/database.py +++ b/cps/tasks/database.py @@ -20,7 +20,11 @@ from __future__ import division, print_function, unicode_literals from cps import config, logger from cps.services.worker import CalibreTask -from urllib.request import urlopen + +try: + from urllib.request import urlopen +except ImportError as e: + from urllib2 import urlopen class TaskReconnectDatabase(CalibreTask): From dd30ac4fbdc9699dc4462b1a9a7efeddb53ffe3a Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sat, 20 Mar 2021 11:32:50 +0100 Subject: [PATCH 010/220] Added thumbnails Merge remote-tracking branch 'cover/thumbnails' into development # Conflicts: # cps/admin.py # cps/templates/layout.html # cps/ub.py # cps/web.py Update join for sqlalchemy 1.4 --- .gitignore | 1 + cps.py | 5 + cps/__init__.py | 3 +- cps/admin.py | 19 +- cps/constants.py | 1 + cps/db.py | 13 +- cps/editbooks.py | 2 + cps/fs.py | 61 +++++ cps/helper.py | 89 ++++++- cps/jinjia.py | 22 ++ cps/schedule.py | 36 +++ cps/services/background_scheduler.py | 52 ++++ cps/services/worker.py | 17 +- cps/shelf.py | 2 +- cps/static/css/caliBlur.css | 82 ++++-- cps/static/js/main.js | 12 + cps/tasks/database.py | 49 ++++ cps/tasks/thumbnail.py | 366 +++++++++++++++++++++++++++ cps/templates/admin.html | 36 ++- cps/templates/author.html | 2 +- cps/templates/book_cover.html | 13 + cps/templates/book_edit.html | 3 +- cps/templates/detail.html | 2 +- cps/templates/discover.html | 3 +- cps/templates/fragment.html | 1 + cps/templates/grid.html | 3 +- cps/templates/index.html | 5 +- cps/templates/layout.html | 1 + cps/templates/search.html | 3 +- cps/templates/shelf.html | 3 +- cps/ub.py | 34 +++ cps/web.py | 96 +++++-- requirements.txt | 1 + 33 files changed, 965 insertions(+), 73 deletions(-) create mode 100644 cps/fs.py create mode 100644 cps/schedule.py create mode 100644 cps/services/background_scheduler.py create mode 100644 cps/tasks/database.py create mode 100644 cps/tasks/thumbnail.py create mode 100644 cps/templates/book_cover.html diff --git a/.gitignore b/.gitignore index 109de4ef..903cfd36 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ vendor/ # calibre-web *.db *.log +cps/cache .idea/ *.bak diff --git a/cps.py b/cps.py index 737b0d97..19ca89b8 100755 --- a/cps.py +++ b/cps.py @@ -43,6 +43,7 @@ from cps.gdrive import gdrive from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.error_handler import init_errorhandler +from cps.schedule import register_jobs try: from cps.kobo import kobo, get_kobo_activated @@ -78,6 +79,10 @@ def main(): app.register_blueprint(kobo_auth) if oauth_available: app.register_blueprint(oauth) + + # Register scheduled jobs + register_jobs() + success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/__init__.py b/cps/__init__.py index 627cca0b..30029428 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -96,7 +96,7 @@ def create_app(): app.instance_path = app.instance_path.decode('utf-8') if os.environ.get('FLASK_DEBUG'): - cache_buster.init_cache_busting(app) + cache_buster.init_cache_busting(app) log.info('Starting Calibre Web...') if sys.version_info < (3, 0): @@ -121,6 +121,7 @@ def create_app(): return app + @babel.localeselector def get_locale(): # if a user is logged in, use the locale from the user settings diff --git a/cps/admin.py b/cps/admin.py index 78cfebf1..cd548bfb 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -40,7 +40,7 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_ -from . import constants, logger, helper, services, isoLanguages +from . import constants, logger, helper, services, isoLanguages, fs from .cli import filepicker from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash @@ -164,6 +164,23 @@ def shutdown(): return json.dumps(showtext), 400 +@admi.route("/clear-cache") +@login_required +@admin_required +def clear_cache(): + cache_type = request.args.get('cache_type'.strip()) + showtext = {} + + if cache_type == fs.CACHE_TYPE_THUMBNAILS: + log.info('clearing cover thumbnail cache') + showtext['text'] = _(u'Cleared cover thumbnail cache') + helper.clear_cover_thumbnail_cache() + return json.dumps(showtext) + + showtext['text'] = _(u'Unknown command') + return json.dumps(showtext) + + @admi.route("/admin/view") @login_required @admin_required diff --git a/cps/constants.py b/cps/constants.py index e9c26cb1..0eb94709 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -37,6 +37,7 @@ else: STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') +CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') if HOME_CONFIG: home_dir = os.path.join(os.path.expanduser("~"),".calibre-web") diff --git a/cps/db.py b/cps/db.py index ac59ac2b..f43cc811 100644 --- a/cps/db.py +++ b/cps/db.py @@ -620,15 +620,18 @@ class CalibreDB(): randm = self.session.query(Books) \ .filter(self.common_filters(allow_show_archived)) \ .order_by(func.random()) \ - .limit(self.config.config_random_books) + .limit(self.config.config_random_books) \ + .all() else: randm = false() off = int(int(pagesize) * (page - 1)) - query = self.session.query(database) \ - .filter(db_filter) \ + query = self.session.query(database) + if len(join) == 3: + query = query.join(join[0], join[1]).join(join[2], isouter=True) + elif len(join) == 2: + query = query.join(join[0], join[1], isouter=True) + query = query.filter(db_filter)\ .filter(self.common_filters(allow_show_archived)) - if len(join): - query = query.join(*join, isouter=True) entries = list() pagination = list() try: diff --git a/cps/editbooks.py b/cps/editbooks.py index 28cad5c5..b7f496d0 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -614,6 +614,7 @@ def upload_cover(request, book): abort(403) ret, message = helper.save_cover(requested_file, book.path) if ret is True: + helper.clear_cover_thumbnail_cache(book.id) return True else: flash(message, category="error") @@ -710,6 +711,7 @@ def edit_book(book_id): if result is True: book.has_cover = 1 modif_date = True + helper.clear_cover_thumbnail_cache(book.id) else: flash(error, category="error") diff --git a/cps/fs.py b/cps/fs.py new file mode 100644 index 00000000..699d5991 --- /dev/null +++ b/cps/fs.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +from .constants import CACHE_DIR +from os import listdir, makedirs, remove +from os.path import isdir, isfile, join +from shutil import rmtree + +CACHE_TYPE_THUMBNAILS = 'thumbnails' + + +class FileSystem: + _instance = None + _cache_dir = CACHE_DIR + + def __new__(cls): + if cls._instance is None: + cls._instance = super(FileSystem, cls).__new__(cls) + return cls._instance + + def get_cache_dir(self, cache_type=None): + if not isdir(self._cache_dir): + makedirs(self._cache_dir) + + if cache_type and not isdir(join(self._cache_dir, cache_type)): + makedirs(join(self._cache_dir, cache_type)) + + return join(self._cache_dir, cache_type) if cache_type else self._cache_dir + + def get_cache_file_path(self, filename, cache_type=None): + return join(self.get_cache_dir(cache_type), filename) if filename else None + + def list_cache_files(self, cache_type=None): + path = self.get_cache_dir(cache_type) + return [file for file in listdir(path) if isfile(join(path, file))] + + def delete_cache_dir(self, cache_type=None): + if not cache_type and isdir(self._cache_dir): + rmtree(self._cache_dir) + if cache_type and isdir(join(self._cache_dir, cache_type)): + rmtree(join(self._cache_dir, cache_type)) + + def delete_cache_file(self, filename, cache_type=None): + if isfile(join(self.get_cache_dir(cache_type), filename)): + remove(join(self.get_cache_dir(cache_type), filename)) diff --git a/cps/helper.py b/cps/helper.py index e18ae33b..e3c79dea 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -52,12 +52,13 @@ except ImportError: from . import calibre_db from .tasks.convert import TaskConvert -from . import logger, config, get_locale, db, ub +from . import logger, config, get_locale, db, fs, ub from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR from .subproc_wrapper import process_wait from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS from .tasks.mail import TaskEmail +from .tasks.thumbnail import TaskClearCoverThumbnailCache log = logger.create() @@ -514,12 +515,32 @@ def update_dir_stucture(book_id, calibrepath, first_author=None, orignal_filepat def delete_book(book, calibrepath, book_format): + clear_cover_thumbnail_cache(book.id) if config.config_use_google_drive: return delete_book_gdrive(book, book_format) else: return delete_book_file(book, calibrepath, book_format) +def get_thumbnails_for_books(books): + books_with_covers = list(filter(lambda b: b.has_cover, books)) + book_ids = list(map(lambda b: b.id, books_with_covers)) + cache = fs.FileSystem() + thumbnail_files = cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) + + return ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.in_(book_ids))\ + .filter(ub.Thumbnail.filename.in_(thumbnail_files))\ + .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .all() + + +def get_thumbnails_for_book_series(series): + books = list(map(lambda s: s[0], series)) + return get_thumbnails_for_books(books) + + def get_cover_on_failure(use_generic_cover): if use_generic_cover: return send_from_directory(_STATIC_DIR, "generic_cover.jpg") @@ -532,14 +553,54 @@ def get_book_cover(book_id): return get_book_cover_internal(book, use_generic_cover_on_failure=True) -def get_book_cover_with_uuid(book_uuid, - use_generic_cover_on_failure=True): +def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): book = calibre_db.get_book_by_uuid(book_uuid) return get_book_cover_internal(book, use_generic_cover_on_failure) -def get_book_cover_internal(book, use_generic_cover_on_failure): +def get_cached_book_cover(cache_id): + parts = cache_id.split('_') + book_uuid = parts[0] if len(parts) else None + resolution = parts[2] if len(parts) > 2 else None + book = calibre_db.get_book_by_uuid(book_uuid) if book_uuid else None + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) + + +def get_cached_book_cover_thumbnail(cache_id): + parts = cache_id.split('_') + thumbnail_uuid = parts[0] if len(parts) else None + thumbnail = None + if thumbnail_uuid: + thumbnail = ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.uuid == thumbnail_uuid)\ + .first() + + if thumbnail and thumbnail.expiration > datetime.utcnow(): + cache = fs.FileSystem() + if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) + + elif thumbnail: + book = calibre_db.get_book(thumbnail.book_id) + return get_book_cover_internal(book, use_generic_cover_on_failure=True) + + else: + return get_cover_on_failure(True) + + +def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): if book and book.has_cover: + + # Send the book cover thumbnail if it exists in cache + if resolution: + thumbnail = get_book_cover_thumbnail(book, resolution) + if thumbnail: + cache = fs.FileSystem() + if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) + + # Send the book cover from Google Drive if configured if config.config_use_google_drive: try: if not gd.is_gdrive_ready(): @@ -550,9 +611,11 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): else: log.error('%s/cover.jpg not found on Google Drive', book.path) return get_cover_on_failure(use_generic_cover_on_failure) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) return get_cover_on_failure(use_generic_cover_on_failure) + + # Send the book cover from the Calibre directory else: cover_file_path = os.path.join(config.config_calibre_dir, book.path) if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): @@ -563,6 +626,16 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): return get_cover_on_failure(use_generic_cover_on_failure) +def get_book_cover_thumbnail(book, resolution): + if book and book.has_cover: + return ub.session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book.id)\ + .filter(ub.Thumbnail.resolution == resolution)\ + .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .first() + + # saves book cover from url def save_cover_from_url(url, book_path): try: @@ -820,3 +893,7 @@ def get_download_link(book_id, book_format, client): return do_download_file(book, book_format, client, data1, headers) else: abort(404) + + +def clear_cover_thumbnail_cache(book_id=None): + WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id)) diff --git a/cps/jinjia.py b/cps/jinjia.py index 688d1fba..b2479adc 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -128,8 +128,30 @@ def formatseriesindex_filter(series_index): return series_index return 0 + @jinjia.app_template_filter('uuidfilter') def uuidfilter(var): return uuid4() +@jinjia.app_template_filter('book_cover_cache_id') +def book_cover_cache_id(book, resolution=None): + timestamp = int(book.last_modified.timestamp() * 1000) + cache_bust = str(book.uuid) + '_' + str(timestamp) + return cache_bust if not resolution else cache_bust + '_' + str(resolution) + + +@jinjia.app_template_filter('get_book_thumbnails') +def get_book_thumbnails(book_id, thumbnails=None): + return list(filter(lambda t: t.book_id == book_id, thumbnails)) if book_id > -1 and thumbnails else list() + + +@jinjia.app_template_filter('get_book_thumbnail_srcset') +def get_book_thumbnail_srcset(thumbnails): + srcset = list() + for thumbnail in thumbnails: + timestamp = int(thumbnail.generated_at.timestamp() * 1000) + cache_id = str(thumbnail.uuid) + '_' + str(timestamp) + url = url_for('web.get_cached_cover_thumbnail', cache_id=cache_id) + srcset.append(url + ' ' + str(thumbnail.resolution) + 'x') + return ', '.join(srcset) diff --git a/cps/schedule.py b/cps/schedule.py new file mode 100644 index 00000000..7ee43410 --- /dev/null +++ b/cps/schedule.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals + +from .services.background_scheduler import BackgroundScheduler +from .tasks.database import TaskReconnectDatabase +from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails + + +def register_jobs(): + scheduler = BackgroundScheduler() + + # Generate 100 book cover thumbnails every 5 minutes + scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=100), trigger='cron', minute='*/5') + + # Cleanup book cover cache every 6 hours + scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', minute='15', hour='*/6') + + # Reconnect metadata.db every 4 hours + scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', minute='5', hour='*/4') diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py new file mode 100644 index 00000000..efa57379 --- /dev/null +++ b/cps/services/background_scheduler.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +import atexit + +from .. import logger +from .worker import WorkerThread +from apscheduler.schedulers.background import BackgroundScheduler as BScheduler + + +class BackgroundScheduler: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(BackgroundScheduler, cls).__new__(cls) + + scheduler = BScheduler() + atexit.register(lambda: scheduler.shutdown()) + + cls.log = logger.create() + cls.scheduler = scheduler + cls.scheduler.start() + + return cls._instance + + def add(self, func, trigger, **trigger_args): + self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) + + def add_task(self, user, task, trigger, **trigger_args): + def scheduled_task(): + worker_task = task() + self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) + WorkerThread.add(user, worker_task) + + self.add(func=scheduled_task, trigger=trigger, **trigger_args) diff --git a/cps/services/worker.py b/cps/services/worker.py index 072674a0..2b6816db 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -35,7 +35,6 @@ def _get_main_thread(): raise Exception("main thread not found?!") - class ImprovedQueue(queue.Queue): def to_list(self): """ @@ -45,7 +44,8 @@ class ImprovedQueue(queue.Queue): with self.mutex: return list(self.queue) -#Class for all worker tasks in the background + +# Class for all worker tasks in the background class WorkerThread(threading.Thread): _instance = None @@ -127,6 +127,10 @@ class WorkerThread(threading.Thread): # CalibreTask.start() should wrap all exceptions in it's own error handling item.task.start(self) + # remove self_cleanup tasks from list + if item.task.self_cleanup: + self.dequeued.remove(item) + self.queue.task_done() @@ -141,6 +145,7 @@ class CalibreTask: self.end_time = None self.message = message self.id = uuid.uuid4() + self.self_cleanup = False @abc.abstractmethod def run(self, worker_thread): @@ -209,6 +214,14 @@ class CalibreTask: # todo: throw error if outside of [0,1] self._progress = x + @property + def self_cleanup(self): + return self._self_cleanup + + @self_cleanup.setter + def self_cleanup(self, is_self_cleanup): + self._self_cleanup = is_self_cleanup + def _handleError(self, error_message): self.stat = STAT_FAIL self.progress = 1 diff --git a/cps/shelf.py b/cps/shelf.py index 5c6037ac..7b00c32b 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -403,7 +403,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param): db.Books, ub.BookShelf.shelf == shelf_id, [ub.BookShelf.order.asc()], - ub.BookShelf,ub.BookShelf.book_id == db.Books.id) + ub.BookShelf, ub.BookShelf.book_id == db.Books.id) # delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web wrong_entries = calibre_db.session.query(ub.BookShelf)\ .join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True)\ diff --git a/cps/static/css/caliBlur.css b/cps/static/css/caliBlur.css index 14e5c286..aa747c0b 100644 --- a/cps/static/css/caliBlur.css +++ b/cps/static/css/caliBlur.css @@ -5148,7 +5148,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head pointer-events: none } -#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { +#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #ClearCacheDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { cursor: pointer } @@ -5235,7 +5235,7 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d margin-bottom: 20px } -body.admin:not(.modal-open) .btn-default { +body.admin .btn-default { margin-bottom: 10px } @@ -5466,7 +5466,7 @@ body.admin.modal-open .navbar { z-index: 0 !important } -#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal { +#RestartDialog, #ShutdownDialog, #StatusDialog, #ClearCacheDialog, #deleteModal { top: 0; overflow: hidden; padding-top: 70px; @@ -5476,7 +5476,7 @@ body.admin.modal-open .navbar { background: rgba(0, 0, 0, .5) } -#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before { +#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #ClearCacheDialog:before, #deleteModal:before { content: "\E208"; padding-right: 10px; display: block; @@ -5498,18 +5498,18 @@ body.admin.modal-open .navbar { z-index: 99 } -#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { +#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0) } -#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { +#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { width: 450px; margin: auto } -#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { +#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { max-height: calc(100% - 90px); -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); box-shadow: 0 5px 15px rgba(0, 0, 0, .5); @@ -5520,7 +5520,7 @@ body.admin.modal-open .navbar { width: 450px } -#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { +#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { padding: 15px 20px; border-radius: 3px 3px 0 0; line-height: 1.71428571; @@ -5533,7 +5533,7 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { padding-right: 10px; font-size: 18px; color: #999; @@ -5557,6 +5557,11 @@ body.admin.modal-open .navbar { font-family: plex-icons-new, serif } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before { + content: "\EA15"; + font-family: plex-icons-new, serif +} + #deleteModal > .modal-dialog > .modal-content > .modal-header:before { content: "\EA6D"; font-family: plex-icons-new, serif @@ -5580,6 +5585,12 @@ body.admin.modal-open .navbar { font-size: 20px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:after { + content: "Clear Cover Thumbnail Cache"; + display: inline-block; + font-size: 20px +} + #deleteModal > .modal-dialog > .modal-content > .modal-header:after { content: "Delete Book"; display: inline-block; @@ -5610,7 +5621,17 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body { + padding: 20px 20px 10px; + font-size: 16px; + line-height: 1.6em; + font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif; + color: #eee; + background: #282828; + text-align: left +} + +#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { padding: 20px 20px 0 0; font-size: 16px; line-height: 1.6em; @@ -5619,7 +5640,7 @@ body.admin.modal-open .navbar { background: #282828 } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { float: right; z-index: 9; position: relative; @@ -5655,6 +5676,18 @@ body.admin.modal-open .navbar { border-radius: 3px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > #clear_cache { + float: right; + z-index: 9; + position: relative; + margin: 25px 0 0 10px; + min-width: 80px; + padding: 10px 18px; + font-size: 16px; + line-height: 1.33; + border-radius: 3px +} + #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; @@ -5675,11 +5708,15 @@ body.admin.modal-open .navbar { margin: 55px 0 0 10px } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache) { + margin: 25px 0 0 10px +} + #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { margin: 0 0 0 10px } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { background-color: hsla(0, 0%, 100%, .3) } @@ -5713,6 +5750,21 @@ body.admin.modal-open .navbar { box-shadow: 0 5px 15px rgba(0, 0, 0, .5) } +#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body:after { + content: ''; + position: absolute; + width: 100%; + height: 72px; + background-color: #323232; + border-radius: 0 0 3px 3px; + left: 0; + margin-top: 10px; + z-index: 0; + border-top: 1px solid #222; + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + box-shadow: 0 5px 15px rgba(0, 0, 0, .5) +} + #deleteButton { position: fixed; top: 60px; @@ -7299,11 +7351,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. background-color: transparent !important } - #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { + #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { max-width: calc(100vw - 40px) } - #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { + #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { max-width: calc(100vw - 40px); left: 0 } @@ -7453,7 +7505,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. padding: 30px 15px } - #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { + #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { left: auto; right: 34px } diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 834b9b30..51d6095d 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -430,6 +430,18 @@ $(function() { } }); }); + $("#clear_cache").click(function () { + $("#spinner3").show(); + $.ajax({ + dataType: "json", + url: window.location.pathname + "/../../clear-cache", + data: {"cache_type":"thumbnails"}, + success: function(data) { + $("#spinner3").hide(); + $("#ClearCacheDialog").modal("hide"); + } + }); + }); // Init all data control handlers to default $("input[data-control]").trigger("change"); diff --git a/cps/tasks/database.py b/cps/tasks/database.py new file mode 100644 index 00000000..11f0186d --- /dev/null +++ b/cps/tasks/database.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals + +from cps import config, logger +from cps.services.worker import CalibreTask + +try: + from urllib.request import urlopen +except ImportError as e: + from urllib2 import urlopen + + +class TaskReconnectDatabase(CalibreTask): + def __init__(self, task_message=u'Reconnecting Calibre database'): + super(TaskReconnectDatabase, self).__init__(task_message) + self.log = logger.create() + self.listen_address = config.get_config_ipaddress() + self.listen_port = config.config_port + + def run(self, worker_thread): + address = self.listen_address if self.listen_address else 'localhost' + port = self.listen_port if self.listen_port else 8083 + + try: + urlopen('http://' + address + ':' + str(port) + '/reconnect') + self._handleSuccess() + except Exception as ex: + self._handleError(u'Unable to reconnect Calibre database: ' + str(ex)) + + @property + def name(self): + return "Reconnect Database" diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py new file mode 100644 index 00000000..70ddc06b --- /dev/null +++ b/cps/tasks/thumbnail.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2020 mmonkey +# +# 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 __future__ import division, print_function, unicode_literals +import os + +from cps import config, db, fs, gdriveutils, logger, ub +from cps.services.worker import CalibreTask +from datetime import datetime, timedelta +from sqlalchemy import func + +try: + from urllib.request import urlopen +except ImportError as e: + from urllib2 import urlopen + +try: + from wand.image import Image + use_IM = True +except (ImportError, RuntimeError) as e: + use_IM = False + +THUMBNAIL_RESOLUTION_1X = 1 +THUMBNAIL_RESOLUTION_2X = 2 + + +class TaskGenerateCoverThumbnails(CalibreTask): + def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): + super(TaskGenerateCoverThumbnails, self).__init__(task_message) + self.limit = limit + self.log = logger.create() + self.app_db_session = ub.get_new_session_instance() + self.calibre_db = db.CalibreDB(expire_on_commit=False) + self.cache = fs.FileSystem() + self.resolutions = [ + THUMBNAIL_RESOLUTION_1X, + THUMBNAIL_RESOLUTION_2X + ] + + def run(self, worker_thread): + if self.calibre_db.session and use_IM: + expired_thumbnails = self.get_expired_thumbnails() + thumbnail_book_ids = self.get_thumbnail_book_ids() + books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) + + count = len(books_without_thumbnails) + if count == 0: + # Do not display this task on the frontend if there are no covers to update + self.self_cleanup = True + + for i, book in enumerate(books_without_thumbnails): + for resolution in self.resolutions: + expired_thumbnail = self.get_expired_thumbnail_for_book_and_resolution( + book, + resolution, + expired_thumbnails + ) + if expired_thumbnail: + self.update_book_thumbnail(book, expired_thumbnail) + else: + self.create_book_thumbnail(book, resolution) + + self.message = u'Generating cover thumbnail {0} of {1}'.format(i + 1, count) + self.progress = (1.0 / count) * i + + self._handleSuccess() + self.app_db_session.remove() + + def get_expired_thumbnails(self): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.expiration < datetime.utcnow())\ + .all() + + def get_thumbnail_book_ids(self): + return self.app_db_session\ + .query(ub.Thumbnail.book_id)\ + .group_by(ub.Thumbnail.book_id)\ + .having(func.min(ub.Thumbnail.expiration) > datetime.utcnow())\ + .distinct() + + def get_books_without_thumbnails(self, thumbnail_book_ids): + return self.calibre_db.session\ + .query(db.Books)\ + .filter(db.Books.has_cover == 1)\ + .filter(db.Books.id.notin_(thumbnail_book_ids))\ + .limit(self.limit)\ + .all() + + def get_expired_thumbnail_for_book_and_resolution(self, book, resolution, expired_thumbnails): + for thumbnail in expired_thumbnails: + if thumbnail.book_id == book.id and thumbnail.resolution == resolution: + return thumbnail + + return None + + def update_book_thumbnail(self, book, thumbnail): + thumbnail.generated_at = datetime.utcnow() + thumbnail.expiration = datetime.utcnow() + timedelta(days=30) + + try: + self.app_db_session.commit() + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self.log.info(u'Error updating book thumbnail: ' + str(ex)) + self._handleError(u'Error updating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def create_book_thumbnail(self, book, resolution): + thumbnail = ub.Thumbnail() + thumbnail.book_id = book.id + thumbnail.format = 'jpeg' + thumbnail.resolution = resolution + + self.app_db_session.add(thumbnail) + try: + self.app_db_session.commit() + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self.log.info(u'Error creating book thumbnail: ' + str(ex)) + self._handleError(u'Error creating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def generate_book_thumbnail(self, book, thumbnail): + if book and thumbnail: + if config.config_use_google_drive: + if not gdriveutils.is_gdrive_ready(): + raise Exception('Google Drive is configured but not ready') + + web_content_link = gdriveutils.get_cover_via_gdrive(book.path) + if not web_content_link: + raise Exception('Google Drive cover url not found') + + stream = None + try: + stream = urlopen(web_content_link) + with Image(file=stream) as img: + height = self.get_thumbnail_height(thumbnail) + if img.height > height: + width = self.get_thumbnail_width(height, img) + img.resize(width=width, height=height, filter='lanczos') + img.format = thumbnail.format + filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + img.save(filename=filename) + except Exception as ex: + # Bubble exception to calling function + self.log.info(u'Error generating thumbnail file: ' + str(ex)) + raise ex + finally: + stream.close() + else: + book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') + if not os.path.isfile(book_cover_filepath): + raise Exception('Book cover file not found') + + with Image(filename=book_cover_filepath) as img: + height = self.get_thumbnail_height(thumbnail) + if img.height > height: + width = self.get_thumbnail_width(height, img) + img.resize(width=width, height=height, filter='lanczos') + img.format = thumbnail.format + filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + img.save(filename=filename) + + def get_thumbnail_height(self, thumbnail): + return int(225 * thumbnail.resolution) + + def get_thumbnail_width(self, height, img): + percent = (height / float(img.height)) + return int((float(img.width) * float(percent))) + + @property + def name(self): + return "ThumbnailsGenerate" + + +class TaskSyncCoverThumbnailCache(CalibreTask): + def __init__(self, task_message=u'Syncing cover thumbnail cache'): + super(TaskSyncCoverThumbnailCache, 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.cache = fs.FileSystem() + + def run(self, worker_thread): + cached_thumbnail_files = self.cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) + + # Expire thumbnails in the database if the cached file is missing + # This case will happen if a user deletes the cache dir or cached files + if self.app_db_session: + self.expire_missing_thumbnails(cached_thumbnail_files) + self.progress = 0.25 + + # Delete thumbnails in the database if the book has been removed + # This case will happen if a book is removed in Calibre and the metadata.db file is updated in the filesystem + if self.app_db_session and self.calibre_db: + book_ids = self.get_book_ids() + self.delete_thumbnails_for_missing_books(book_ids) + self.progress = 0.50 + + # Expire thumbnails in the database if their corresponding book has been updated since they were generated + # This case will happen if the book was updated externally + if self.app_db_session and self.cache: + books = self.get_books_updated_in_the_last_day() + book_ids = list(map(lambda b: b.id, books)) + thumbnails = self.get_thumbnails_for_updated_books(book_ids) + self.expire_thumbnails_for_updated_book(books, thumbnails) + self.progress = 0.75 + + # Delete extraneous cached thumbnail files + # This case will happen if a book was deleted and the thumbnail OR the metadata.db file was changed externally + if self.app_db_session: + db_thumbnail_files = self.get_thumbnail_filenames() + self.delete_extraneous_thumbnail_files(cached_thumbnail_files, db_thumbnail_files) + + self._handleSuccess() + self.app_db_session.remove() + + def expire_missing_thumbnails(self, filenames): + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.filename.notin_(filenames))\ + .update({"expiration": datetime.utcnow()}, synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(u'Error expiring thumbnails for missing cache files: ' + str(ex)) + self._handleError(u'Error expiring thumbnails for missing cache files: ' + str(ex)) + self.app_db_session.rollback() + + def get_book_ids(self): + results = self.calibre_db.session\ + .query(db.Books.id)\ + .filter(db.Books.has_cover == 1)\ + .distinct() + + return [value for value, in results] + + def delete_thumbnails_for_missing_books(self, book_ids): + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.notin_(book_ids))\ + .delete(synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(str(ex)) + self._handleError(u'Error deleting thumbnails for missing books: ' + str(ex)) + self.app_db_session.rollback() + + def get_thumbnail_filenames(self): + results = self.app_db_session\ + .query(ub.Thumbnail.filename)\ + .all() + + return [thumbnail for thumbnail, in results] + + def delete_extraneous_thumbnail_files(self, cached_thumbnail_files, db_thumbnail_files): + extraneous_files = list(set(cached_thumbnail_files).difference(db_thumbnail_files)) + for file in extraneous_files: + self.cache.delete_cache_file(file, fs.CACHE_TYPE_THUMBNAILS) + + def get_books_updated_in_the_last_day(self): + return self.calibre_db.session\ + .query(db.Books)\ + .filter(db.Books.has_cover == 1)\ + .filter(db.Books.last_modified > datetime.utcnow() - timedelta(days=1, hours=1))\ + .all() + + def get_thumbnails_for_updated_books(self, book_ids): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id.in_(book_ids))\ + .all() + + def expire_thumbnails_for_updated_book(self, books, thumbnails): + thumbnail_ids = list() + for book in books: + for thumbnail in thumbnails: + if thumbnail.book_id == book.id and thumbnail.generated_at < book.last_modified: + thumbnail_ids.append(thumbnail.id) + + try: + self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.id.in_(thumbnail_ids)) \ + .update({"expiration": datetime.utcnow()}, synchronize_session=False) + self.app_db_session.commit() + except Exception as ex: + self.log.info(u'Error expiring thumbnails for updated books: ' + str(ex)) + self._handleError(u'Error expiring thumbnails for updated books: ' + str(ex)) + self.app_db_session.rollback() + + @property + def name(self): + return "ThumbnailsSync" + + +class TaskClearCoverThumbnailCache(CalibreTask): + def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): + super(TaskClearCoverThumbnailCache, self).__init__(task_message) + self.log = logger.create() + self.book_id = book_id + self.app_db_session = ub.get_new_session_instance() + self.cache = fs.FileSystem() + + def run(self, worker_thread): + if self.app_db_session: + if self.book_id: + thumbnails = self.get_thumbnails_for_book(self.book_id) + for thumbnail in thumbnails: + self.expire_and_delete_thumbnail(thumbnail) + else: + self.expire_and_delete_all_thumbnails() + + self._handleSuccess() + self.app_db_session.remove() + + def get_thumbnails_for_book(self, book_id): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book_id)\ + .all() + + def expire_and_delete_thumbnail(self, thumbnail): + thumbnail.expiration = datetime.utcnow() + + try: + self.app_db_session.commit() + self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + except Exception as ex: + self.log.info(u'Error expiring book thumbnail: ' + str(ex)) + self._handleError(u'Error expiring book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def expire_and_delete_all_thumbnails(self): + self.app_db_session\ + .query(ub.Thumbnail)\ + .update({'expiration': datetime.utcnow()}) + + try: + self.app_db_session.commit() + self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) + except Exception as ex: + self.log.info(u'Error expiring book thumbnails: ' + str(ex)) + self._handleError(u'Error expiring book thumbnails: ' + str(ex)) + self.app_db_session.rollback() + + @property + def name(self): + return "ThumbnailsClear" diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 576652d4..20d0802c 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -142,15 +142,18 @@
-
+

{{_('Administration')}}

- - -
-
-
{{_('Reconnect Calibre Database')}}
-
{{_('Restart')}}
-
{{_('Shutdown')}}
+ + +
+
+
{{_('Reconnect Calibre Database')}}
+
{{_('Clear Cover Thumbnail Cache')}}
+
+
+
{{_('Restart')}}
+
{{_('Shutdown')}}
@@ -231,4 +234,21 @@
+ {% endblock %} diff --git a/cps/templates/author.html b/cps/templates/author.html index 4e32db80..990f60ad 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -37,7 +37,7 @@
- + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html new file mode 100644 index 00000000..c5281797 --- /dev/null +++ b/cps/templates/book_cover.html @@ -0,0 +1,13 @@ +{% macro book_cover_image(book, thumbnails) -%} + {%- set book_title = book.title if book.title else book.name -%} + {% set srcset = thumbnails|get_book_thumbnail_srcset if thumbnails|length else '' %} + {%- if srcset|length -%} + {{ book_title }} + {%- else -%} + {{ book_title }} + {%- endif -%} +{%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index c0fc141e..091beffc 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -1,9 +1,10 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %} {% if book %}
- {{ book.title }} + {{ book_cover_image(book, book.id|get_book_thumbnails(thumbnails)) }}
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 342cce53..3e2b65ac 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,7 +4,7 @@
- {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }}
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index d57994b4..02eff658 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -9,7 +10,7 @@ {% if entry.has_cover is defined %} - {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/fragment.html b/cps/templates/fragment.html index 1421ea6a..901dd193 100644 --- a/cps/templates/fragment.html +++ b/cps/templates/fragment.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %}
{% block body %}{% endblock %}
diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 9724e31d..2eecd6c6 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}

{{_(title)}}

@@ -29,7 +30,7 @@
- {{ entry[0].name }} + {{ book_cover_image(entry[0], entry[0].id|get_book_thumbnails(thumbnails)) }} {{entry.count}} diff --git a/cps/templates/index.html b/cps/templates/index.html index d300fc65..579f19f7 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %} {% if g.user.show_detail_random() %} @@ -9,7 +10,7 @@
- {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} @@ -86,7 +87,7 @@
- {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 318140fa..fef082bb 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -1,4 +1,5 @@ {% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %} +{% from 'book_cover.html' import book_cover_image %} diff --git a/cps/templates/search.html b/cps/templates/search.html index d11f3ec8..428655ea 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -44,7 +45,7 @@ {% if entry.has_cover is defined %} - {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 1ad79dbd..e49a008f 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -1,3 +1,4 @@ +{% from 'book_cover.html' import book_cover_image %} {% extends "layout.html" %} {% block body %}
@@ -31,7 +32,7 @@
- {{ entry.title }} + {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/ub.py b/cps/ub.py index ecf98679..af53e4f8 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -18,6 +18,7 @@ # along with this program. If not, see . from __future__ import division, print_function, unicode_literals +import atexit import os import sys import datetime @@ -441,6 +442,27 @@ class RemoteAuthToken(Base): return '' % self.id +def filename(context): + file_format = context.get_current_parameters()['format'] + if file_format == 'jpeg': + return context.get_current_parameters()['uuid'] + '.jpg' + else: + return context.get_current_parameters()['uuid'] + '.' + file_format + + +class Thumbnail(Base): + __tablename__ = 'thumbnail' + + id = Column(Integer, primary_key=True) + book_id = Column(Integer) + uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) + format = Column(String, default='jpeg') + resolution = Column(SmallInteger, default=1) + filename = Column(String, default=filename) + generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) + expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) + + # Add missing tables during migration of database def add_missing_tables(engine, session): if not engine.dialect.has_table(engine.connect(), "book_read_link"): @@ -455,6 +477,8 @@ def add_missing_tables(engine, session): KoboStatistics.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "archived_book"): ArchivedBook.__table__.create(bind=engine) + if not engine.dialect.has_table(engine.connect(), "thumbnail"): + Thumbnail.__table__.create(bind=engine) if not engine.dialect.has_table(engine.connect(), "registration"): Registration.__table__.create(bind=engine) with engine.connect() as conn: @@ -725,6 +749,16 @@ def init_db(app_db_path): sys.exit(3) +def get_new_session_instance(): + new_engine = create_engine(u'sqlite:///{0}'.format(cli.settingspath), echo=False) + new_session = scoped_session(sessionmaker()) + new_session.configure(bind=new_engine) + + atexit.register(lambda: new_session.remove() if new_session else True) + + return new_session + + def dispose(): global session diff --git a/cps/web.py b/cps/web.py index ad8cd5da..5bf226ab 100644 --- a/cps/web.py +++ b/cps/web.py @@ -50,9 +50,10 @@ from . import constants, logger, isoLanguages, services from . import babel, db, ub, config, get_locale, app from . import calibre_db from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download -from .helper import check_valid_domain, render_task_status, \ - get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ - send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password +from .helper import check_valid_domain, render_task_status, get_cc_columns, get_book_cover, get_cached_book_cover, \ + get_cached_book_cover_thumbnail, get_thumbnails_for_books, get_thumbnails_for_book_series, get_download_link, \ + send_mail, generate_random_password, send_registration_mail, check_send_to_kindle, check_read_formats, \ + tags_filters, reset_password from .pagination import Pagination from .redirect import redirect_back from .usermanagement import login_required_if_no_ano @@ -411,8 +412,9 @@ def render_books_list(data, sort, book_id, page): else: website = data or "newest" entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, order) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Books"), page=website) + title=_(u"Books"), page=website, thumbnails=thumbnails) def render_rated_books(page, book_id, order): @@ -457,8 +459,9 @@ def render_hot_books(page): ub.delete_download(book.Downloads.book_id) numBooks = entries.__len__() pagination = Pagination(page, config.config_books_per_page, numBooks) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Hot Books (Most Downloaded)"), page="hot") + title=_(u"Hot Books (Most Downloaded)"), page="hot", thumbnails=thumbnails) else: abort(404) @@ -482,12 +485,14 @@ def render_downloaded_books(page, order): .filter(db.Books.id == book.id).first(): ub.delete_download(book.id) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Downloaded books by %(user)s",user=current_user.nickname), - page="download") + page="download", + thumbnails=thumbnails) else: abort(404) @@ -498,6 +503,7 @@ def render_author_books(page, author_id, order): db.Books.authors.any(db.Authors.id == author_id), [order[0], db.Series.name, db.Books.series_index], db.books_series_link, + db.Books.id==db.books_series_link.c.book, db.Series) if entries is None or not len(entries): flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), @@ -513,9 +519,10 @@ def render_author_books(page, author_id, order): author_info = services.goodreads_support.get_author_info(author_name) other_books = services.goodreads_support.get_other_books(author_info, entries) + thumbnails = get_thumbnails_for_books(entries) return render_title_template('author.html', entries=entries, pagination=pagination, id=author_id, title=_(u"Author: %(name)s", name=author_name), author=author_info, - other_books=other_books, page="author") + other_books=other_books, page="author", thumbnails=thumbnails) def render_publisher_books(page, book_id, order): @@ -526,9 +533,12 @@ def render_publisher_books(page, book_id, order): db.Books.publishers.any(db.Publishers.id == book_id), [db.Series.name, order[0], db.Books.series_index], db.books_series_link, + db.Books.id == db.books_series_link.c.book, db.Series) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher") + title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher", + thumbnails=thumbnails) else: abort(404) @@ -540,8 +550,10 @@ def render_series_books(page, book_id, order): db.Books, db.Books.series.any(db.Series.id == book_id), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Series: %(serie)s", serie=name.name), page="series") + title=_(u"Series: %(serie)s", serie=name.name), page="series", + thumbnails=thumbnails) else: abort(404) @@ -553,8 +565,10 @@ def render_ratings_books(page, book_id, order): db.Books.ratings.any(db.Ratings.id == book_id), [order[0]]) if name and name.rating <= 10: + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings") + title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings", + thumbnails=thumbnails) else: abort(404) @@ -566,8 +580,10 @@ def render_formats_books(page, book_id, order): db.Books, db.Books.data.any(db.Data.format == book_id.upper()), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"File format: %(format)s", format=name.format), page="formats") + title=_(u"File format: %(format)s", format=name.format), page="formats", + thumbnails=thumbnails) else: abort(404) @@ -579,9 +595,13 @@ def render_category_books(page, book_id, order): db.Books, db.Books.tags.any(db.Tags.id == book_id), [order[0], db.Series.name, db.Books.series_index], - db.books_series_link, db.Series) + db.books_series_link, + db.Books.id == db.books_series_link.c.book, + db.Series) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Category: %(name)s", name=name.name), page="category") + title=_(u"Category: %(name)s", name=name.name), page="category", + thumbnails=thumbnails) else: abort(404) @@ -599,8 +619,9 @@ def render_language_books(page, name, order): db.Books, db.Books.languages.any(db.Languages.lang_code == name), [order[0]]) + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=name, - title=_(u"Language: %(name)s", name=lang_name), page="language") + title=_(u"Language: %(name)s", name=lang_name), page="language", thumbnails=thumbnails) def render_read_books(page, are_read, as_xml=False, order=None): @@ -644,8 +665,10 @@ def render_read_books(page, are_read, as_xml=False, order=None): else: name = _(u'Unread Books') + ' (' + str(pagination.total_count) + ')' pagename = "unread" + + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename) + title=name, page=pagename, thumbnails=thumbnails) def render_archived_books(page, order): @@ -668,8 +691,9 @@ def render_archived_books(page, order): name = _(u'Archived Books') + ' (' + str(len(archived_book_ids)) + ')' pagename = "archived" + thumbnails = get_thumbnails_for_books(entries + random) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename) + title=name, page=pagename, thumbnails=thumbnails) def render_prepare_search_form(cc): @@ -702,6 +726,7 @@ def render_prepare_search_form(cc): def render_search_results(term, offset=None, order=None, limit=None): entries, result_count, pagination = calibre_db.get_search_results(term, offset, order, limit) + thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', searchterm=term, pagination=pagination, @@ -710,7 +735,8 @@ def render_search_results(term, offset=None, order=None, limit=None): entries=entries, result_count=result_count, title=_(u"Search"), - page="search") + page="search", + thumbnails=thumbnails) # ################################### View Books list ################################################################## @@ -740,6 +766,7 @@ def books_table(): return render_title_template('book_table.html', title=_(u"Books List"), page="book_table", visiblility=visibility) + @web.route("/ajax/listbooks") @login_required def list_books(): @@ -772,6 +799,7 @@ def list_books(): response.headers["Content-Type"] = "application/json; charset=utf-8" return response + @web.route("/ajax/table_settings", methods=['POST']) @login_required def update_table_settings(): @@ -826,6 +854,7 @@ def publisher_list(): charlist = calibre_db.session.query(func.upper(func.substr(db.Publishers.name, 1, 1)).label('char')) \ .join(db.books_publishers_link).join(db.Books).filter(calibre_db.common_filters()) \ .group_by(func.upper(func.substr(db.Publishers.name, 1, 1))).all() + return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=charlist, title=_(u"Publishers"), page="publisherlist", data="publisher") else: @@ -857,8 +886,10 @@ def series_list(): .join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters()) \ .group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all() + thumbnails = get_thumbnails_for_book_series(entries) return render_title_template('grid.html', entries=entries, folder='web.books_list', charlist=charlist, - title=_(u"Series"), page="serieslist", data="series", bodyClass="grid-view") + title=_(u"Series"), page="serieslist", data="series", bodyClass="grid-view", + thumbnails=thumbnails) else: abort(404) @@ -1235,13 +1266,16 @@ def render_adv_search_results(term, offset=None, order=None, limit=None): else: offset = 0 limit_all = result_count + + thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', adv_searchterm=searchterm, pagination=pagination, entries=q[offset:limit_all], result_count=result_count, - title=_(u"Advanced Search"), page="advsearch") - + title=_(u"Advanced Search"), + page="advsearch", + thumbnails=thumbnails) @web.route("/advsearch", methods=['GET']) @@ -1260,10 +1294,24 @@ def advanced_search_form(): def get_cover(book_id): return get_book_cover(book_id) + +@web.route("/cached-cover/") +@login_required_if_no_ano +def get_cached_cover(cache_id): + return get_cached_book_cover(cache_id) + + +@web.route("/cached-cover-thumbnail/") +@login_required_if_no_ano +def get_cached_cover_thumbnail(cache_id): + return get_cached_book_cover_thumbnail(cache_id) + + @web.route("/robots.txt") def get_robots(): return send_from_directory(constants.STATIC_DIR, "robots.txt") + @web.route("/show//", defaults={'anyname': 'None'}) @web.route("/show///") @login_required_if_no_ano @@ -1293,7 +1341,6 @@ def serve_book(book_id, book_format, anyname): return send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + book_format) - @web.route("/download//", defaults={'anyname': 'None'}) @web.route("/download///") @login_required_if_no_ano @@ -1481,9 +1528,6 @@ def logout(): return redirect(url_for('web.login')) - - - # ################################### Users own configuration ######################################################### def change_profile_email(to_save, kobo_support, local_oauth_check, oauth_status): if "email" in to_save and to_save["email"] != current_user.email: @@ -1683,6 +1727,7 @@ def show_book(book_id): if media_format.format.lower() in constants.EXTENSIONS_AUDIO: audioentries.append(media_format.format.lower()) + thumbnails = get_thumbnails_for_books([entries]) return render_title_template('detail.html', entry=entries, audioentries=audioentries, @@ -1694,7 +1739,8 @@ def show_book(book_id): is_archived=is_archived, kindle_list=kindle_list, reader_list=reader_list, - page="book") + page="book", + thumbnails=thumbnails) else: log.debug(u"Error opening eBook. File does not exist or file is not accessible") flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") diff --git a/requirements.txt b/requirements.txt index ca11eff9..5a26381e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +APScheduler>=3.6.3, <3.8.0 Babel>=1.3, <2.9 Flask-Babel>=0.11.1,<2.1.0 Flask-Login>=0.3.2,<0.5.1 From 1e7a2c400ba6c6a6b661c1a086d85a95cd813806 Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sat, 20 Mar 2021 14:06:15 +0100 Subject: [PATCH 011/220] Fixed misstyping --- cps/config_sql.py | 2 +- cps/db.py | 2 +- cps/gdriveutils.py | 2 +- cps/ub.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cps/config_sql.py b/cps/config_sql.py index 17f1c613..78e6c9c9 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -24,7 +24,7 @@ import sys from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy.exc import OperationalError try: - # Compability with sqlalchemy 2.0 + # Compatibility with sqlalchemy 2.0 from sqlalchemy.orm import declarative_base except ImportError: from sqlalchemy.ext.declarative import declarative_base diff --git a/cps/db.py b/cps/db.py index f43cc811..883eec05 100644 --- a/cps/db.py +++ b/cps/db.py @@ -32,7 +32,7 @@ from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.ext.declarative import DeclarativeMeta try: - # Compability with sqlalchemy 2.0 + # Compatibility with sqlalchemy 2.0 from sqlalchemy.orm import declarative_base except ImportError: from sqlalchemy.ext.declarative import declarative_base diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index 5f970554..c7a95ca5 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -29,7 +29,7 @@ from sqlalchemy import Column, UniqueConstraint from sqlalchemy import String, Integer from sqlalchemy.orm import sessionmaker, scoped_session try: - # Compability with sqlalchemy 2.0 + # Compatibility with sqlalchemy 2.0 from sqlalchemy.orm import declarative_base except ImportError: from sqlalchemy.ext.declarative import declarative_base diff --git a/cps/ub.py b/cps/ub.py index af53e4f8..4a76ea49 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -45,7 +45,7 @@ from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.sql.expression import func try: - # Compability with sqlalchemy 2.0 + # Compatibility with sqlalchemy 2.0 from sqlalchemy.orm import declarative_base except ImportError: from sqlalchemy.ext.declarative import declarative_base From 9a08bcd2bc0a5280cbb293d1306b7c4e2ad6d792 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Thu, 16 Sep 2021 23:20:11 -0500 Subject: [PATCH 012/220] Started addressing some PR comments --- cps/admin.py | 1 - cps/templates/book_cover.html | 3 ++- cps/updater.py | 2 +- cps/web.py | 26 ++++++++++++++------------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/cps/admin.py b/cps/admin.py index 32d66a6f..98535320 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -41,7 +41,6 @@ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text from . import constants, logger, helper, services, isoLanguages, fs -from .cli import filepicker from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html index c5281797..67e7c24b 100644 --- a/cps/templates/book_cover.html +++ b/cps/templates/book_cover.html @@ -1,5 +1,6 @@ -{% macro book_cover_image(book, thumbnails) -%} +{% macro book_cover_image(book, thumbnails, title) -%} {%- set book_title = book.title if book.title else book.name -%} + {%- set book_title = title if title else book_title -%} {% set srcset = thumbnails|get_book_thumbnail_srcset if thumbnails|length else '' %} {%- if srcset|length -%} Date: Fri, 17 Sep 2021 01:42:56 -0500 Subject: [PATCH 013/220] Review feedback fixes --- cps/helper.py | 5 +++-- cps/jinjia.py | 2 ++ cps/schedule.py | 7 +++++-- cps/services/background_scheduler.py | 4 ++-- cps/tasks/thumbnail.py | 1 + cps/templates/book_cover.html | 2 +- cps/ub.py | 2 +- cps/web.py | 22 +++++++++++----------- 8 files changed, 26 insertions(+), 19 deletions(-) diff --git a/cps/helper.py b/cps/helper.py index 758d2531..6f22a701 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -554,13 +554,14 @@ def get_thumbnails_for_books(books): cache = fs.FileSystem() thumbnail_files = cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) - return ub.session\ + thumbnails = ub.session\ .query(ub.Thumbnail)\ .filter(ub.Thumbnail.book_id.in_(book_ids))\ - .filter(ub.Thumbnail.filename.in_(thumbnail_files))\ .filter(ub.Thumbnail.expiration > datetime.utcnow())\ .all() + return list(filter(lambda t: t.filename in thumbnail_files, thumbnails)) + def get_thumbnails_for_book_series(series): books = list(map(lambda s: s[0], series)) diff --git a/cps/jinjia.py b/cps/jinjia.py index ac6d3d33..5f86478c 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -129,10 +129,12 @@ def formatseriesindex_filter(series_index): return series_index return 0 + @jinjia.app_template_filter('escapedlink') def escapedlink_filter(url, text): return "{}".format(url, escape(text)) + @jinjia.app_template_filter('uuidfilter') def uuidfilter(var): return uuid4() diff --git a/cps/schedule.py b/cps/schedule.py index 7ee43410..8ab9c732 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -18,6 +18,7 @@ from __future__ import division, print_function, unicode_literals +from datetime import datetime from .services.background_scheduler import BackgroundScheduler from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails @@ -26,8 +27,10 @@ from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumb def register_jobs(): scheduler = BackgroundScheduler() - # Generate 100 book cover thumbnails every 5 minutes - scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=100), trigger='cron', minute='*/5') + # Generate up to 1000 book covers daily + generate_thumbnails_task = scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=1000), + trigger='interval', days=1) + generate_thumbnails_task.modify(next_run_time=datetime.now()) # Cleanup book cover cache every 6 hours scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', minute='15', hour='*/6') diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index efa57379..122168e8 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -41,7 +41,7 @@ class BackgroundScheduler: return cls._instance def add(self, func, trigger, **trigger_args): - self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) + return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) def add_task(self, user, task, trigger, **trigger_args): def scheduled_task(): @@ -49,4 +49,4 @@ class BackgroundScheduler: self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) WorkerThread.add(user, worker_task) - self.add(func=scheduled_task, trigger=trigger, **trigger_args) + return self.add(func=scheduled_task, trigger=trigger, **trigger_args) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 70ddc06b..fed12e8b 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -37,6 +37,7 @@ except (ImportError, RuntimeError) as e: THUMBNAIL_RESOLUTION_1X = 1 THUMBNAIL_RESOLUTION_2X = 2 +THUMBNAIL_RESOLUTION_3X = 3 class TaskGenerateCoverThumbnails(CalibreTask): diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html index 67e7c24b..884ff4cd 100644 --- a/cps/templates/book_cover.html +++ b/cps/templates/book_cover.html @@ -1,4 +1,4 @@ -{% macro book_cover_image(book, thumbnails, title) -%} +{% macro book_cover_image(book, thumbnails, title=None) -%} {%- set book_title = book.title if book.title else book.name -%} {%- set book_title = title if title else book_title -%} {% set srcset = thumbnails|get_book_thumbnail_srcset if thumbnails|length else '' %} diff --git a/cps/ub.py b/cps/ub.py index 9d3a5845..e0bee4ef 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -532,7 +532,7 @@ class Thumbnail(Base): resolution = Column(SmallInteger, default=1) filename = Column(String, default=filename) generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) - expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) + expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=90)) # Add missing tables during migration of database diff --git a/cps/web.py b/cps/web.py index 64bdd8e4..f43bc6ad 100644 --- a/cps/web.py +++ b/cps/web.py @@ -416,7 +416,7 @@ def render_books_list(data, sort, book_id, page): db.Books.id == db.books_series_link.c.book, db.Series) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Books"), page=website, thumbnails=thumbnails) @@ -467,7 +467,7 @@ def render_hot_books(page): ub.delete_download(book.Downloads.book_id) numBooks = entries.__len__() pagination = Pagination(page, config.config_books_per_page, numBooks) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=_(u"Hot Books (Most Downloaded)"), page="hot", thumbnails=thumbnails) else: @@ -498,7 +498,7 @@ def render_downloaded_books(page, order, user_id): ub.delete_download(book.id) user = ub.session.query(ub.User).filter(ub.User.id == user_id).first() - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, @@ -551,7 +551,7 @@ def render_publisher_books(page, book_id, order): db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher", thumbnails=thumbnails) @@ -566,7 +566,7 @@ def render_series_books(page, book_id, order): db.Books, db.Books.series.any(db.Series.id == book_id), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, title=_(u"Series: %(serie)s", serie=name.name), page="series", thumbnails=thumbnails) @@ -581,7 +581,7 @@ def render_ratings_books(page, book_id, order): db.Books.ratings.any(db.Ratings.id == book_id), [order[0]]) if name and name.rating <= 10: - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings", thumbnails=thumbnails) @@ -596,7 +596,7 @@ def render_formats_books(page, book_id, order): db.Books, db.Books.data.any(db.Data.format == book_id.upper()), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, title=_(u"File format: %(format)s", format=name.format), page="formats", thumbnails=thumbnails) @@ -614,7 +614,7 @@ def render_category_books(page, book_id, order): db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, title=_(u"Category: %(name)s", name=name.name), page="category", thumbnails=thumbnails) @@ -635,7 +635,7 @@ def render_language_books(page, name, order): db.Books, db.Books.languages.any(db.Languages.lang_code == name), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=name, title=_(u"Language: %(name)s", name=lang_name), page="language", thumbnails=thumbnails) @@ -688,7 +688,7 @@ def render_read_books(page, are_read, as_xml=False, order=None): name = _(u'Unread Books') + ' (' + str(pagination.total_count) + ')' pagename = "unread" - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=name, page=pagename, thumbnails=thumbnails) @@ -713,7 +713,7 @@ def render_archived_books(page, order): name = _(u'Archived Books') + ' (' + str(len(archived_book_ids)) + ')' pagename = "archived" - thumbnails = get_thumbnails_for_books(entries + random if random else entries) + thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, title=name, page=pagename, thumbnails=thumbnails) From 8bee2b9552f3ad2e0b3b04b1f3302d67e98b4614 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sun, 19 Sep 2021 22:45:19 -0500 Subject: [PATCH 014/220] Added CACHE_DIR env variable, graceful handling when APScheduler is not installed --- cps/constants.py | 5 ++++- cps/fs.py | 8 ++++++++ cps/helper.py | 2 ++ cps/schedule.py | 15 +++++++-------- cps/services/background_scheduler.py | 26 +++++++++++++++++++------- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/cps/constants.py b/cps/constants.py index 1e44796a..6a6b8f2b 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -40,7 +40,10 @@ else: STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') -CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') + +# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache +DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') +CACHE_DIR = os.environ.get("CACHE_DIR", DEFAULT_CACHE_DIR) if HOME_CONFIG: home_dir = os.path.join(os.path.expanduser("~"),".calibre-web") diff --git a/cps/fs.py b/cps/fs.py index 699d5991..8528e39c 100644 --- a/cps/fs.py +++ b/cps/fs.py @@ -50,6 +50,14 @@ class FileSystem: path = self.get_cache_dir(cache_type) return [file for file in listdir(path) if isfile(join(path, file))] + def list_existing_cache_files(self, filenames, cache_type=None): + path = self.get_cache_dir(cache_type) + return [file for file in listdir(path) if isfile(join(path, file)) and file in filenames] + + def list_missing_cache_files(self, filenames, cache_type=None): + path = self.get_cache_dir(cache_type) + return [file for file in listdir(path) if isfile(join(path, file)) and file not in filenames] + def delete_cache_dir(self, cache_type=None): if not cache_type and isdir(self._cache_dir): rmtree(self._cache_dir) diff --git a/cps/helper.py b/cps/helper.py index 6f22a701..688bc615 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -405,6 +405,7 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa src=new_path, dest=new_name, error=str(ex)) return False + def update_dir_structure_gdrive(book_id, first_author): error = False book = calibre_db.get_book(book_id) @@ -505,6 +506,7 @@ def uniq(inpt): output.append(x) return output + def check_email(email): email = valid_email(email) if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first(): diff --git a/cps/schedule.py b/cps/schedule.py index 8ab9c732..649b65a3 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -27,13 +27,12 @@ from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumb def register_jobs(): scheduler = BackgroundScheduler() - # Generate up to 1000 book covers daily - generate_thumbnails_task = scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(limit=1000), - trigger='interval', days=1) - generate_thumbnails_task.modify(next_run_time=datetime.now()) + if scheduler: + # Reconnect metadata.db once every 12 hours + scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='interval', hours=12) - # Cleanup book cover cache every 6 hours - scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', minute='15', hour='*/6') + # Cleanup book cover cache once every 24 hours + scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='interval', days=1) - # Reconnect metadata.db every 4 hours - scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', minute='5', hour='*/4') + # Generate all missing book cover thumbnails once every 24 hours + scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='interval', days=1) diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index 122168e8..f2688b7f 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -21,13 +21,23 @@ import atexit from .. import logger from .worker import WorkerThread -from apscheduler.schedulers.background import BackgroundScheduler as BScheduler + +try: + from apscheduler.schedulers.background import BackgroundScheduler as BScheduler + use_APScheduler = True +except (ImportError, RuntimeError) as e: + use_APScheduler = False + log = logger.create() + log.info(f'APScheduler not found. Unable to schedule tasks.') class BackgroundScheduler: _instance = None def __new__(cls): + if not use_APScheduler: + return False + if cls._instance is None: cls._instance = super(BackgroundScheduler, cls).__new__(cls) @@ -41,12 +51,14 @@ class BackgroundScheduler: return cls._instance def add(self, func, trigger, **trigger_args): - return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) + if use_APScheduler: + return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) def add_task(self, user, task, trigger, **trigger_args): - def scheduled_task(): - worker_task = task() - self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) - WorkerThread.add(user, worker_task) + if use_APScheduler: + def scheduled_task(): + worker_task = task() + self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) + WorkerThread.add(user, worker_task) - return self.add(func=scheduled_task, trigger=trigger, **trigger_args) + return self.add(func=scheduled_task, trigger=trigger, **trigger_args) From 524ed07a6c3daf6c8e746a9b5e69f76742fe2301 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Tue, 21 Sep 2021 23:39:00 -0500 Subject: [PATCH 015/220] Handle read only access to cache dir gracefully. minor cleanup --- cps.py | 3 +- cps/fs.py | 44 ++++++++++++++++++++++------ cps/schedule.py | 12 +++++--- cps/services/background_scheduler.py | 4 +-- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/cps.py b/cps.py index 82137bbc..fe006551 100755 --- a/cps.py +++ b/cps.py @@ -44,7 +44,7 @@ from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.search_metadata import meta from cps.error_handler import init_errorhandler -from cps.schedule import register_jobs +from cps.schedule import register_jobs, register_startup_jobs try: from cps.kobo import kobo, get_kobo_activated @@ -83,6 +83,7 @@ def main(): # Register scheduled jobs register_jobs() + # register_startup_jobs() success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/fs.py b/cps/fs.py index 8528e39c..4f835fa6 100644 --- a/cps/fs.py +++ b/cps/fs.py @@ -17,6 +17,7 @@ # along with this program. If not, see . from __future__ import division, print_function, unicode_literals +from . import logger from .constants import CACHE_DIR from os import listdir, makedirs, remove from os.path import isdir, isfile, join @@ -32,16 +33,26 @@ class FileSystem: def __new__(cls): if cls._instance is None: cls._instance = super(FileSystem, cls).__new__(cls) + cls.log = logger.create() return cls._instance def get_cache_dir(self, cache_type=None): if not isdir(self._cache_dir): - makedirs(self._cache_dir) + try: + makedirs(self._cache_dir) + except OSError: + self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).') + return False - if cache_type and not isdir(join(self._cache_dir, cache_type)): - makedirs(join(self._cache_dir, cache_type)) + path = join(self._cache_dir, cache_type) + if cache_type and not isdir(path): + try: + makedirs(path) + except OSError: + self.log.info(f'Failed to create path {path} (Permission denied).') + return False - return join(self._cache_dir, cache_type) if cache_type else self._cache_dir + return path if cache_type else self._cache_dir def get_cache_file_path(self, filename, cache_type=None): return join(self.get_cache_dir(cache_type), filename) if filename else None @@ -60,10 +71,25 @@ class FileSystem: def delete_cache_dir(self, cache_type=None): if not cache_type and isdir(self._cache_dir): - rmtree(self._cache_dir) - if cache_type and isdir(join(self._cache_dir, cache_type)): - rmtree(join(self._cache_dir, cache_type)) + try: + rmtree(self._cache_dir) + except OSError: + self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).') + return False + + path = join(self._cache_dir, cache_type) + if cache_type and isdir(path): + try: + rmtree(path) + except OSError: + self.log.info(f'Failed to delete path {path} (Permission denied).') + return False def delete_cache_file(self, filename, cache_type=None): - if isfile(join(self.get_cache_dir(cache_type), filename)): - remove(join(self.get_cache_dir(cache_type), filename)) + path = join(self.get_cache_dir(cache_type), filename) + if isfile(path): + try: + remove(path) + except OSError: + self.log.info(f'Failed to delete path {path} (Permission denied).') + return False diff --git a/cps/schedule.py b/cps/schedule.py index 649b65a3..d427d908 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -18,8 +18,8 @@ from __future__ import division, print_function, unicode_literals -from datetime import datetime from .services.background_scheduler import BackgroundScheduler +from .services.worker import WorkerThread from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails @@ -29,10 +29,14 @@ def register_jobs(): if scheduler: # Reconnect metadata.db once every 12 hours - scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='interval', hours=12) + scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') # Cleanup book cover cache once every 24 hours - scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='interval', days=1) + scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', hour=4) # Generate all missing book cover thumbnails once every 24 hours - scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='interval', days=1) + scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) + + +def register_startup_jobs(): + WorkerThread.add(None, TaskGenerateCoverThumbnails()) diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index f2688b7f..1b588ac2 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -28,7 +28,7 @@ try: except (ImportError, RuntimeError) as e: use_APScheduler = False log = logger.create() - log.info(f'APScheduler not found. Unable to schedule tasks.') + log.info('APScheduler not found. Unable to schedule tasks.') class BackgroundScheduler: @@ -58,7 +58,7 @@ class BackgroundScheduler: if use_APScheduler: def scheduled_task(): worker_task = task() - self.log.info('Running scheduled task in background: ' + worker_task.name + ': ' + worker_task.message) + self.log.info(f'Running scheduled task in background: {worker_task.name} - {worker_task.message}') WorkerThread.add(user, worker_task) return self.add(func=scheduled_task, trigger=trigger, **trigger_args) From be28a9131553b7f29df6c712096530bdb62ae122 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Fri, 24 Sep 2021 03:11:14 -0500 Subject: [PATCH 016/220] Simplified all of the thumbnail generation and loading. --- cps/fs.py | 14 +- cps/helper.py | 61 +-------- cps/jinjia.py | 26 ++-- cps/schedule.py | 5 +- cps/tasks/thumbnail.py | 250 +++++++--------------------------- cps/templates/author.html | 3 +- cps/templates/book_cover.html | 19 ++- cps/templates/book_edit.html | 3 +- cps/templates/detail.html | 3 +- cps/templates/discover.html | 3 +- cps/templates/grid.html | 3 +- cps/templates/index.html | 6 +- cps/templates/search.html | 3 +- cps/templates/shelf.html | 3 +- cps/ub.py | 2 +- cps/web.py | 83 +++-------- 16 files changed, 111 insertions(+), 376 deletions(-) diff --git a/cps/fs.py b/cps/fs.py index 4f835fa6..30ab552a 100644 --- a/cps/fs.py +++ b/cps/fs.py @@ -57,17 +57,9 @@ class FileSystem: def get_cache_file_path(self, filename, cache_type=None): return join(self.get_cache_dir(cache_type), filename) if filename else None - def list_cache_files(self, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file))] - - def list_existing_cache_files(self, filenames, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file)) and file in filenames] - - def list_missing_cache_files(self, filenames, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file)) and file not in filenames] + def get_cache_file_exists(self, filename, cache_type=None): + path = self.get_cache_file_path(filename, cache_type) + return isfile(path) def delete_cache_dir(self, cache_type=None): if not cache_type and isdir(self._cache_dir): diff --git a/cps/helper.py b/cps/helper.py index 688bc615..1652be23 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -35,7 +35,7 @@ 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_login import current_user -from sqlalchemy.sql.expression import true, false, and_, text, func +from sqlalchemy.sql.expression import true, false, and_, or_, text, func from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash from markupsafe import escape @@ -550,26 +550,6 @@ def delete_book(book, calibrepath, book_format): return delete_book_file(book, calibrepath, book_format) -def get_thumbnails_for_books(books): - books_with_covers = list(filter(lambda b: b.has_cover, books)) - book_ids = list(map(lambda b: b.id, books_with_covers)) - cache = fs.FileSystem() - thumbnail_files = cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) - - thumbnails = ub.session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.in_(book_ids))\ - .filter(ub.Thumbnail.expiration > datetime.utcnow())\ - .all() - - return list(filter(lambda t: t.filename in thumbnail_files, thumbnails)) - - -def get_thumbnails_for_book_series(series): - books = list(map(lambda s: s[0], series)) - return get_thumbnails_for_books(books) - - def get_cover_on_failure(use_generic_cover): if use_generic_cover: return send_from_directory(_STATIC_DIR, "generic_cover.jpg") @@ -577,9 +557,9 @@ def get_cover_on_failure(use_generic_cover): return None -def get_book_cover(book_id): +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) + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): @@ -587,37 +567,6 @@ def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): return get_book_cover_internal(book, use_generic_cover_on_failure) -def get_cached_book_cover(cache_id): - parts = cache_id.split('_') - book_uuid = parts[0] if len(parts) else None - resolution = parts[2] if len(parts) > 2 else None - book = calibre_db.get_book_by_uuid(book_uuid) if book_uuid else None - return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) - - -def get_cached_book_cover_thumbnail(cache_id): - parts = cache_id.split('_') - thumbnail_uuid = parts[0] if len(parts) else None - thumbnail = None - if thumbnail_uuid: - thumbnail = ub.session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.uuid == thumbnail_uuid)\ - .first() - - if thumbnail and thumbnail.expiration > datetime.utcnow(): - cache = fs.FileSystem() - if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): - return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) - - elif thumbnail: - book = calibre_db.get_book(thumbnail.book_id) - return get_book_cover_internal(book, use_generic_cover_on_failure=True) - - else: - return get_cover_on_failure(True) - - def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): if book and book.has_cover: @@ -626,7 +575,7 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None) thumbnail = get_book_cover_thumbnail(book, resolution) if thumbnail: cache = fs.FileSystem() - if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + if cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) # Send the book cover from Google Drive if configured @@ -661,7 +610,7 @@ def get_book_cover_thumbnail(book, resolution): .query(ub.Thumbnail)\ .filter(ub.Thumbnail.book_id == book.id)\ .filter(ub.Thumbnail.resolution == resolution)\ - .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ .first() diff --git a/cps/jinjia.py b/cps/jinjia.py index 5f86478c..8c8b72a9 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -33,6 +33,7 @@ from flask_babel import get_locale from flask_login import current_user from markupsafe import escape from . import logger +from .tasks.thumbnail import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X jinjia = Blueprint('jinjia', __name__) @@ -140,24 +141,17 @@ def uuidfilter(var): return uuid4() -@jinjia.app_template_filter('book_cover_cache_id') -def book_cover_cache_id(book, resolution=None): +@jinjia.app_template_filter('last_modified') +def book_cover_cache_id(book): timestamp = int(book.last_modified.timestamp() * 1000) - cache_bust = str(book.uuid) + '_' + str(timestamp) - return cache_bust if not resolution else cache_bust + '_' + str(resolution) + return str(timestamp) -@jinjia.app_template_filter('get_book_thumbnails') -def get_book_thumbnails(book_id, thumbnails=None): - return list(filter(lambda t: t.book_id == book_id, thumbnails)) if book_id > -1 and thumbnails else list() - - -@jinjia.app_template_filter('get_book_thumbnail_srcset') -def get_book_thumbnail_srcset(thumbnails): +@jinjia.app_template_filter('get_cover_srcset') +def get_cover_srcset(book): srcset = list() - for thumbnail in thumbnails: - timestamp = int(thumbnail.generated_at.timestamp() * 1000) - cache_id = str(thumbnail.uuid) + '_' + str(timestamp) - url = url_for('web.get_cached_cover_thumbnail', cache_id=cache_id) - srcset.append(url + ' ' + str(thumbnail.resolution) + 'x') + for resolution in [THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X]: + timestamp = int(book.last_modified.timestamp() * 1000) + url = url_for('web.get_cover', book_id=book.id, resolution=resolution, cache_bust=str(timestamp)) + srcset.append(f'{url} {resolution}x') return ', '.join(srcset) diff --git a/cps/schedule.py b/cps/schedule.py index d427d908..8c350bd5 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -21,7 +21,7 @@ from __future__ import division, print_function, unicode_literals from .services.background_scheduler import BackgroundScheduler from .services.worker import WorkerThread from .tasks.database import TaskReconnectDatabase -from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails +from .tasks.thumbnail import TaskGenerateCoverThumbnails def register_jobs(): @@ -31,9 +31,6 @@ def register_jobs(): # Reconnect metadata.db once every 12 hours scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') - # Cleanup book cover cache once every 24 hours - scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', hour=4) - # Generate all missing book cover thumbnails once every 24 hours scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index fed12e8b..688f6e4b 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -22,7 +22,7 @@ import os from cps import config, db, fs, gdriveutils, logger, ub from cps.services.worker import CalibreTask from datetime import datetime, timedelta -from sqlalchemy import func +from sqlalchemy import or_ try: from urllib.request import urlopen @@ -41,9 +41,8 @@ THUMBNAIL_RESOLUTION_3X = 3 class TaskGenerateCoverThumbnails(CalibreTask): - def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): + def __init__(self, task_message=u'Generating cover thumbnails'): super(TaskGenerateCoverThumbnails, self).__init__(task_message) - self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() self.calibre_db = db.CalibreDB(expire_on_commit=False) @@ -55,74 +54,51 @@ class TaskGenerateCoverThumbnails(CalibreTask): def run(self, worker_thread): if self.calibre_db.session and use_IM: - expired_thumbnails = self.get_expired_thumbnails() - thumbnail_book_ids = self.get_thumbnail_book_ids() - books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) + books_with_covers = self.get_books_with_covers() + count = len(books_with_covers) - count = len(books_without_thumbnails) - if count == 0: - # Do not display this task on the frontend if there are no covers to update - self.self_cleanup = True + updated = 0 + generated = 0 + for i, book in enumerate(books_with_covers): + book_cover_thumbnails = self.get_book_cover_thumbnails(book.id) - for i, book in enumerate(books_without_thumbnails): - for resolution in self.resolutions: - expired_thumbnail = self.get_expired_thumbnail_for_book_and_resolution( - book, - resolution, - expired_thumbnails - ) - if expired_thumbnail: - self.update_book_thumbnail(book, expired_thumbnail) - else: - self.create_book_thumbnail(book, resolution) + # Generate new thumbnails for missing covers + resolutions = list(map(lambda t: t.resolution, book_cover_thumbnails)) + missing_resolutions = list(set(self.resolutions).difference(resolutions)) + for resolution in missing_resolutions: + generated += 1 + self.create_book_cover_thumbnail(book, resolution) - self.message = u'Generating cover thumbnail {0} of {1}'.format(i + 1, count) + # Replace outdated or missing thumbnails + for thumbnail in book_cover_thumbnails: + if book.last_modified > thumbnail.generated_at: + updated += 1 + self.update_book_cover_thumbnail(book, thumbnail) + + elif not self.cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + updated += 1 + self.update_book_cover_thumbnail(book, thumbnail) + + self.message = u'Processing book {0} of {1}'.format(i + 1, count) self.progress = (1.0 / count) * i self._handleSuccess() self.app_db_session.remove() - def get_expired_thumbnails(self): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.expiration < datetime.utcnow())\ - .all() - - def get_thumbnail_book_ids(self): - return self.app_db_session\ - .query(ub.Thumbnail.book_id)\ - .group_by(ub.Thumbnail.book_id)\ - .having(func.min(ub.Thumbnail.expiration) > datetime.utcnow())\ - .distinct() - - def get_books_without_thumbnails(self, thumbnail_book_ids): + def get_books_with_covers(self): return self.calibre_db.session\ .query(db.Books)\ .filter(db.Books.has_cover == 1)\ - .filter(db.Books.id.notin_(thumbnail_book_ids))\ - .limit(self.limit)\ .all() - def get_expired_thumbnail_for_book_and_resolution(self, book, resolution, expired_thumbnails): - for thumbnail in expired_thumbnails: - if thumbnail.book_id == book.id and thumbnail.resolution == resolution: - return thumbnail + def get_book_cover_thumbnails(self, book_id): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book_id)\ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ + .all() - return None - - def update_book_thumbnail(self, book, thumbnail): - thumbnail.generated_at = datetime.utcnow() - thumbnail.expiration = datetime.utcnow() + timedelta(days=30) - - try: - self.app_db_session.commit() - self.generate_book_thumbnail(book, thumbnail) - except Exception as ex: - self.log.info(u'Error updating book thumbnail: ' + str(ex)) - self._handleError(u'Error updating book thumbnail: ' + str(ex)) - self.app_db_session.rollback() - - def create_book_thumbnail(self, book, resolution): + def create_book_cover_thumbnail(self, book, resolution): thumbnail = ub.Thumbnail() thumbnail.book_id = book.id thumbnail.format = 'jpeg' @@ -137,6 +113,18 @@ class TaskGenerateCoverThumbnails(CalibreTask): self._handleError(u'Error creating book thumbnail: ' + str(ex)) self.app_db_session.rollback() + def update_book_cover_thumbnail(self, book, thumbnail): + thumbnail.generated_at = datetime.utcnow() + + try: + self.app_db_session.commit() + self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self.log.info(u'Error updating book thumbnail: ' + str(ex)) + self._handleError(u'Error updating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + def generate_book_thumbnail(self, book, thumbnail): if book and thumbnail: if config.config_use_google_drive: @@ -190,128 +178,6 @@ class TaskGenerateCoverThumbnails(CalibreTask): return "ThumbnailsGenerate" -class TaskSyncCoverThumbnailCache(CalibreTask): - def __init__(self, task_message=u'Syncing cover thumbnail cache'): - super(TaskSyncCoverThumbnailCache, 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.cache = fs.FileSystem() - - def run(self, worker_thread): - cached_thumbnail_files = self.cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) - - # Expire thumbnails in the database if the cached file is missing - # This case will happen if a user deletes the cache dir or cached files - if self.app_db_session: - self.expire_missing_thumbnails(cached_thumbnail_files) - self.progress = 0.25 - - # Delete thumbnails in the database if the book has been removed - # This case will happen if a book is removed in Calibre and the metadata.db file is updated in the filesystem - if self.app_db_session and self.calibre_db: - book_ids = self.get_book_ids() - self.delete_thumbnails_for_missing_books(book_ids) - self.progress = 0.50 - - # Expire thumbnails in the database if their corresponding book has been updated since they were generated - # This case will happen if the book was updated externally - if self.app_db_session and self.cache: - books = self.get_books_updated_in_the_last_day() - book_ids = list(map(lambda b: b.id, books)) - thumbnails = self.get_thumbnails_for_updated_books(book_ids) - self.expire_thumbnails_for_updated_book(books, thumbnails) - self.progress = 0.75 - - # Delete extraneous cached thumbnail files - # This case will happen if a book was deleted and the thumbnail OR the metadata.db file was changed externally - if self.app_db_session: - db_thumbnail_files = self.get_thumbnail_filenames() - self.delete_extraneous_thumbnail_files(cached_thumbnail_files, db_thumbnail_files) - - self._handleSuccess() - self.app_db_session.remove() - - def expire_missing_thumbnails(self, filenames): - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.filename.notin_(filenames))\ - .update({"expiration": datetime.utcnow()}, synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(u'Error expiring thumbnails for missing cache files: ' + str(ex)) - self._handleError(u'Error expiring thumbnails for missing cache files: ' + str(ex)) - self.app_db_session.rollback() - - def get_book_ids(self): - results = self.calibre_db.session\ - .query(db.Books.id)\ - .filter(db.Books.has_cover == 1)\ - .distinct() - - return [value for value, in results] - - def delete_thumbnails_for_missing_books(self, book_ids): - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.notin_(book_ids))\ - .delete(synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(str(ex)) - self._handleError(u'Error deleting thumbnails for missing books: ' + str(ex)) - self.app_db_session.rollback() - - def get_thumbnail_filenames(self): - results = self.app_db_session\ - .query(ub.Thumbnail.filename)\ - .all() - - return [thumbnail for thumbnail, in results] - - def delete_extraneous_thumbnail_files(self, cached_thumbnail_files, db_thumbnail_files): - extraneous_files = list(set(cached_thumbnail_files).difference(db_thumbnail_files)) - for file in extraneous_files: - self.cache.delete_cache_file(file, fs.CACHE_TYPE_THUMBNAILS) - - def get_books_updated_in_the_last_day(self): - return self.calibre_db.session\ - .query(db.Books)\ - .filter(db.Books.has_cover == 1)\ - .filter(db.Books.last_modified > datetime.utcnow() - timedelta(days=1, hours=1))\ - .all() - - def get_thumbnails_for_updated_books(self, book_ids): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.in_(book_ids))\ - .all() - - def expire_thumbnails_for_updated_book(self, books, thumbnails): - thumbnail_ids = list() - for book in books: - for thumbnail in thumbnails: - if thumbnail.book_id == book.id and thumbnail.generated_at < book.last_modified: - thumbnail_ids.append(thumbnail.id) - - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.id.in_(thumbnail_ids)) \ - .update({"expiration": datetime.utcnow()}, synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(u'Error expiring thumbnails for updated books: ' + str(ex)) - self._handleError(u'Error expiring thumbnails for updated books: ' + str(ex)) - self.app_db_session.rollback() - - @property - def name(self): - return "ThumbnailsSync" - - class TaskClearCoverThumbnailCache(CalibreTask): def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): super(TaskClearCoverThumbnailCache, self).__init__(task_message) @@ -325,9 +191,9 @@ class TaskClearCoverThumbnailCache(CalibreTask): if self.book_id: thumbnails = self.get_thumbnails_for_book(self.book_id) for thumbnail in thumbnails: - self.expire_and_delete_thumbnail(thumbnail) + self.delete_thumbnail(thumbnail) else: - self.expire_and_delete_all_thumbnails() + self.delete_all_thumbnails() self._handleSuccess() self.app_db_session.remove() @@ -338,29 +204,19 @@ class TaskClearCoverThumbnailCache(CalibreTask): .filter(ub.Thumbnail.book_id == book_id)\ .all() - def expire_and_delete_thumbnail(self, thumbnail): - thumbnail.expiration = datetime.utcnow() - + def delete_thumbnail(self, thumbnail): try: - self.app_db_session.commit() self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) except Exception as ex: - self.log.info(u'Error expiring book thumbnail: ' + str(ex)) - self._handleError(u'Error expiring book thumbnail: ' + str(ex)) - self.app_db_session.rollback() - - def expire_and_delete_all_thumbnails(self): - self.app_db_session\ - .query(ub.Thumbnail)\ - .update({'expiration': datetime.utcnow()}) + self.log.info(u'Error deleting book thumbnail: ' + str(ex)) + self._handleError(u'Error deleting book thumbnail: ' + str(ex)) + def delete_all_thumbnails(self): try: - self.app_db_session.commit() self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) except Exception as ex: - self.log.info(u'Error expiring book thumbnails: ' + str(ex)) - self._handleError(u'Error expiring book thumbnails: ' + str(ex)) - self.app_db_session.rollback() + self.log.info(u'Error deleting book thumbnails: ' + str(ex)) + self._handleError(u'Error deleting book thumbnails: ' + str(ex)) @property def name(self): diff --git a/cps/templates/author.html b/cps/templates/author.html index 3a4e6c57..2ff1ce7a 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -37,8 +37,7 @@
- {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry, title=author.name|safe) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html index 884ff4cd..7d3a84f4 100644 --- a/cps/templates/book_cover.html +++ b/cps/templates/book_cover.html @@ -1,14 +1,11 @@ -{% macro book_cover_image(book, thumbnails, title=None) -%} +{% macro book_cover_image(book, title=None) -%} {%- set book_title = book.title if book.title else book.name -%} {%- set book_title = title if title else book_title -%} - {% set srcset = thumbnails|get_book_thumbnail_srcset if thumbnails|length else '' %} - {%- if srcset|length -%} - {{ book_title }} - {%- else -%} - {{ book_title }} - {%- endif -%} + {% set srcset = book|get_cover_srcset %} + {{ book_title }} {%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index bce329d0..8525b8e3 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -4,8 +4,7 @@ {% if book %}
- {{ book_cover_image(book, book.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(book) }}
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index ea870d4c..b538ff9a 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,8 +4,7 @@
- {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }}
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index ecfc83d0..01cd393d 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -10,8 +10,7 @@ {% if entry.has_cover is defined %} - {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 5a3c1e01..45fb53bf 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -29,8 +29,7 @@
- {{ book_cover_image(entry[0], entry[0].id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry[0], title=entry[0].series[0].name|shortentitle) }} {{entry.count}} diff --git a/cps/templates/index.html b/cps/templates/index.html index c38ba01e..e47c6c70 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -10,8 +10,7 @@
- {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }} {% if entry.id in read_book_ids %}{% endif %} @@ -88,8 +87,7 @@
- {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/search.html b/cps/templates/search.html index cf9c4fe7..872b6dc1 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -45,8 +45,7 @@ {% if entry.has_cover is defined %} - {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index cb08be90..0e5865c9 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -32,8 +32,7 @@
- {{ book_cover_image(entry, entry.id|get_book_thumbnails(thumbnails)) }} - + {{ book_cover_image(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/ub.py b/cps/ub.py index e0bee4ef..f108a1ce 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -532,7 +532,7 @@ class Thumbnail(Base): resolution = Column(SmallInteger, default=1) filename = Column(String, default=filename) generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) - expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=90)) + expiration = Column(DateTime, nullable=True) # Add missing tables during migration of database diff --git a/cps/web.py b/cps/web.py index f43bc6ad..8f78ff32 100644 --- a/cps/web.py +++ b/cps/web.py @@ -51,7 +51,6 @@ from . import babel, db, ub, config, get_locale, app from . import calibre_db from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .helper import check_valid_domain, render_task_status, check_email, check_username, \ - get_cached_book_cover, get_cached_book_cover_thumbnail, get_thumbnails_for_books, get_thumbnails_for_book_series, \ get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email from .pagination import Pagination @@ -415,10 +414,8 @@ def render_books_list(data, sort, book_id, page): db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series) - - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Books"), page=website, thumbnails=thumbnails) + title=_(u"Books"), page=website) def render_rated_books(page, book_id, order): @@ -467,9 +464,8 @@ def render_hot_books(page): ub.delete_download(book.Downloads.book_id) numBooks = entries.__len__() pagination = Pagination(page, config.config_books_per_page, numBooks) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=_(u"Hot Books (Most Downloaded)"), page="hot", thumbnails=thumbnails) + title=_(u"Hot Books (Most Downloaded)"), page="hot") else: abort(404) @@ -497,16 +493,13 @@ def render_downloaded_books(page, order, user_id): .filter(db.Books.id == book.id).first(): ub.delete_download(book.id) user = ub.session.query(ub.User).filter(ub.User.id == user_id).first() - - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=user_id, title=_(u"Downloaded books by %(user)s",user=user.name), - page="download", - thumbnails=thumbnails) + page="download") else: abort(404) @@ -535,10 +528,9 @@ def render_author_books(page, author_id, order): author_info = services.goodreads_support.get_author_info(author_name) other_books = services.goodreads_support.get_other_books(author_info, entries) - thumbnails = get_thumbnails_for_books(entries) return render_title_template('author.html', entries=entries, pagination=pagination, id=author_id, title=_(u"Author: %(name)s", name=author_name), author=author_info, - other_books=other_books, page="author", thumbnails=thumbnails) + other_books=other_books, page="author") def render_publisher_books(page, book_id, order): @@ -551,10 +543,8 @@ def render_publisher_books(page, book_id, order): db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher", - thumbnails=thumbnails) + title=_(u"Publisher: %(name)s", name=publisher.name), page="publisher") else: abort(404) @@ -566,10 +556,8 @@ def render_series_books(page, book_id, order): db.Books, db.Books.series.any(db.Series.id == book_id), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Series: %(serie)s", serie=name.name), page="series", - thumbnails=thumbnails) + title=_(u"Series: %(serie)s", serie=name.name), page="series") else: abort(404) @@ -581,10 +569,8 @@ def render_ratings_books(page, book_id, order): db.Books.ratings.any(db.Ratings.id == book_id), [order[0]]) if name and name.rating <= 10: - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings", - thumbnails=thumbnails) + title=_(u"Rating: %(rating)s stars", rating=int(name.rating / 2)), page="ratings") else: abort(404) @@ -596,10 +582,8 @@ def render_formats_books(page, book_id, order): db.Books, db.Books.data.any(db.Data.format == book_id.upper()), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, pagination=pagination, entries=entries, id=book_id, - title=_(u"File format: %(format)s", format=name.format), page="formats", - thumbnails=thumbnails) + title=_(u"File format: %(format)s", format=name.format), page="formats") else: abort(404) @@ -614,10 +598,8 @@ def render_category_books(page, book_id, order): db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, - title=_(u"Category: %(name)s", name=name.name), page="category", - thumbnails=thumbnails) + title=_(u"Category: %(name)s", name=name.name), page="category") else: abort(404) @@ -635,9 +617,8 @@ def render_language_books(page, name, order): db.Books, db.Books.languages.any(db.Languages.lang_code == name), [order[0]]) - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=name, - title=_(u"Language: %(name)s", name=lang_name), page="language", thumbnails=thumbnails) + title=_(u"Language: %(name)s", name=lang_name), page="language") def render_read_books(page, are_read, as_xml=False, order=None): @@ -687,10 +668,8 @@ def render_read_books(page, are_read, as_xml=False, order=None): else: name = _(u'Unread Books') + ' (' + str(pagination.total_count) + ')' pagename = "unread" - - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename, thumbnails=thumbnails) + title=name, page=pagename) def render_archived_books(page, order): @@ -713,9 +692,8 @@ def render_archived_books(page, order): name = _(u'Archived Books') + ' (' + str(len(archived_book_ids)) + ')' pagename = "archived" - thumbnails = get_thumbnails_for_books(entries + random if type(random) is list else entries) return render_title_template('index.html', random=random, entries=entries, pagination=pagination, - title=name, page=pagename, thumbnails=thumbnails) + title=name, page=pagename) def render_prepare_search_form(cc): @@ -752,7 +730,6 @@ def render_prepare_search_form(cc): 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, offset, order, limit, *join) - thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', searchterm=term, pagination=pagination, @@ -761,8 +738,7 @@ def render_search_results(term, offset=None, order=None, limit=None): entries=entries, result_count=result_count, title=_(u"Search"), - page="search", - thumbnails=thumbnails) + page="search") # ################################### View Books list ################################################################## @@ -973,10 +949,9 @@ def series_list(): .join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters()) \ .group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all() - thumbnails = get_thumbnails_for_book_series(entries) return render_title_template('grid.html', entries=entries, folder='web.books_list', charlist=charlist, title=_(u"Series"), page="serieslist", data="series", bodyClass="grid-view", - order=order_no, thumbnails=thumbnails) + order=order_no) else: abort(404) @@ -1392,17 +1367,13 @@ def render_adv_search_results(term, offset=None, order=None, limit=None): else: offset = 0 limit_all = result_count - - entries = q[offset:limit_all] - thumbnails = get_thumbnails_for_books(entries) return render_title_template('search.html', adv_searchterm=searchterm, pagination=pagination, - entries=entries, + entries=q[offset:limit_all], result_count=result_count, title=_(u"Advanced Search"), - page="advsearch", - thumbnails=thumbnails) + page="advsearch") @web.route("/advsearch", methods=['GET']) @@ -1417,21 +1388,11 @@ def advanced_search_form(): @web.route("/cover/") +@web.route("/cover//") +@web.route("/cover///") @login_required_if_no_ano -def get_cover(book_id): - return get_book_cover(book_id) - - -@web.route("/cached-cover/") -@login_required_if_no_ano -def get_cached_cover(cache_id): - return get_cached_book_cover(cache_id) - - -@web.route("/cached-cover-thumbnail/") -@login_required_if_no_ano -def get_cached_cover_thumbnail(cache_id): - return get_cached_book_cover_thumbnail(cache_id) +def get_cover(book_id, resolution=None, cache_bust=None): + return get_book_cover(book_id, resolution) @web.route("/robots.txt") @@ -1841,7 +1802,6 @@ def show_book(book_id): if media_format.format.lower() in constants.EXTENSIONS_AUDIO: audioentries.append(media_format.format.lower()) - thumbnails = get_thumbnails_for_books([entries]) return render_title_template('detail.html', entry=entries, audioentries=audioentries, @@ -1853,8 +1813,7 @@ def show_book(book_id): is_archived=is_archived, kindle_list=kindle_list, reader_list=reader_list, - page="book", - thumbnails=thumbnails) + page="book") else: log.debug(u"Oops! Selected book title is unavailable. File does not exist or is not accessible") flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), From 0bd544704d2060efa544d176b582826838d306ec Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sat, 25 Sep 2021 03:04:38 -0500 Subject: [PATCH 017/220] Added series cover thumbnail generation. Better cache file handling. --- cps/admin.py | 2 +- cps/constants.py | 13 ++ cps/fs.py | 19 ++- cps/helper.py | 57 ++++++- cps/jinjia.py | 43 ++++-- cps/schedule.py | 3 +- cps/tasks/thumbnail.py | 280 ++++++++++++++++++++++++++++++---- cps/templates/author.html | 2 +- cps/templates/book_cover.html | 11 -- cps/templates/book_edit.html | 4 +- cps/templates/detail.html | 2 +- cps/templates/discover.html | 4 +- cps/templates/fragment.html | 2 +- cps/templates/grid.html | 4 +- cps/templates/image.html | 23 +++ cps/templates/index.html | 6 +- cps/templates/layout.html | 2 +- cps/templates/search.html | 4 +- cps/templates/shelf.html | 4 +- cps/ub.py | 5 +- cps/web.py | 32 +++- 21 files changed, 430 insertions(+), 92 deletions(-) delete mode 100644 cps/templates/book_cover.html create mode 100644 cps/templates/image.html diff --git a/cps/admin.py b/cps/admin.py index 98535320..92c8bd70 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -166,7 +166,7 @@ def clear_cache(): cache_type = request.args.get('cache_type'.strip()) showtext = {} - if cache_type == fs.CACHE_TYPE_THUMBNAILS: + if cache_type == constants.CACHE_TYPE_THUMBNAILS: log.info('clearing cover thumbnail cache') showtext['text'] = _(u'Cleared cover thumbnail cache') helper.clear_cover_thumbnail_cache() diff --git a/cps/constants.py b/cps/constants.py index 6a6b8f2b..306d2872 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -169,6 +169,19 @@ NIGHTLY_VERSION[1] = '$Format:%cI$' # NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57' # NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00' +# CACHE +CACHE_TYPE_THUMBNAILS = 'thumbnails' + +# Thumbnail Types +THUMBNAIL_TYPE_COVER = 1 +THUMBNAIL_TYPE_SERIES = 2 +THUMBNAIL_TYPE_AUTHOR = 3 + +# Thumbnails Sizes +COVER_THUMBNAIL_ORIGINAL = 0 +COVER_THUMBNAIL_SMALL = 1 +COVER_THUMBNAIL_MEDIUM = 2 +COVER_THUMBNAIL_LARGE = 3 # clean-up the module namespace del sys, os, namedtuple diff --git a/cps/fs.py b/cps/fs.py index 30ab552a..0171a5d5 100644 --- a/cps/fs.py +++ b/cps/fs.py @@ -19,12 +19,10 @@ from __future__ import division, print_function, unicode_literals from . import logger from .constants import CACHE_DIR -from os import listdir, makedirs, remove +from os import makedirs, remove from os.path import isdir, isfile, join from shutil import rmtree -CACHE_TYPE_THUMBNAILS = 'thumbnails' - class FileSystem: _instance = None @@ -54,8 +52,19 @@ class FileSystem: return path if cache_type else self._cache_dir + def get_cache_file_dir(self, filename, cache_type=None): + path = join(self.get_cache_dir(cache_type), filename[:2]) + if not isdir(path): + try: + makedirs(path) + except OSError: + self.log.info(f'Failed to create path {path} (Permission denied).') + return False + + return path + def get_cache_file_path(self, filename, cache_type=None): - return join(self.get_cache_dir(cache_type), filename) if filename else None + return join(self.get_cache_file_dir(filename, cache_type), filename) if filename else None def get_cache_file_exists(self, filename, cache_type=None): path = self.get_cache_file_path(filename, cache_type) @@ -78,7 +87,7 @@ class FileSystem: return False def delete_cache_file(self, filename, cache_type=None): - path = join(self.get_cache_dir(cache_type), filename) + path = self.get_cache_file_path(filename, cache_type) if isfile(path): try: remove(path) diff --git a/cps/helper.py b/cps/helper.py index 1652be23..2f2df0e0 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -55,7 +55,7 @@ from . import calibre_db from .tasks.convert import TaskConvert from . import logger, config, get_locale, db, fs, ub from . import gdriveutils as gd -from .constants import STATIC_DIR as _STATIC_DIR +from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES from .subproc_wrapper import process_wait from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS from .tasks.mail import TaskEmail @@ -575,8 +575,9 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None) thumbnail = get_book_cover_thumbnail(book, resolution) if thumbnail: cache = fs.FileSystem() - if cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): - return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) + if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS), + thumbnail.filename) # Send the book cover from Google Drive if configured if config.config_use_google_drive: @@ -606,14 +607,54 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None) def get_book_cover_thumbnail(book, resolution): if book and book.has_cover: - return ub.session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id == book.id)\ - .filter(ub.Thumbnail.resolution == resolution)\ - .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ + return ub.session \ + .query(ub.Thumbnail) \ + .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \ + .filter(ub.Thumbnail.entity_id == book.id) \ + .filter(ub.Thumbnail.resolution == resolution) \ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .first() +def get_series_thumbnail_on_failure(series_id, resolution): + book = calibre_db.session \ + .query(db.Books) \ + .join(db.books_series_link) \ + .join(db.Series) \ + .filter(db.Series.id == series_id) \ + .filter(db.Books.has_cover == 1) \ + .first() + + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) + + +def get_series_cover_thumbnail(series_id, resolution=None): + return get_series_cover_internal(series_id, resolution) + + +def get_series_cover_internal(series_id, resolution=None): + # Send the series thumbnail if it exists in cache + if resolution: + thumbnail = get_series_thumbnail(series_id, resolution) + if thumbnail: + cache = fs.FileSystem() + if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS), + thumbnail.filename) + + return get_series_thumbnail_on_failure(series_id, resolution) + + +def get_series_thumbnail(series_id, resolution): + return ub.session \ + .query(ub.Thumbnail) \ + .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \ + .filter(ub.Thumbnail.entity_id == series_id) \ + .filter(ub.Thumbnail.resolution == resolution) \ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ + .first() + + # saves book cover from url def save_cover_from_url(url, book_path): try: diff --git a/cps/jinjia.py b/cps/jinjia.py index 8c8b72a9..1a58416a 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -32,9 +32,7 @@ from flask import Blueprint, request, url_for from flask_babel import get_locale from flask_login import current_user from markupsafe import escape -from . import logger -from .tasks.thumbnail import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X - +from . import constants, logger jinjia = Blueprint('jinjia', __name__) log = logger.create() @@ -141,17 +139,44 @@ def uuidfilter(var): return uuid4() +@jinjia.app_template_filter('cache_timestamp') +def cache_timestamp(rolling_period='month'): + if rolling_period == 'day': + return str(int(datetime.datetime.today().replace(hour=1, minute=1).timestamp())) + elif rolling_period == 'year': + return str(int(datetime.datetime.today().replace(day=1).timestamp())) + else: + return str(int(datetime.datetime.today().replace(month=1, day=1).timestamp())) + + @jinjia.app_template_filter('last_modified') -def book_cover_cache_id(book): - timestamp = int(book.last_modified.timestamp() * 1000) - return str(timestamp) +def book_last_modified(book): + return str(int(book.last_modified.timestamp())) @jinjia.app_template_filter('get_cover_srcset') def get_cover_srcset(book): srcset = list() - for resolution in [THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X]: - timestamp = int(book.last_modified.timestamp() * 1000) - url = url_for('web.get_cover', book_id=book.id, resolution=resolution, cache_bust=str(timestamp)) + resolutions = { + constants.COVER_THUMBNAIL_SMALL: 'sm', + constants.COVER_THUMBNAIL_MEDIUM: 'md', + constants.COVER_THUMBNAIL_LARGE: 'lg' + } + for resolution, shortname in resolutions.items(): + url = url_for('web.get_cover', book_id=book.id, resolution=shortname, c=book_last_modified(book)) + srcset.append(f'{url} {resolution}x') + return ', '.join(srcset) + + +@jinjia.app_template_filter('get_series_srcset') +def get_cover_srcset(series): + srcset = list() + resolutions = { + constants.COVER_THUMBNAIL_SMALL: 'sm', + constants.COVER_THUMBNAIL_MEDIUM: 'md', + constants.COVER_THUMBNAIL_LARGE: 'lg' + } + for resolution, shortname in resolutions.items(): + url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp()) srcset.append(f'{url} {resolution}x') return ', '.join(srcset) diff --git a/cps/schedule.py b/cps/schedule.py index 8c350bd5..dc153b9a 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -21,7 +21,7 @@ from __future__ import division, print_function, unicode_literals from .services.background_scheduler import BackgroundScheduler from .services.worker import WorkerThread from .tasks.database import TaskReconnectDatabase -from .tasks.thumbnail import TaskGenerateCoverThumbnails +from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails def register_jobs(): @@ -37,3 +37,4 @@ def register_jobs(): def register_startup_jobs(): WorkerThread.add(None, TaskGenerateCoverThumbnails()) + # WorkerThread.add(None, TaskGenerateSeriesThumbnails()) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 688f6e4b..152b8772 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -19,10 +19,11 @@ from __future__ import division, print_function, unicode_literals import os +from .. import constants from cps import config, db, fs, gdriveutils, logger, ub from cps.services.worker import CalibreTask from datetime import datetime, timedelta -from sqlalchemy import or_ +from sqlalchemy import func, text, or_ try: from urllib.request import urlopen @@ -35,9 +36,34 @@ try: except (ImportError, RuntimeError) as e: use_IM = False -THUMBNAIL_RESOLUTION_1X = 1 -THUMBNAIL_RESOLUTION_2X = 2 -THUMBNAIL_RESOLUTION_3X = 3 + +def get_resize_height(resolution): + return int(225 * resolution) + + +def get_resize_width(resolution, original_width, original_height): + height = get_resize_height(resolution) + percent = (height / float(original_height)) + width = int((float(original_width) * float(percent))) + return width if width % 2 == 0 else width + 1 + + +def get_best_fit(width, height, image_width, image_height): + resize_width = int(width / 2.0) + resize_height = int(height / 2.0) + aspect_ratio = image_width / image_height + + # If this image's aspect ratio is different than the first image, then resize this image + # to fill the width and height of the first image + if aspect_ratio < width / height: + resize_width = int(width / 2.0) + resize_height = image_height * int(width / 2.0) / image_width + + elif aspect_ratio > width / height: + resize_width = image_width * int(height / 2.0) / image_height + resize_height = int(height / 2.0) + + return {'width': resize_width, 'height': resize_height} class TaskGenerateCoverThumbnails(CalibreTask): @@ -48,8 +74,8 @@ class TaskGenerateCoverThumbnails(CalibreTask): self.calibre_db = db.CalibreDB(expire_on_commit=False) self.cache = fs.FileSystem() self.resolutions = [ - THUMBNAIL_RESOLUTION_1X, - THUMBNAIL_RESOLUTION_2X + constants.COVER_THUMBNAIL_SMALL, + constants.COVER_THUMBNAIL_MEDIUM ] def run(self, worker_thread): @@ -75,7 +101,7 @@ class TaskGenerateCoverThumbnails(CalibreTask): updated += 1 self.update_book_cover_thumbnail(book, thumbnail) - elif not self.cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): updated += 1 self.update_book_cover_thumbnail(book, thumbnail) @@ -86,21 +112,23 @@ class TaskGenerateCoverThumbnails(CalibreTask): self.app_db_session.remove() def get_books_with_covers(self): - return self.calibre_db.session\ - .query(db.Books)\ - .filter(db.Books.has_cover == 1)\ + return self.calibre_db.session \ + .query(db.Books) \ + .filter(db.Books.has_cover == 1) \ .all() def get_book_cover_thumbnails(self, book_id): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id == book_id)\ - .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ + return self.app_db_session \ + .query(ub.Thumbnail) \ + .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \ + .filter(ub.Thumbnail.entity_id == book_id) \ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ .all() def create_book_cover_thumbnail(self, book, resolution): thumbnail = ub.Thumbnail() - thumbnail.book_id = book.id + thumbnail.type = constants.THUMBNAIL_TYPE_COVER + thumbnail.entity_id = book.id thumbnail.format = 'jpeg' thumbnail.resolution = resolution @@ -118,7 +146,7 @@ class TaskGenerateCoverThumbnails(CalibreTask): try: self.app_db_session.commit() - self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) self.generate_book_thumbnail(book, thumbnail) except Exception as ex: self.log.info(u'Error updating book thumbnail: ' + str(ex)) @@ -144,7 +172,8 @@ class TaskGenerateCoverThumbnails(CalibreTask): width = self.get_thumbnail_width(height, img) img.resize(width=width, height=height, filter='lanczos') img.format = thumbnail.format - filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + filename = self.cache.get_cache_file_path(thumbnail.filename, + constants.CACHE_TYPE_THUMBNAILS) img.save(filename=filename) except Exception as ex: # Bubble exception to calling function @@ -158,26 +187,212 @@ class TaskGenerateCoverThumbnails(CalibreTask): raise Exception('Book cover file not found') with Image(filename=book_cover_filepath) as img: - height = self.get_thumbnail_height(thumbnail) + height = get_resize_height(thumbnail.resolution) if img.height > height: - width = self.get_thumbnail_width(height, img) + width = get_resize_width(thumbnail.resolution, img.width, img.height) img.resize(width=width, height=height, filter='lanczos') img.format = thumbnail.format - filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) img.save(filename=filename) - def get_thumbnail_height(self, thumbnail): - return int(225 * thumbnail.resolution) - - def get_thumbnail_width(self, height, img): - percent = (height / float(img.height)) - return int((float(img.width) * float(percent))) - @property def name(self): return "ThumbnailsGenerate" +class TaskGenerateSeriesThumbnails(CalibreTask): + def __init__(self, task_message=u'Generating series thumbnails'): + 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.cache = fs.FileSystem() + self.resolutions = [ + constants.COVER_THUMBNAIL_SMALL, + constants.COVER_THUMBNAIL_MEDIUM + ] + + # get all series + # get all books in series with covers and count >= 4 books + # get the dimensions from the first book in the series & pop the first book from the series list of books + # randomly select three other books in the series + + # resize the covers in the sequence? + # create an image sequence from the 4 selected books of the series + # join pairs of books in the series with wand's concat + # join the two sets of pairs with wand's + + def run(self, worker_thread): + if self.calibre_db.session and use_IM: + all_series = self.get_series_with_four_plus_books() + count = len(all_series) + + updated = 0 + generated = 0 + for i, series in enumerate(all_series): + series_thumbnails = self.get_series_thumbnails(series.id) + series_books = self.get_series_books(series.id) + + # Generate new thumbnails for missing covers + resolutions = list(map(lambda t: t.resolution, series_thumbnails)) + missing_resolutions = list(set(self.resolutions).difference(resolutions)) + for resolution in missing_resolutions: + generated += 1 + self.create_series_thumbnail(series, series_books, resolution) + + # Replace outdated or missing thumbnails + for thumbnail in series_thumbnails: + if any(book.last_modified > thumbnail.generated_at for book in series_books): + updated += 1 + self.update_series_thumbnail(series_books, thumbnail) + + elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): + updated += 1 + self.update_series_thumbnail(series_books, thumbnail) + + self.message = u'Processing series {0} of {1}'.format(i + 1, count) + self.progress = (1.0 / count) * i + + self._handleSuccess() + self.app_db_session.remove() + + def get_series_with_four_plus_books(self): + return self.calibre_db.session \ + .query(db.Series) \ + .join(db.books_series_link) \ + .join(db.Books) \ + .filter(db.Books.has_cover == 1) \ + .group_by(text('books_series_link.series')) \ + .having(func.count('book_series_link') > 3) \ + .all() + + def get_series_books(self, series_id): + return self.calibre_db.session \ + .query(db.Books) \ + .join(db.books_series_link) \ + .join(db.Series) \ + .filter(db.Books.has_cover == 1) \ + .filter(db.Series.id == series_id) \ + .all() + + def get_series_thumbnails(self, series_id): + return self.app_db_session \ + .query(ub.Thumbnail) \ + .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \ + .filter(ub.Thumbnail.entity_id == series_id) \ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \ + .all() + + def create_series_thumbnail(self, series, series_books, resolution): + thumbnail = ub.Thumbnail() + thumbnail.type = constants.THUMBNAIL_TYPE_SERIES + thumbnail.entity_id = series.id + thumbnail.format = 'jpeg' + thumbnail.resolution = resolution + + self.app_db_session.add(thumbnail) + try: + self.app_db_session.commit() + self.generate_series_thumbnail(series_books, thumbnail) + except Exception as ex: + self.log.info(u'Error creating book thumbnail: ' + str(ex)) + self._handleError(u'Error creating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def update_series_thumbnail(self, series_books, thumbnail): + thumbnail.generated_at = datetime.utcnow() + + try: + self.app_db_session.commit() + self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) + self.generate_series_thumbnail(series_books, thumbnail) + except Exception as ex: + self.log.info(u'Error updating book thumbnail: ' + str(ex)) + self._handleError(u'Error updating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + + def generate_series_thumbnail(self, series_books, thumbnail): + books = series_books[:4] + + top = 0 + left = 0 + width = 0 + height = 0 + with Image() as canvas: + for book in books: + if config.config_use_google_drive: + if not gdriveutils.is_gdrive_ready(): + raise Exception('Google Drive is configured but not ready') + + web_content_link = gdriveutils.get_cover_via_gdrive(book.path) + if not web_content_link: + raise Exception('Google Drive cover url not found') + + stream = None + try: + stream = urlopen(web_content_link) + with Image(file=stream) as img: + # Use the first image in this set to determine the width and height to scale the + # other images in this set + if width == 0 or height == 0: + width = get_resize_width(thumbnail.resolution, img.width, img.height) + height = get_resize_height(thumbnail.resolution) + canvas.blank(width, height) + + dimensions = get_best_fit(width, height, img.width, img.height) + + # resize and crop the image + img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos') + img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center') + + # add the image to the canvas + canvas.composite(img, left, top) + + except Exception as ex: + self.log.info(u'Error generating thumbnail file: ' + str(ex)) + raise ex + finally: + stream.close() + + book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') + if not os.path.isfile(book_cover_filepath): + raise Exception('Book cover file not found') + + with Image(filename=book_cover_filepath) as img: + # Use the first image in this set to determine the width and height to scale the + # other images in this set + if width == 0 or height == 0: + width = get_resize_width(thumbnail.resolution, img.width, img.height) + height = get_resize_height(thumbnail.resolution) + canvas.blank(width, height) + + dimensions = get_best_fit(width, height, img.width, img.height) + + # resize and crop the image + img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos') + img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center') + + # add the image to the canvas + canvas.composite(img, left, top) + + # set the coordinates for the next iteration + if left == 0 and top == 0: + left = int(width / 2.0) + elif left == int(width / 2.0) and top == 0: + left = 0 + top = int(height / 2.0) + else: + left = int(width / 2.0) + + canvas.format = thumbnail.format + filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) + canvas.save(filename=filename) + + @property + def name(self): + return "SeriesThumbnailGenerate" + + class TaskClearCoverThumbnailCache(CalibreTask): def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): super(TaskClearCoverThumbnailCache, self).__init__(task_message) @@ -199,21 +414,22 @@ class TaskClearCoverThumbnailCache(CalibreTask): self.app_db_session.remove() def get_thumbnails_for_book(self, book_id): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id == book_id)\ + return self.app_db_session \ + .query(ub.Thumbnail) \ + .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \ + .filter(ub.Thumbnail.entity_id == book_id) \ .all() def delete_thumbnail(self, thumbnail): try: - self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) except Exception as ex: self.log.info(u'Error deleting book thumbnail: ' + str(ex)) self._handleError(u'Error deleting book thumbnail: ' + str(ex)) def delete_all_thumbnails(self): try: - self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) + self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS) except Exception as ex: self.log.info(u'Error deleting book thumbnails: ' + str(ex)) self._handleError(u'Error deleting book thumbnails: ' + str(ex)) diff --git a/cps/templates/author.html b/cps/templates/author.html index 2ff1ce7a..df891d5b 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -37,7 +37,7 @@
- {{ book_cover_image(entry, title=author.name|safe) }} + {{ image.book_cover(entry, title=author.name|safe) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/book_cover.html b/cps/templates/book_cover.html deleted file mode 100644 index 7d3a84f4..00000000 --- a/cps/templates/book_cover.html +++ /dev/null @@ -1,11 +0,0 @@ -{% macro book_cover_image(book, title=None) -%} - {%- set book_title = book.title if book.title else book.name -%} - {%- set book_title = title if title else book_title -%} - {% set srcset = book|get_cover_srcset %} - {{ book_title }} -{%- endmacro %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 8525b8e3..b5f5df7a 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -1,10 +1,10 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %} {% if book %}
- {{ book_cover_image(book) }} + {{ image.book_cover(book) }}
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index b538ff9a..7985691a 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,7 +4,7 @@
- {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }}
diff --git a/cps/templates/discover.html b/cps/templates/discover.html index 01cd393d..d697ff0a 100644 --- a/cps/templates/discover.html +++ b/cps/templates/discover.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %}
@@ -10,7 +10,7 @@ {% if entry.has_cover is defined %} - {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/fragment.html b/cps/templates/fragment.html index 901dd193..f2e94fb2 100644 --- a/cps/templates/fragment.html +++ b/cps/templates/fragment.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %}
{% block body %}{% endblock %}
diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 45fb53bf..b3395fcb 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %}

{{_(title)}}

@@ -29,7 +29,7 @@
- {{ book_cover_image(entry[0], title=entry[0].series[0].name|shortentitle) }} + {{ image.series(entry[0].series[0], title=entry[0].series[0].name|shortentitle) }} {{entry.count}} diff --git a/cps/templates/image.html b/cps/templates/image.html new file mode 100644 index 00000000..ae57727b --- /dev/null +++ b/cps/templates/image.html @@ -0,0 +1,23 @@ +{% macro book_cover(book, title=None, alt=None) -%} + {%- set image_title = book.title if book.title else book.name -%} + {%- set image_title = title if title else image_title -%} + {%- set image_alt = alt if alt else image_title -%} + {% set srcset = book|get_cover_srcset %} + {{ image_alt }} +{%- endmacro %} + +{% macro series(series, title=None, alt=None) -%} + {%- set image_alt = alt if alt else image_title -%} + {% set srcset = series|get_series_srcset %} + {{ book_title }} +{%- endmacro %} diff --git a/cps/templates/index.html b/cps/templates/index.html index e47c6c70..709d6ce7 100644 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %} {% if g.user.show_detail_random() %} @@ -10,7 +10,7 @@
- {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }} {% if entry.id in read_book_ids %}{% endif %} @@ -87,7 +87,7 @@
- {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 63dc7469..4a664ec1 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -1,5 +1,5 @@ {% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %} -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} diff --git a/cps/templates/search.html b/cps/templates/search.html index 872b6dc1..15ff344f 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %}
@@ -45,7 +45,7 @@ {% if entry.has_cover is defined %} - {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 0e5865c9..18dbde8b 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -1,4 +1,4 @@ -{% from 'book_cover.html' import book_cover_image %} +{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %}
@@ -32,7 +32,7 @@
- {{ book_cover_image(entry) }} + {{ image.book_cover(entry) }} {% if entry.id in read_book_ids %}{% endif %} diff --git a/cps/ub.py b/cps/ub.py index f108a1ce..7666f0f2 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -526,10 +526,11 @@ class Thumbnail(Base): __tablename__ = 'thumbnail' id = Column(Integer, primary_key=True) - book_id = Column(Integer) + entity_id = Column(Integer) uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) format = Column(String, default='jpeg') - resolution = Column(SmallInteger, default=1) + type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER) + resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL) filename = Column(String, default=filename) generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) expiration = Column(DateTime, nullable=True) diff --git a/cps/web.py b/cps/web.py index 8f78ff32..88a5c0ab 100644 --- a/cps/web.py +++ b/cps/web.py @@ -50,8 +50,8 @@ from . import constants, logger, isoLanguages, services from . import babel, db, ub, config, get_locale, app from . import calibre_db from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download -from .helper import check_valid_domain, render_task_status, check_email, check_username, \ - get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ +from .helper import check_valid_domain, render_task_status, check_email, check_username, get_cc_columns, \ + get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \ send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email from .pagination import Pagination from .redirect import redirect_back @@ -1388,11 +1388,31 @@ def advanced_search_form(): @web.route("/cover/") -@web.route("/cover//") -@web.route("/cover///") +@web.route("/cover//") @login_required_if_no_ano -def get_cover(book_id, resolution=None, cache_bust=None): - return get_book_cover(book_id, resolution) +def get_cover(book_id, resolution=None): + resolutions = { + 'og': constants.COVER_THUMBNAIL_ORIGINAL, + 'sm': constants.COVER_THUMBNAIL_SMALL, + 'md': constants.COVER_THUMBNAIL_MEDIUM, + 'lg': constants.COVER_THUMBNAIL_LARGE, + } + cover_resolution = resolutions.get(resolution, None) + return get_book_cover(book_id, cover_resolution) + + +@web.route("/series_cover/") +@web.route("/series_cover//") +@login_required_if_no_ano +def get_series_cover(series_id, resolution=None): + resolutions = { + 'og': constants.COVER_THUMBNAIL_ORIGINAL, + 'sm': constants.COVER_THUMBNAIL_SMALL, + 'md': constants.COVER_THUMBNAIL_MEDIUM, + 'lg': constants.COVER_THUMBNAIL_LARGE, + } + cover_resolution = resolutions.get(resolution, None) + return get_series_cover_thumbnail(series_id, cover_resolution) @web.route("/robots.txt") From 26071d4e7ad1943f91904804fc1b5464866d748b Mon Sep 17 00:00:00 2001 From: mmonkey Date: Sun, 26 Sep 2021 02:02:48 -0500 Subject: [PATCH 018/220] Added Scheduled Tasks Settings --- cps/admin.py | 62 +++++++++++++++++++--------- cps/config_sql.py | 15 +++++-- cps/schedule.py | 19 +++++---- cps/services/background_scheduler.py | 24 +++++++---- cps/tasks/thumbnail.py | 14 +------ cps/templates/admin.html | 43 +++++++++++-------- cps/templates/schedule_edit.html | 38 +++++++++++++++++ 7 files changed, 147 insertions(+), 68 deletions(-) create mode 100644 cps/templates/schedule_edit.html diff --git a/cps/admin.py b/cps/admin.py index 92c8bd70..bd292ba3 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -159,23 +159,6 @@ def shutdown(): return json.dumps(showtext), 400 -@admi.route("/clear-cache") -@login_required -@admin_required -def clear_cache(): - cache_type = request.args.get('cache_type'.strip()) - showtext = {} - - if cache_type == constants.CACHE_TYPE_THUMBNAILS: - log.info('clearing cover thumbnail cache') - showtext['text'] = _(u'Cleared cover thumbnail cache') - helper.clear_cover_thumbnail_cache() - return json.dumps(showtext) - - showtext['text'] = _(u'Unknown command') - return json.dumps(showtext) - - @admi.route("/admin/view") @login_required @admin_required @@ -205,6 +188,7 @@ def admin(): feature_support=feature_support, kobo_support=kobo_support, title=_(u"Admin page"), page="admin") + @admi.route("/admin/dbconfig", methods=["GET", "POST"]) @login_required @admin_required @@ -245,6 +229,7 @@ def ajax_db_config(): def calibreweb_alive(): return "", 200 + @admi.route("/admin/viewconfig") @login_required @admin_required @@ -257,6 +242,7 @@ def view_configuration(): restrictColumns=restrict_columns, title=_(u"UI Configuration"), page="uiconfig") + @admi.route("/admin/usertable") @login_required @admin_required @@ -339,6 +325,7 @@ def list_users(): response.headers["Content-Type"] = "application/json; charset=utf-8" return response + @admi.route("/ajax/deleteuser", methods=['POST']) @login_required @admin_required @@ -372,6 +359,7 @@ def delete_user(): success.extend(errors) return Response(json.dumps(success), mimetype='application/json') + @admi.route("/ajax/getlocale") @login_required @admin_required @@ -517,6 +505,7 @@ def update_table_settings(): return "Invalid request", 400 return "" + def check_valid_read_column(column): if column != "0": if not calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.id == column) \ @@ -524,6 +513,7 @@ def check_valid_read_column(column): return False return True + def check_valid_restricted_column(column): if column != "0": if not calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.id == column) \ @@ -532,7 +522,6 @@ def check_valid_restricted_column(column): return True - @admi.route("/admin/viewconfig", methods=["POST"]) @login_required @admin_required @@ -564,7 +553,6 @@ def update_view_configuration(): _config_int(to_save, "config_books_per_page") _config_int(to_save, "config_authors_max") - config.config_default_role = constants.selected_roles(to_save) config.config_default_role &= ~constants.ROLE_ANONYMOUS @@ -1210,6 +1198,7 @@ def _db_configuration_update_helper(): config.save() return _db_configuration_result(None, gdrive_error) + def _configuration_update_helper(): reboot_required = False to_save = request.form.to_dict() @@ -1299,6 +1288,7 @@ def _configuration_update_helper(): return _configuration_result(None, reboot_required) + def _configuration_result(error_flash=None, reboot=False): resp = {} if error_flash: @@ -1388,6 +1378,7 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support): log.error("Settings DB is not Writeable") flash(_("Settings DB is not Writeable"), category="error") + def _delete_user(content): if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, ub.User.id != content.id).count(): @@ -1572,6 +1563,39 @@ def update_mailsettings(): return edit_mailsettings() +@admi.route("/admin/scheduledtasks") +@login_required +@admin_required +def edit_scheduledtasks(): + content = config.get_scheduled_task_settings() + return render_title_template("schedule_edit.html", content=content, title=_(u"Edit Scheduled Tasks Settings")) + + +@admi.route("/admin/scheduledtasks", methods=["POST"]) +@login_required +@admin_required +def update_scheduledtasks(): + to_save = request.form.to_dict() + _config_int(to_save, "schedule_start_time") + _config_int(to_save, "schedule_end_time") + _config_checkbox(to_save, "schedule_generate_book_covers") + _config_checkbox(to_save, "schedule_generate_series_covers") + + try: + config.save() + flash(_(u"Scheduled tasks settings updated"), category="success") + except IntegrityError as ex: + ub.session.rollback() + log.error("An unknown error occurred while saving scheduled tasks settings") + flash(_(u"An unknown error occurred. Please try again later."), category="error") + except OperationalError: + ub.session.rollback() + log.error("Settings DB is not Writeable") + flash(_("Settings DB is not Writeable"), category="error") + + return edit_scheduledtasks() + + @admi.route("/admin/user/", methods=["GET", "POST"]) @login_required @admin_required diff --git a/cps/config_sql.py b/cps/config_sql.py index 88107f9b..4841f03d 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -133,13 +133,18 @@ class _Settings(_Base): config_calibre = Column(String) config_rarfile_location = Column(String, default=None) config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD)) - config_unicode_filename =Column(Boolean, default=False) + config_unicode_filename = Column(Boolean, default=False) config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) config_reverse_proxy_login_header_name = Column(String) config_allow_reverse_proxy_header_login = Column(Boolean, default=False) + schedule_start_time = Column(Integer, default=4) + schedule_end_time = Column(Integer, default=6) + schedule_generate_book_covers = Column(Boolean, default=False) + schedule_generate_series_covers = Column(Boolean, default=False) + def __repr__(self): return self.__class__.__name__ @@ -170,7 +175,6 @@ class _ConfigSQL(object): if change: self.save() - def _read_from_storage(self): if self._settings is None: log.debug("_ConfigSQL._read_from_storage") @@ -254,6 +258,8 @@ class _ConfigSQL(object): return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0) or (self.mail_gmail_token != {} and self.mail_server_type == 1)) + def get_scheduled_task_settings(self): + return {k:v for k, v in self.__dict__.items() if k.startswith('schedule_')} def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None): """Possibly updates a field of this object. @@ -289,7 +295,6 @@ class _ConfigSQL(object): storage[k] = v return storage - def load(self): '''Load all configuration values from the underlying storage.''' s = self._read_from_storage() # type: _Settings @@ -407,6 +412,7 @@ def autodetect_calibre_binary(): return element return "" + def autodetect_unrar_binary(): if sys.platform == "win32": calibre_path = ["C:\\program files\\WinRar\\unRAR.exe", @@ -418,6 +424,7 @@ def autodetect_unrar_binary(): return element return "" + def autodetect_kepubify_binary(): if sys.platform == "win32": calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe", @@ -429,6 +436,7 @@ def autodetect_kepubify_binary(): return element return "" + def _migrate_database(session): # make sure the table is created, if it does not exist _Base.metadata.create_all(session.bind) @@ -452,6 +460,7 @@ def load_configuration(session): # session.commit() return conf + def get_flask_session_key(session): flask_settings = session.query(_Flask_Settings).one_or_none() if flask_settings == None: diff --git a/cps/schedule.py b/cps/schedule.py index dc153b9a..2cddaecb 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -19,7 +19,6 @@ from __future__ import division, print_function, unicode_literals from .services.background_scheduler import BackgroundScheduler -from .services.worker import WorkerThread from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails @@ -28,13 +27,19 @@ def register_jobs(): scheduler = BackgroundScheduler() if scheduler: - # Reconnect metadata.db once every 12 hours - scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') + # Reconnect Calibre database (metadata.db) + scheduler.schedule_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') - # Generate all missing book cover thumbnails once every 24 hours - scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) + # Generate all missing book cover thumbnails + scheduler.schedule_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) + + # Generate all missing series thumbnails + scheduler.schedule_task(user=None, task=lambda: TaskGenerateSeriesThumbnails(), trigger='cron', hour=4) def register_startup_jobs(): - WorkerThread.add(None, TaskGenerateCoverThumbnails()) - # WorkerThread.add(None, TaskGenerateSeriesThumbnails()) + scheduler = BackgroundScheduler() + + if scheduler: + scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateCoverThumbnails()) + scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateSeriesThumbnails()) diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index 1b588ac2..ba578903 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -40,25 +40,31 @@ class BackgroundScheduler: if cls._instance is None: cls._instance = super(BackgroundScheduler, cls).__new__(cls) - - scheduler = BScheduler() - atexit.register(lambda: scheduler.shutdown()) - cls.log = logger.create() - cls.scheduler = scheduler + cls.scheduler = BScheduler() cls.scheduler.start() + atexit.register(lambda: cls.scheduler.shutdown()) + return cls._instance - def add(self, func, trigger, **trigger_args): + def _add(self, func, trigger, **trigger_args): if use_APScheduler: return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) - def add_task(self, user, task, trigger, **trigger_args): + # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled + def schedule_task(self, user, task, trigger, **trigger_args): if use_APScheduler: def scheduled_task(): worker_task = task() - self.log.info(f'Running scheduled task in background: {worker_task.name} - {worker_task.message}') WorkerThread.add(user, worker_task) - return self.add(func=scheduled_task, trigger=trigger, **trigger_args) + return self._add(func=scheduled_task, trigger=trigger, **trigger_args) + + # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled + def schedule_task_immediately(self, user, task): + if use_APScheduler: + def scheduled_task(): + WorkerThread.add(user, task()) + + return self._add(func=scheduled_task, trigger='date') diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 152b8772..a220fd8c 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -167,9 +167,9 @@ class TaskGenerateCoverThumbnails(CalibreTask): try: stream = urlopen(web_content_link) with Image(file=stream) as img: - height = self.get_thumbnail_height(thumbnail) + height = get_resize_height(thumbnail.resolution) if img.height > height: - width = self.get_thumbnail_width(height, img) + width = get_resize_width(thumbnail.resolution, img.width, img.height) img.resize(width=width, height=height, filter='lanczos') img.format = thumbnail.format filename = self.cache.get_cache_file_path(thumbnail.filename, @@ -212,16 +212,6 @@ class TaskGenerateSeriesThumbnails(CalibreTask): constants.COVER_THUMBNAIL_MEDIUM ] - # get all series - # get all books in series with covers and count >= 4 books - # get the dimensions from the first book in the series & pop the first book from the series list of books - # randomly select three other books in the series - - # resize the covers in the sequence? - # create an image sequence from the 4 selected books of the series - # join pairs of books in the series with wand's concat - # join the two sets of pairs with wand's - def run(self, worker_thread): if self.calibre_db.session and use_IM: all_series = self.get_series_with_four_plus_books() diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 597ba103..ec0fc84e 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -156,6 +156,31 @@
+
+
+

{{_('Scheduled Tasks')}}

+
+
+
{{_('Time at which tasks start to run')}}
+
{{config.schedule_start_time}}:00
+
+
+
{{_('Time at which tasks stop running')}}
+
{{config.schedule_end_time}}:00
+
+
+
{{_('Generate book cover thumbnails')}}
+
{{ display_bool_setting(config.schedule_generate_book_covers) }}
+
+
+
{{_('Generate series cover thumbnails')}}
+
{{ display_bool_setting(config.schedule_generate_series_covers) }}
+
+
+ {{_('Edit Scheduled Tasks Settings')}} +
+
+

{{_('Administration')}}

{{_('Download Debug Package')}} @@ -163,7 +188,6 @@
{{_('Reconnect Calibre Database')}}
-
{{_('Clear Cover Thumbnail Cache')}}
{{_('Restart')}}
@@ -248,21 +272,4 @@
- {% endblock %} diff --git a/cps/templates/schedule_edit.html b/cps/templates/schedule_edit.html new file mode 100644 index 00000000..f4e72224 --- /dev/null +++ b/cps/templates/schedule_edit.html @@ -0,0 +1,38 @@ +{% extends "layout.html" %} +{% block header %} + + +{% endblock %} +{% block body %} +
+

{{title}}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {{_('Cancel')}} +
+
+{% endblock %} From 46205a1f83ec6a81c57d5dd3348ea583f9201063 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Wed, 29 Sep 2021 02:40:12 -0500 Subject: [PATCH 019/220] Made long running tasks cancellable. Added cancel button to cancellable tasks in the task list. Added APP_MODE env variable for determining if the app is running in development, test, or production. --- cps.py | 8 +- cps/admin.py | 23 +++++- cps/constants.py | 7 +- cps/db.py | 6 +- cps/helper.py | 17 +++- cps/schedule.py | 61 ++++++++++++--- cps/services/background_scheduler.py | 35 ++++++--- cps/services/worker.py | 35 +++++++-- cps/static/css/caliBlur.css | 93 +++++++++------------- cps/static/js/main.js | 12 --- cps/static/js/table.js | 34 +++++++- cps/tasks/convert.py | 4 + cps/tasks/database.py | 4 + cps/tasks/mail.py | 6 +- cps/tasks/thumbnail.py | 111 +++++++++++++++++++-------- cps/tasks/upload.py | 4 + cps/templates/admin.html | 2 +- cps/templates/schedule_edit.html | 6 +- cps/templates/tasks.html | 27 +++++++ cps/web.py | 4 +- 20 files changed, 346 insertions(+), 153 deletions(-) diff --git a/cps.py b/cps.py index fe006551..20a27c71 100755 --- a/cps.py +++ b/cps.py @@ -44,7 +44,7 @@ from cps.editbooks import editbook from cps.remotelogin import remotelogin from cps.search_metadata import meta from cps.error_handler import init_errorhandler -from cps.schedule import register_jobs, register_startup_jobs +from cps.schedule import register_scheduled_tasks, register_startup_tasks try: from cps.kobo import kobo, get_kobo_activated @@ -81,9 +81,9 @@ def main(): if oauth_available: app.register_blueprint(oauth) - # Register scheduled jobs - register_jobs() - # register_startup_jobs() + # Register scheduled tasks + register_scheduled_tasks() + register_startup_tasks() success = web_server.start() sys.exit(0 if success else 1) diff --git a/cps/admin.py b/cps/admin.py index bd292ba3..62b3dbe0 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -40,12 +40,13 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text -from . import constants, logger, helper, services, isoLanguages, fs -from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils +from . import constants, logger, helper, services, isoLanguages +from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, schedule from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username 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 try: @@ -1568,7 +1569,7 @@ def update_mailsettings(): @admin_required def edit_scheduledtasks(): content = config.get_scheduled_task_settings() - return render_title_template("schedule_edit.html", content=content, title=_(u"Edit Scheduled Tasks Settings")) + return render_title_template("schedule_edit.html", config=content, title=_(u"Edit Scheduled Tasks Settings")) @admi.route("/admin/scheduledtasks", methods=["POST"]) @@ -1584,6 +1585,12 @@ def update_scheduledtasks(): try: config.save() flash(_(u"Scheduled tasks settings updated"), category="success") + + # Cancel any running tasks + schedule.end_scheduled_tasks() + + # Re-register tasks with new settings + schedule.register_scheduled_tasks() except IntegrityError as ex: ub.session.rollback() log.error("An unknown error occurred while saving scheduled tasks settings") @@ -1869,3 +1876,13 @@ def extract_dynamic_field_from_filter(user, filtr): def extract_user_identifier(user, filtr): dynamic_field = extract_dynamic_field_from_filter(user, filtr) return extract_user_data_from_field(user, dynamic_field) + + +@admi.route("/ajax/canceltask", methods=['POST']) +@login_required +@admin_required +def cancel_task(): + task_id = request.get_json().get('task_id', None) + worker = WorkerThread.get_instance() + worker.end_task(task_id) + return "" diff --git a/cps/constants.py b/cps/constants.py index 306d2872..a92a0029 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -24,10 +24,13 @@ from sqlalchemy import __version__ as sql_version sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2,0,0]) +# APP_MODE - production, development, or test +APP_MODE = os.environ.get('APP_MODE', 'production') + # if installed via pip this variable is set to true (empty file with name .HOMEDIR present) HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR')) -#In executables updater is not available, so variable is set to False there +# In executables updater is not available, so variable is set to False there UPDATER_AVAILABLE = True # Base dir is parent of current file, necessary if called from different folder @@ -43,7 +46,7 @@ TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') # Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache') -CACHE_DIR = os.environ.get("CACHE_DIR", DEFAULT_CACHE_DIR) +CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR) if HOME_CONFIG: home_dir = os.path.join(os.path.expanduser("~"),".calibre-web") diff --git a/cps/db.py b/cps/db.py index 296db7a4..e4cea100 100644 --- a/cps/db.py +++ b/cps/db.py @@ -443,11 +443,11 @@ class CalibreDB(): """ self.session = None if self._init: - self.initSession(expire_on_commit) + self.init_session(expire_on_commit) self.instances.add(self) - def initSession(self, expire_on_commit=True): + def init_session(self, expire_on_commit=True): self.session = self.session_factory() self.session.expire_on_commit = expire_on_commit self.update_title_sort(self.config) @@ -593,7 +593,7 @@ class CalibreDB(): autoflush=True, bind=cls.engine)) for inst in cls.instances: - inst.initSession() + inst.init_session() cls._init = True return True diff --git a/cps/helper.py b/cps/helper.py index 2f2df0e0..a221dbb7 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -57,7 +57,8 @@ from . import logger, config, get_locale, db, fs, ub 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 -from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS +from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \ + STAT_CANCELLED from .tasks.mail import TaskEmail from .tasks.thumbnail import TaskClearCoverThumbnailCache @@ -838,12 +839,22 @@ def render_task_status(tasklist): 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) + 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['id'] = task.id + ret['stat'] = task.stat + ret['is_cancellable'] = task.is_cancellable + renderedtasklist.append(ret) return renderedtasklist @@ -914,5 +925,5 @@ def get_download_link(book_id, book_format, client): abort(404) -def clear_cover_thumbnail_cache(book_id=None): +def clear_cover_thumbnail_cache(book_id): WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id)) diff --git a/cps/schedule.py b/cps/schedule.py index 2cddaecb..2bb7878f 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -17,29 +17,70 @@ # along with this program. If not, see . from __future__ import division, print_function, unicode_literals +import datetime +from . import config, constants from .services.background_scheduler import BackgroundScheduler from .tasks.database import TaskReconnectDatabase from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails +from .services.worker import WorkerThread -def register_jobs(): +def get_scheduled_tasks(reconnect=True): + tasks = list() + + # Reconnect Calibre database (metadata.db) + if reconnect: + tasks.append(lambda: TaskReconnectDatabase()) + + # Generate all missing book cover thumbnails + if config.schedule_generate_book_covers: + tasks.append(lambda: TaskGenerateCoverThumbnails()) + + # Generate all missing series thumbnails + if config.schedule_generate_series_covers: + tasks.append(lambda: TaskGenerateSeriesThumbnails()) + + return tasks + + +def end_scheduled_tasks(): + worker = WorkerThread.get_instance() + for __, __, __, task in worker.tasks: + if task.scheduled and task.is_cancellable: + worker.end_task(task.id) + + +def register_scheduled_tasks(): scheduler = BackgroundScheduler() if scheduler: - # Reconnect Calibre database (metadata.db) - scheduler.schedule_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') + # Remove all existing jobs + scheduler.remove_all_jobs() - # Generate all missing book cover thumbnails - scheduler.schedule_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) + start = config.schedule_start_time + end = config.schedule_end_time - # Generate all missing series thumbnails - scheduler.schedule_task(user=None, task=lambda: TaskGenerateSeriesThumbnails(), trigger='cron', hour=4) + # Register scheduled tasks + if start != end: + scheduler.schedule_tasks(tasks=get_scheduled_tasks(), trigger='cron', hour=start) + scheduler.schedule(func=end_scheduled_tasks, trigger='cron', hour=end) + + # Kick-off tasks, if they should currently be running + now = datetime.datetime.now().hour + if start <= now < end: + scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False)) -def register_startup_jobs(): +def register_startup_tasks(): scheduler = BackgroundScheduler() if scheduler: - scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateCoverThumbnails()) - scheduler.schedule_task_immediately(None, task=lambda: TaskGenerateSeriesThumbnails()) + start = config.schedule_start_time + end = config.schedule_end_time + now = datetime.datetime.now().hour + + # Run scheduled tasks immediately for development and testing + # Ignore tasks that should currently be running, as these will be added when registering scheduled tasks + if constants.APP_MODE in ['development', 'test'] and not (start <= now < end): + scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False)) diff --git a/cps/services/background_scheduler.py b/cps/services/background_scheduler.py index ba578903..971b0bf7 100644 --- a/cps/services/background_scheduler.py +++ b/cps/services/background_scheduler.py @@ -48,23 +48,38 @@ class BackgroundScheduler: return cls._instance - def _add(self, func, trigger, **trigger_args): + def schedule(self, func, trigger, **trigger_args): if use_APScheduler: return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args) - # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled - def schedule_task(self, user, task, trigger, **trigger_args): + # Expects a lambda expression for the task + def schedule_task(self, task, user=None, trigger='cron', **trigger_args): if use_APScheduler: def scheduled_task(): worker_task = task() + worker_task.scheduled = True WorkerThread.add(user, worker_task) + return self.schedule(func=scheduled_task, trigger=trigger, **trigger_args) - return self._add(func=scheduled_task, trigger=trigger, **trigger_args) - - # Expects a lambda expression for the task, so that the task isn't instantiated before the task is scheduled - def schedule_task_immediately(self, user, task): + # Expects a list of lambda expressions for the tasks + def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args): if use_APScheduler: - def scheduled_task(): - WorkerThread.add(user, task()) + for task in tasks: + self.schedule_task(task, user=user, trigger=trigger, **trigger_args) - return self._add(func=scheduled_task, trigger='date') + # Expects a lambda expression for the task + def schedule_task_immediately(self, task, user=None): + if use_APScheduler: + def immediate_task(): + WorkerThread.add(user, task()) + return self.schedule(func=immediate_task, trigger='date') + + # Expects a list of lambda expressions for the tasks + def schedule_tasks_immediately(self, tasks, user=None): + if use_APScheduler: + for task in tasks: + self.schedule_task_immediately(task, user) + + # Remove all jobs + def remove_all_jobs(self): + self.scheduler.remove_all_jobs() diff --git a/cps/services/worker.py b/cps/services/worker.py index 97068f74..04a4c056 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -21,6 +21,8 @@ STAT_WAITING = 0 STAT_FAIL = 1 STAT_STARTED = 2 STAT_FINISH_SUCCESS = 3 +STAT_ENDED = 4 +STAT_CANCELLED = 5 # Only retain this many tasks in dequeued list TASK_CLEANUP_TRIGGER = 20 @@ -50,7 +52,7 @@ class WorkerThread(threading.Thread): _instance = None @classmethod - def getInstance(cls): + def get_instance(cls): if cls._instance is None: cls._instance = WorkerThread() return cls._instance @@ -67,12 +69,13 @@ class WorkerThread(threading.Thread): @classmethod def add(cls, user, task): - ins = cls.getInstance() + ins = cls.get_instance() ins.num += 1 - log.debug("Add Task for user: {}: {}".format(user, task)) + username = user if user is not None else 'System' + log.debug("Add Task for user: {}: {}".format(username, task)) ins.queue.put(QueuedTask( num=ins.num, - user=user, + user=username, added=datetime.now(), task=task, )) @@ -134,6 +137,12 @@ class WorkerThread(threading.Thread): self.queue.task_done() + def end_task(self, task_id): + ins = self.get_instance() + for __, __, __, task in ins.tasks: + if str(task.id) == str(task_id) and task.is_cancellable: + task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED + class CalibreTask: __metaclass__ = abc.ABCMeta @@ -147,10 +156,11 @@ class CalibreTask: self.message = message self.id = uuid.uuid4() self.self_cleanup = False + self._scheduled = False @abc.abstractmethod def run(self, worker_thread): - """Provides the caller some human-readable name for this class""" + """The main entry-point for this task""" raise NotImplementedError @abc.abstractmethod @@ -158,6 +168,11 @@ class CalibreTask: """Provides the caller some human-readable name for this class""" raise NotImplementedError + @abc.abstractmethod + def is_cancellable(self): + """Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?""" + raise NotImplementedError + def start(self, *args): self.start_time = datetime.now() self.stat = STAT_STARTED @@ -208,7 +223,7 @@ class CalibreTask: We have a separate dictating this because there may be certain tasks that want to override this """ # By default, we're good to clean a task if it's "Done" - return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL) + return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED) '''@progress.setter def progress(self, x): @@ -226,6 +241,14 @@ class CalibreTask: def self_cleanup(self, is_self_cleanup): self._self_cleanup = is_self_cleanup + @property + def scheduled(self): + return self._scheduled + + @scheduled.setter + def scheduled(self, is_scheduled): + self._scheduled = is_scheduled + def _handleError(self, error_message): self.stat = STAT_FAIL self.progress = 1 diff --git a/cps/static/css/caliBlur.css b/cps/static/css/caliBlur.css index b4fa6045..b2b35423 100644 --- a/cps/static/css/caliBlur.css +++ b/cps/static/css/caliBlur.css @@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head pointer-events: none } -#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #ClearCacheDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { +#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, #cancelTaskModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { cursor: pointer } @@ -5237,6 +5237,10 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d margin-bottom: 20px } +body.admin > div.container-fluid div.scheduled_tasks_details { + margin-bottom: 20px +} + body.admin .btn-default { margin-bottom: 10px } @@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar { z-index: 0 !important } -#RestartDialog, #ShutdownDialog, #StatusDialog, #ClearCacheDialog, #deleteModal { +#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal { top: 0; overflow: hidden; padding-top: 70px; @@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar { background: rgba(0, 0, 0, .5) } -#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #ClearCacheDialog:before, #deleteModal:before { +#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before, #cancelTaskModal:before { content: "\E208"; padding-right: 10px; display: block; @@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar { z-index: 99 } -#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { +#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before { -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0) } -#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { +#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog { width: 450px; margin: auto } -#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { +#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content { max-height: calc(100% - 90px); -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); box-shadow: 0 5px 15px rgba(0, 0, 0, .5); @@ -5522,7 +5526,7 @@ body.admin.modal-open .navbar { width: 450px } -#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { +#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header { padding: 15px 20px; border-radius: 3px 3px 0 0; line-height: 1.71428571; @@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar { text-align: left } -#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before { padding-right: 10px; font-size: 18px; color: #999; @@ -5559,12 +5563,12 @@ body.admin.modal-open .navbar { font-family: plex-icons-new, serif } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:before { - content: "\EA15"; +#deleteModal > .modal-dialog > .modal-content > .modal-header:before { + content: "\EA6D"; font-family: plex-icons-new, serif } -#deleteModal > .modal-dialog > .modal-content > .modal-header:before { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before { content: "\EA6D"; font-family: plex-icons-new, serif } @@ -5587,19 +5591,19 @@ body.admin.modal-open .navbar { font-size: 20px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-header:after { - content: "Clear Cover Thumbnail Cache"; - display: inline-block; - font-size: 20px -} - #deleteModal > .modal-dialog > .modal-content > .modal-header:after { content: "Delete Book"; display: inline-block; font-size: 20px } -#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:after { + content: "Delete Book"; + display: inline-block; + font-size: 20px +} + +#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile { display: none } @@ -5613,7 +5617,7 @@ body.admin.modal-open .navbar { text-align: left } -#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body { +#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body { padding: 20px 20px 40px; font-size: 16px; line-height: 1.6em; @@ -5623,17 +5627,7 @@ body.admin.modal-open .navbar { text-align: left } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body { - padding: 20px 20px 10px; - font-size: 16px; - line-height: 1.6em; - font-family: Open Sans Regular, Helvetica Neue, Helvetica, Arial, sans-serif; - color: #eee; - background: #282828; - text-align: left -} - -#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body > p { padding: 20px 20px 0 0; font-size: 16px; line-height: 1.6em; @@ -5642,7 +5636,7 @@ body.admin.modal-open .navbar { background: #282828 } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { float: right; z-index: 9; position: relative; @@ -5678,11 +5672,11 @@ body.admin.modal-open .navbar { border-radius: 3px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > #clear_cache { +#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; position: relative; - margin: 25px 0 0 10px; + margin: 0 0 0 10px; min-width: 80px; padding: 10px 18px; font-size: 16px; @@ -5690,7 +5684,7 @@ body.admin.modal-open .navbar { border-radius: 3px } -#deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger { float: right; z-index: 9; position: relative; @@ -5710,15 +5704,15 @@ body.admin.modal-open .navbar { margin: 55px 0 0 10px } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache) { - margin: 25px 0 0 10px -} - #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { margin: 0 0 0 10px } -#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #ClearCacheDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#clear_cache):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { +#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { + margin: 0 0 0 10px +} + +#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { background-color: hsla(0, 0%, 100%, .3) } @@ -5752,21 +5746,6 @@ body.admin.modal-open .navbar { box-shadow: 0 5px 15px rgba(0, 0, 0, .5) } -#ClearCacheDialog > .modal-dialog > .modal-content > .modal-body:after { - content: ''; - position: absolute; - width: 100%; - height: 72px; - background-color: #323232; - border-radius: 0 0 3px 3px; - left: 0; - margin-top: 10px; - z-index: 0; - border-top: 1px solid #222; - -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); - box-shadow: 0 5px 15px rgba(0, 0, 0, .5) -} - #deleteButton { position: fixed; top: 60px; @@ -7355,11 +7334,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. background-color: transparent !important } - #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #ClearCacheDialog > .modal-dialog, #deleteModal > .modal-dialog { + #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog { max-width: calc(100vw - 40px) } - #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #ClearCacheDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { + #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content { max-width: calc(100vw - 40px); left: 0 } @@ -7509,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div. padding: 30px 15px } - #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #ClearCacheDialog.in:before, #deleteModal.in:before { + #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before { left: auto; right: 34px } diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 5537d189..988e3b9f 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -454,18 +454,6 @@ $(function() { } }); }); - $("#clear_cache").click(function () { - $("#spinner3").show(); - $.ajax({ - dataType: "json", - url: window.location.pathname + "/../../clear-cache", - data: {"cache_type":"thumbnails"}, - success: function(data) { - $("#spinner3").hide(); - $("#ClearCacheDialog").modal("hide"); - } - }); - }); // Init all data control handlers to default $("input[data-control]").trigger("change"); diff --git a/cps/static/js/table.js b/cps/static/js/table.js index a55ec5d1..dc4ab4ab 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -/* exported TableActions, RestrictionActions, EbookActions, responseHandler */ +/* exported TableActions, RestrictionActions, EbookActions, TaskActions, responseHandler */ /* global getPath, confirmDialog */ var selections = []; @@ -42,6 +42,24 @@ $(function() { }, 1000); } + $("#cancel_task_confirm").click(function() { + //get data-id attribute of the clicked element + var taskId = $(this).data("task-id"); + $.ajax({ + method: "post", + contentType: "application/json; charset=utf-8", + dataType: "json", + url: window.location.pathname + "/../ajax/canceltask", + data: JSON.stringify({"task_id": taskId}), + }); + }); + //triggered when modal is about to be shown + $("#cancelTaskModal").on("show.bs.modal", function(e) { + //get data-id attribute of the clicked element and store in button + var taskId = $(e.relatedTarget).data("task-id"); + $(e.currentTarget).find("#cancel_task_confirm").data("task-id", taskId); + }); + $("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table", function (e, rowsAfter, rowsBefore) { var rows = rowsAfter; @@ -576,6 +594,7 @@ function handle_header_buttons () { $(".header_select").removeAttr("disabled"); } } + /* Function for deleting domain restrictions */ function TableActions (value, row) { return [ @@ -613,6 +632,19 @@ function UserActions (value, row) { ].join(""); } +/* Function for cancelling tasks */ +function TaskActions (value, row) { + var cancellableStats = [0, 1, 2]; + if (row.id && row.is_cancellable && cancellableStats.includes(row.stat)) { + return [ + "
", + "", + "
" + ].join(""); + } + return ''; +} + /* Function for keeping checked rows */ function responseHandler(res) { $.each(res.rows, function (i, row) { diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index 56cc7076..f150a397 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -234,3 +234,7 @@ class TaskConvert(CalibreTask): @property def name(self): return "Convert" + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/database.py b/cps/tasks/database.py index 11f0186d..0441d564 100644 --- a/cps/tasks/database.py +++ b/cps/tasks/database.py @@ -47,3 +47,7 @@ class TaskReconnectDatabase(CalibreTask): @property def name(self): return "Reconnect Database" + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 292114d5..24064bd3 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -162,7 +162,6 @@ class TaskEmail(CalibreTask): log.debug_or_exception(ex) self._handleError(u'Error sending e-mail: {}'.format(ex)) - def send_standard_email(self, msg): use_ssl = int(self.settings.get('mail_use_ssl', 0)) timeout = 600 # set timeout to 5mins @@ -218,7 +217,6 @@ class TaskEmail(CalibreTask): self.asyncSMTP = None self._progress = x - @classmethod def _get_attachment(cls, bookpath, filename): """Get file as MIMEBase message""" @@ -260,5 +258,9 @@ class TaskEmail(CalibreTask): def name(self): return "E-mail" + @property + def is_cancellable(self): + return False + def __str__(self): return "{}, {}".format(self.name, self.subject) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index a220fd8c..d147f10d 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) -# Copyright (C) 2020 mmonkey +# Copyright (C) 2020 monkey # # 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 @@ -21,8 +21,8 @@ import os from .. import constants from cps import config, db, fs, gdriveutils, logger, ub -from cps.services.worker import CalibreTask -from datetime import datetime, timedelta +from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED +from datetime import datetime from sqlalchemy import func, text, or_ try: @@ -67,7 +67,7 @@ def get_best_fit(width, height, image_width, image_height): class TaskGenerateCoverThumbnails(CalibreTask): - def __init__(self, task_message=u'Generating cover thumbnails'): + def __init__(self, task_message=''): super(TaskGenerateCoverThumbnails, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -79,13 +79,14 @@ class TaskGenerateCoverThumbnails(CalibreTask): ] def run(self, worker_thread): - if self.calibre_db.session and use_IM: + if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: + self.message = 'Scanning Books' books_with_covers = self.get_books_with_covers() count = len(books_with_covers) - updated = 0 - generated = 0 + total_generated = 0 for i, book in enumerate(books_with_covers): + generated = 0 book_cover_thumbnails = self.get_book_cover_thumbnails(book.id) # Generate new thumbnails for missing covers @@ -98,16 +99,32 @@ class TaskGenerateCoverThumbnails(CalibreTask): # Replace outdated or missing thumbnails for thumbnail in book_cover_thumbnails: if book.last_modified > thumbnail.generated_at: - updated += 1 + generated += 1 self.update_book_cover_thumbnail(book, thumbnail) elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): - updated += 1 + generated += 1 self.update_book_cover_thumbnail(book, thumbnail) - self.message = u'Processing book {0} of {1}'.format(i + 1, count) + # Increment the progress self.progress = (1.0 / count) * i + if generated > 0: + total_generated += generated + self.message = u'Generated {0} cover thumbnails'.format(total_generated) + + # Check if job has been cancelled or ended + if self.stat == STAT_CANCELLED: + self.log.info(f'GenerateCoverThumbnails task has been cancelled.') + return + + if self.stat == STAT_ENDED: + self.log.info(f'GenerateCoverThumbnails task has been ended.') + return + + if total_generated == 0: + self.self_cleanup = True + self._handleSuccess() self.app_db_session.remove() @@ -180,7 +197,8 @@ class TaskGenerateCoverThumbnails(CalibreTask): self.log.info(u'Error generating thumbnail file: ' + str(ex)) raise ex finally: - stream.close() + if stream is not None: + stream.close() else: book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') if not os.path.isfile(book_cover_filepath): @@ -197,11 +215,15 @@ class TaskGenerateCoverThumbnails(CalibreTask): @property def name(self): - return "ThumbnailsGenerate" + return 'GenerateCoverThumbnails' + + @property + def is_cancellable(self): + return True class TaskGenerateSeriesThumbnails(CalibreTask): - def __init__(self, task_message=u'Generating series thumbnails'): + def __init__(self, task_message=''): super(TaskGenerateSeriesThumbnails, self).__init__(task_message) self.log = logger.create() self.app_db_session = ub.get_new_session_instance() @@ -209,17 +231,18 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.cache = fs.FileSystem() self.resolutions = [ constants.COVER_THUMBNAIL_SMALL, - constants.COVER_THUMBNAIL_MEDIUM + constants.COVER_THUMBNAIL_MEDIUM, ] def run(self, worker_thread): - if self.calibre_db.session and use_IM: + if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED: + self.message = 'Scanning Series' all_series = self.get_series_with_four_plus_books() count = len(all_series) - updated = 0 - generated = 0 + total_generated = 0 for i, series in enumerate(all_series): + generated = 0 series_thumbnails = self.get_series_thumbnails(series.id) series_books = self.get_series_books(series.id) @@ -233,16 +256,32 @@ class TaskGenerateSeriesThumbnails(CalibreTask): # Replace outdated or missing thumbnails for thumbnail in series_thumbnails: if any(book.last_modified > thumbnail.generated_at for book in series_books): - updated += 1 + generated += 1 self.update_series_thumbnail(series_books, thumbnail) elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): - updated += 1 + generated += 1 self.update_series_thumbnail(series_books, thumbnail) - self.message = u'Processing series {0} of {1}'.format(i + 1, count) + # Increment the progress self.progress = (1.0 / count) * i + if generated > 0: + total_generated += generated + self.message = u'Generated {0} series thumbnails'.format(total_generated) + + # Check if job has been cancelled or ended + if self.stat == STAT_CANCELLED: + self.log.info(f'GenerateSeriesThumbnails task has been cancelled.') + return + + if self.stat == STAT_ENDED: + self.log.info(f'GenerateSeriesThumbnails task has been ended.') + return + + if total_generated == 0: + self.self_cleanup = True + self._handleSuccess() self.app_db_session.remove() @@ -302,7 +341,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.app_db_session.rollback() def generate_series_thumbnail(self, series_books, thumbnail): - books = series_books[:4] + # Get the last four books in the series based on series_index + books = sorted(series_books, key=lambda b: float(b.series_index), reverse=True)[:4] top = 0 left = 0 @@ -342,7 +382,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): self.log.info(u'Error generating thumbnail file: ' + str(ex)) raise ex finally: - stream.close() + if stream is not None: + stream.close() book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg') if not os.path.isfile(book_cover_filepath): @@ -380,11 +421,15 @@ class TaskGenerateSeriesThumbnails(CalibreTask): @property def name(self): - return "SeriesThumbnailGenerate" + return 'GenerateSeriesThumbnails' + + @property + def is_cancellable(self): + return True class TaskClearCoverThumbnailCache(CalibreTask): - def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): + def __init__(self, book_id, task_message=u'Clearing cover thumbnail cache'): super(TaskClearCoverThumbnailCache, self).__init__(task_message) self.log = logger.create() self.book_id = book_id @@ -397,8 +442,6 @@ class TaskClearCoverThumbnailCache(CalibreTask): thumbnails = self.get_thumbnails_for_book(self.book_id) for thumbnail in thumbnails: self.delete_thumbnail(thumbnail) - else: - self.delete_all_thumbnails() self._handleSuccess() self.app_db_session.remove() @@ -411,19 +454,19 @@ class TaskClearCoverThumbnailCache(CalibreTask): .all() def delete_thumbnail(self, thumbnail): + thumbnail.expiration = datetime.utcnow() + try: + self.app_db_session.commit() self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) except Exception as ex: self.log.info(u'Error deleting book thumbnail: ' + str(ex)) self._handleError(u'Error deleting book thumbnail: ' + str(ex)) - def delete_all_thumbnails(self): - try: - self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS) - except Exception as ex: - self.log.info(u'Error deleting book thumbnails: ' + str(ex)) - self._handleError(u'Error deleting book thumbnails: ' + str(ex)) - @property def name(self): - return "ThumbnailsClear" + return 'ThumbnailsClear' + + @property + def is_cancellable(self): + return False diff --git a/cps/tasks/upload.py b/cps/tasks/upload.py index d7ef34c2..9f58bf16 100644 --- a/cps/tasks/upload.py +++ b/cps/tasks/upload.py @@ -16,3 +16,7 @@ class TaskUpload(CalibreTask): @property def name(self): return "Upload" + + @property + def is_cancellable(self): + return False diff --git a/cps/templates/admin.html b/cps/templates/admin.html index ec0fc84e..3fd55ae2 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -159,7 +159,7 @@

{{_('Scheduled Tasks')}}

-
+
{{_('Time at which tasks start to run')}}
{{config.schedule_start_time}}:00
diff --git a/cps/templates/schedule_edit.html b/cps/templates/schedule_edit.html index f4e72224..71bb2d1a 100644 --- a/cps/templates/schedule_edit.html +++ b/cps/templates/schedule_edit.html @@ -11,7 +11,7 @@
@@ -19,12 +19,12 @@
- +
diff --git a/cps/templates/tasks.html b/cps/templates/tasks.html index c13ddff9..b36a6daa 100644 --- a/cps/templates/tasks.html +++ b/cps/templates/tasks.html @@ -16,6 +16,9 @@ {{_('Progress')}} {{_('Run Time')}} {{_('Start Time')}} + {% if g.user.role_admin() %} + {{_('Actions')}} + {% endif %} @@ -23,6 +26,30 @@
{% endblock %} +{% block modal %} +{{ delete_book() }} +{% if g.user.role_admin() %} + +{% endif %} +{% endblock %} {% block js %} diff --git a/cps/web.py b/cps/web.py index 88a5c0ab..c5ad2265 100644 --- a/cps/web.py +++ b/cps/web.py @@ -124,7 +124,7 @@ def viewer_required(f): @web.route("/ajax/emailstat") @login_required def get_email_status_json(): - tasks = WorkerThread.getInstance().tasks + tasks = WorkerThread.get_instance().tasks return jsonify(render_task_status(tasks)) @@ -1055,7 +1055,7 @@ def category_list(): @login_required def get_tasks_status(): # if current user admin, show all email, otherwise only own emails - tasks = WorkerThread.getInstance().tasks + tasks = WorkerThread.get_instance().tasks answer = render_task_status(tasks) return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks") From 9e7f69e38a2958d63d1c0a1299687d82ff686417 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Wed, 29 Sep 2021 03:01:28 -0500 Subject: [PATCH 020/220] Updated series cache timeout to one day --- cps/templates/image.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cps/templates/image.html b/cps/templates/image.html index ae57727b..728384a1 100644 --- a/cps/templates/image.html +++ b/cps/templates/image.html @@ -16,7 +16,7 @@ {% set srcset = series|get_series_srcset %} {{ book_title }} From cd3791f5f4b85581705117dcaff9f18bbe052336 Mon Sep 17 00:00:00 2001 From: mmonkey Date: Thu, 30 Sep 2021 01:43:31 -0500 Subject: [PATCH 021/220] Always use full-sized image for book edit and details pages --- cps/__init__.py | 3 +-- cps/admin.py | 2 +- cps/templates/book_edit.html | 4 ++-- cps/templates/detail.html | 3 ++- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cps/__init__.py b/cps/__init__.py index 838da5b4..af4a82e8 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -114,9 +114,8 @@ def create_app(): if os.environ.get('FLASK_DEBUG'): cache_buster.init_cache_busting(app) - cache_buster.init_cache_busting(app) - log.info('Starting Calibre Web...') + Principal(app) lm.init_app(app) app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session)) diff --git a/cps/admin.py b/cps/admin.py index 62b3dbe0..4d168aa0 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -40,7 +40,7 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_, text -from . import constants, logger, helper, services, isoLanguages +from . import constants, logger, helper, services from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, schedule from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ valid_email, check_username diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index b5f5df7a..12d3bd07 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -1,10 +1,10 @@ -{% import 'image.html' as image %} {% extends "layout.html" %} {% block body %} {% if book %}
- {{ image.book_cover(book) }} + +
{% if g.user.role_delete_books() %}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 7985691a..19cd079e 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -4,7 +4,8 @@
- {{ image.book_cover(entry) }} + +
From 920acaca99879055821744470a0e85f6ee51ec03 Mon Sep 17 00:00:00 2001 From: collerek Date: Sat, 11 Dec 2021 01:06:04 +0100 Subject: [PATCH 022/220] everything working to refactor --- .gitignore | 1 + cps/metadata_provider/lubimyczytac.py | 373 ++++++++++++++++++++++++++ cps/static/js/get_meta.js | 46 +++- requirements.txt | 2 + 4 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 cps/metadata_provider/lubimyczytac.py diff --git a/.gitignore b/.gitignore index 614e9936..989c7811 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ .python-version env/ venv/ +p38venv/ eggs/ dist/ executable/ diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py new file mode 100644 index 00000000..aab50bb6 --- /dev/null +++ b/cps/metadata_provider/lubimyczytac.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2021 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 +import re +from typing import Dict, List +from urllib.parse import quote + +import requests +from cps.services.Metadata import Metadata +from lxml.html import fromstring, tostring + + +def get_int_or_float(v): + number_as_float = float(v) + number_as_int = int(number_as_float) + return number_as_int if number_as_float == number_as_int else number_as_float + + +def strip_accents(s): + if s is None: + return s + else: + symbols = ( + "öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ", + "oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ", + ) + tr = dict([(ord(a), ord(b)) for (a, b) in zip(*symbols)]) + return s.translate(tr) # .lower() + + +def sanitize_comments_html(html): + from markdown2 import Markdown + + text = html2text(html) + md = Markdown() + html = md.convert(text) + return html + + +def html2text(html): + from html2text import HTML2Text + import re + + # replace tags with as becomes emphasis in html2text + if isinstance(html, bytes): + html = html.decode("utf-8") + html = re.sub( + r"<\s*(?P/?)\s*[uU]\b(?P[^>]*)>", + r"<\gspan\g>", + html, + ) + h2t = HTML2Text() + h2t.body_width = 0 + h2t.single_line_break = True + h2t.emphasis_mark = "*" + return h2t.handle(html) + + +class LubimyCzytac(Metadata): + __name__ = "LubimyCzytac.pl" + __id__ = "lubimyczytac" + + BASE_URL = "https://lubimyczytac.pl" + + BOOK_SEARCH_RESULT_XPATH = ( + "*//div[@class='listSearch']//div[@class='authorAllBooks__single']" + ) + SINGLE_BOOK_RESULT_XPATH = ".//div[contains(@class,'authorAllBooks__singleText')]" + TITLE_PATH = "/div/a[contains(@class,'authorAllBooks__singleTextTitle')]" + TITLE_TEXT_PATH = f"{TITLE_PATH}//text()" + URL_PATH = f"{TITLE_PATH}/@href" + AUTHORS_PATH = "/div/a[contains(@href,'autor')]//text()" + + SIBLINGS = "/following-sibling::dd" + + CONTAINER = "//section[@class='container book']" + PUBLISHER = f"{CONTAINER}//dt[contains(text(),'Wydawnictwo:')]{SIBLINGS}/a/text()" + LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()" + DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']" + SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]" + + DETAILS = "//div[@id='book-details']" + 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()" + RATING = "//meta[@property='books:rating:value']/@content" + COVER = "//meta[@property='og:image']/@content" + + SUMMARY = "//script[@type='application/ld+json']//text()" + + def search(self, query, __): + if self.active: + result = requests.get(self._prepare_query(title=query)) + root = fromstring(result.text) + matches = self._parse_search_results(root=root) + if matches: + for ind, match in enumerate(matches): + matches[ind] = self._parse_single_book(match=match) + return matches + + def _prepare_query(self, title: str) -> str: + query = "" + characters_to_remove = "\?()\/" + pattern = "[" + characters_to_remove + "]" + title = re.sub(pattern, "", title) + title = title.replace("_", " ") + if '"' in title or ",," in title: + title = title.split('"')[0].split(",,")[0] + + if "/" in title: + title_tokens = [ + token for token in title.lower().split(" ") if len(token) > 1 + ] + else: + title_tokens = list( + self.get_title_tokens(title, strip_joiners=False, strip_subtitle=True) + ) + if title_tokens: + tokens = [quote(t.encode("utf-8")) for t in title_tokens] + query = query + "%20".join(tokens) + if not query: + return "" + return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}" + + def _parse_search_results(self, root) -> List[Dict]: + matches = [] + results = root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH) + for result in results: + title = result.xpath( + f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" + f"{LubimyCzytac.TITLE_TEXT_PATH}" + ) + book_url = result.xpath( + f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" f"{LubimyCzytac.URL_PATH}" + ) + authors = result.xpath( + f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" + f"{LubimyCzytac.AUTHORS_PATH}" + ) + + if not title or not book_url or not authors: + continue + title = title[0].strip() + book_url = LubimyCzytac.BASE_URL + book_url[0] + book_id = book_url.replace(f"{LubimyCzytac.BASE_URL}/ksiazka/", "").split( + "/" + )[0] + matches.append( + {"id": book_id, "title": title, "authors": authors, "url": book_url} + ) + return matches + + def _parse_single_book(self, match: Dict) -> Dict: + url = match.get("url") + result = requests.get(url) + root = fromstring(result.text) + match["series"], match["series_index"] = self._parse_series(root=root) + match["tags"] = self._parse_tags(root=root) + match["publisher"] = self._parse_publisher(root=root) + match["publishedDate"] = self._parse_from_summary( + root=root, attribute_name="datePublished" + ) + match["rating"] = self._parse_rating(root=root) + match["description"] = self._parse_description(root=root) + match["cover"] = self._parse_cover(root=root) + match["source"] = { + "id": self.__id__, + "description": self.__name__, + "link": LubimyCzytac.BASE_URL, + } + match['languages'] = self._parse_languages(root=root) + match["identifiers"] = { + "isbn": self._parse_isbn(root=root), + "lubimyczytac": match["id"], + } + return match + + def _parse_cover(self, root): + imgcol_node = root.xpath('//meta[@property="og:image"]/@content') + if imgcol_node: + img_url = imgcol_node[0] + return img_url + + def _parse_publisher(self, root): + publisher = root.xpath(LubimyCzytac.PUBLISHER) + if publisher: + return publisher[0] + else: + return None + + def _parse_languages(self, root): + lang = root.xpath(LubimyCzytac.LANGUAGES) + languages = list() + if lang: + lang = lang[0].strip() + if "polski" in lang: + languages.append("Polish") + if "angielski" in lang: + languages.append("English") + if not languages: + return ['Polish'] + return languages + + def _parse_series(self, root): + try: + series_node = root.xpath(LubimyCzytac.SERIES) + if series_node: + series_lst = root.xpath(f"{LubimyCzytac.SERIES}/text()") + if series_lst: + series_txt = series_lst + else: + series_txt = None + else: + return (None, None) + + if series_txt: + ser_string = [series_txt[0].replace("\n", "").strip()] + ser_nazwa = ser_string + for ser in ser_string: + if "tom " in ser: + ser_info = ser.split(" (tom ", 1) + ser_nazwa = ser.split(" (tom ")[0] + break + + if ser_info: + series_index_unicode = ser_info[1] + series_index_string = str( + series_index_unicode.replace(" ", "").replace(")", "") + ) + # Sprawdzamy, czy cykl nie jest kompletem/pakietem tomów, np. 1-3 + if "-" in series_index_string: + series_index_string_temp = series_index_string.split("-", 1) + series_index_string = series_index_string_temp[0] + if series_index_string.replace(".", "").isdigit() is True: + series_index = get_int_or_float(series_index_string) + else: + series_index = 0 + else: + series_index = 0 + series = ser_nazwa + return (series, series_index) + except: + return (None, None) + + def _parse_tags(self, root): + tags = None + try: + tags_from_genre = root.xpath(LubimyCzytac.TAGS) + if tags_from_genre: + tags = tags_from_genre + tags = [w.replace(", itd.", " itd.") for w in tags] + return tags + else: + return None + except: + return tags + + def _parse_from_summary(self, root, attribute_name: str) -> str: + data = json.loads(root.xpath(LubimyCzytac.SUMMARY)[0]) + value = data.get(attribute_name) + return value.strip() if value is not None else value + + def _parse_rating(self, root): + rating_node = root.xpath(LubimyCzytac.RATING) + if rating_node: + rating_value = round(float((rating_node[0]).replace(",", ".")) / 2) + return rating_value + return None + + def _parse_date(self, root, xpath="first_publish"): + options = { + "first_publish": LubimyCzytac.FIRST_PUBLISH_DATE, + "first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL, + } + path = options.get(xpath) + from dateutil import parser + + data = root.xpath(path) + if data: + first_pub_date = data[0].strip() + return parser.parse(first_pub_date) + return None + + def _parse_isbn(self, root): + isbn_node = root.xpath('//meta[@property="books:isbn"]/@content')[0] + return isbn_node + + def _parse_description(self, root): + comments = "" + description_node = root.xpath(LubimyCzytac.DESCRIPTION) + if description_node: + for zrodla in root.xpath('//p[@class="source"]'): + zrodla.getparent().remove(zrodla) + comments = tostring(description_node[0], method="html") + comments = sanitize_comments_html(comments) + + else: + # try + description_node = root.xpath('//meta[@property="og:description"]/@content') + if description_node: + comments = description_node[0] + comments = sanitize_comments_html(comments) + + pages = self._parse_from_summary(root=root, attribute_name="numberOfPages") + if pages: + comments += f'

Książka ma {pages} stron(y).

' + + first_publish_date = self._parse_date(root=root) + if first_publish_date: + comments += f'

Data pierwszego wydania: {first_publish_date.strftime("%d.%m.%Y")}

' + + first_publish_date_pl = self._parse_date(root=root, xpath="first_publish_pl") + if first_publish_date_pl: + comments += f'

Data pierwszego wydania w Polsce: {first_publish_date_pl.strftime("%d.%m.%Y")}

' + + return comments + + def get_title_tokens(self, title, strip_joiners=True, strip_subtitle=False): + """ + Taken from https://github.com/kovidgoyal/calibre/blob/master/src/calibre/ebooks/metadata/sources/base.py. + """ + # strip sub-titles + if strip_subtitle: + subtitle = re.compile(r"([\(\[\{].*?[\)\]\}]|[/:\\].*$)") + if len(subtitle.sub("", title)) > 1: + title = subtitle.sub("", title) + + title_patterns = [ + (re.compile(pat, re.IGNORECASE), repl) + for pat, repl in [ + # Remove things like: (2010) (Omnibus) etc. + ( + r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|audiobook|audio\scd|paperback|turtleback|mass\s*market|edition|ed\.)[\])}]", + "", + ), + # Remove any strings that contain the substring edition inside + # parentheses + (r"(?i)[({\[].*?(edition|ed.).*?[\]})]", ""), + # Remove commas used a separators in numbers + (r"(\d+),(\d+)", r"\1\2"), + # Remove hyphens only if they have whitespace before them + (r"(\s-)", " "), + # Replace other special chars with a space + (r"""[:,;!@$%^&*(){}.`~"\s\[\]/]《》「」“”""", " "), + ] + ] + + for pat, repl in title_patterns: + title = pat.sub(repl, title) + + tokens = title.split() + for token in tokens: + token = token.strip().strip('"').strip("'") + if token and ( + not strip_joiners or token.lower() not in ("a", "and", "the", "&") + ): + yield token diff --git a/cps/static/js/get_meta.js b/cps/static/js/get_meta.js index 51ab740d..a8643065 100644 --- a/cps/static/js/get_meta.js +++ b/cps/static/js/get_meta.js @@ -26,19 +26,26 @@ $(function () { ) }; + function getUniqueValues(attribute_name, book){ + var presentArray = $.map($("#"+attribute_name).val().split(","), $.trim); + if ( presentArray.length === 1 && presentArray[0] === "") { + presentArray = []; + } + $.each(book[attribute_name], function(i, el) { + if ($.inArray(el, presentArray) === -1) presentArray.push(el); + }); + return presentArray + } + function populateForm (book) { tinymce.get("description").setContent(book.description); - var uniqueTags = $.map($("#tags").val().split(","), $.trim); - if ( uniqueTags.length == 1 && uniqueTags[0] == "") { - uniqueTags = []; - } - $.each(book.tags, function(i, el) { - if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el); - }); + var uniqueTags = getUniqueValues('tags', book) + var uniqueLanguages = getUniqueValues('languages', book) var ampSeparatedAuthors = (book.authors || []).join(" & "); $("#bookAuthor").val(ampSeparatedAuthors); $("#book_title").val(book.title); $("#tags").val(uniqueTags.join(", ")); + $("#languages").val(uniqueLanguages.join(", ")); $("#rating").data("rating").setValue(Math.round(book.rating)); if(book.cover !== null){ $(".cover img").attr("src", book.cover); @@ -48,7 +55,32 @@ $(function () { $("#publisher").val(book.publisher); if (typeof book.series !== "undefined") { $("#series").val(book.series); + $("#series_index").val(book.series_index); } + if (typeof book.identifiers !== "undefined") { + populateIdentifiers(book.identifiers) + } + } + + function populateIdentifiers(identifiers){ + for (const property in identifiers) { + console.log(`${property}: ${identifiers[property]}`); + if ($('input[name="identifier-type-'+property+'"]').length) { + $('input[name="identifier-val-'+property+'"]').val(identifiers[property]) + } + else { + addIdentifier(property, identifiers[property]) + } + } + } + + function addIdentifier(name, value){ + var line = ''; + line += ''; + line += ''; + line += ''+_("Remove")+''; + line += ''; + $("#identifier-table").append(line); } function doSearch (keyword) { diff --git a/requirements.txt b/requirements.txt index 1db961fe..d1f58a8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,5 @@ Wand>=0.4.4,<0.7.0 unidecode>=0.04.19,<1.3.0 lxml>=3.8.0,<4.7.0 flask-wtf>=0.14.2,<1.1.0 +markdown2==2.4.2 +html2text==2020.1.16 From d55626d4452bf15849b3ead2266a2ca89f8d9c8d Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 13 Dec 2021 01:23:03 +0100 Subject: [PATCH 023/220] refactor and cleaning --- cps/metadata_provider/comicvine.py | 4 +- cps/metadata_provider/google.py | 12 +- cps/metadata_provider/lubimyczytac.py | 468 +++++++++++++------------- cps/services/Metadata.py | 33 +- requirements.txt | 1 + 5 files changed, 278 insertions(+), 240 deletions(-) diff --git a/cps/metadata_provider/comicvine.py b/cps/metadata_provider/comicvine.py index 8f496608..195e68f8 100644 --- a/cps/metadata_provider/comicvine.py +++ b/cps/metadata_provider/comicvine.py @@ -26,7 +26,7 @@ class ComicVine(Metadata): __name__ = "ComicVine" __id__ = "comicvine" - def search(self, query, __): + def search(self, query, generic_cover=""): val = list() apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6" if self.active: @@ -52,7 +52,7 @@ class ComicVine(Metadata): v['tags'] = ["Comics", seriesTitle] v['rating'] = 0 v['series'] = seriesTitle - v['cover'] = r['image'].get('original_url') + v['cover'] = r['image'].get('original_url', generic_cover) v['source'] = { "id": self.__id__, "description": "ComicVine Books", diff --git a/cps/metadata_provider/google.py b/cps/metadata_provider/google.py index f3d02d8e..8be8ad74 100644 --- a/cps/metadata_provider/google.py +++ b/cps/metadata_provider/google.py @@ -17,19 +17,20 @@ # along with this program. If not, see . # Google Books api document: https://developers.google.com/books/docs/v1/using - - import requests + from cps.services.Metadata import Metadata + class Google(Metadata): __name__ = "Google" __id__ = "google" + BASE_URL = "https://www.googleapis.com/books/v1/volumes?q=" - def search(self, query, __): + def search(self, query, generic_cover=""): if self.active: val = list() - result = requests.get("https://www.googleapis.com/books/v1/volumes?q="+query.replace(" ","+")) + result = requests.get(Google.BASE_URL + query.replace(" ","+")) for r in result.json()['items']: v = dict() v['id'] = r['id'] @@ -43,7 +44,8 @@ class Google(Metadata): if r['volumeInfo'].get('imageLinks'): v['cover'] = r['volumeInfo']['imageLinks']['thumbnail'].replace("http://", "https://") else: - v['cover'] = "/../../../static/generic_cover.jpg" + # v['cover'] = "/../../../static/generic_cover.jpg" + v['cover'] = generic_cover v['source'] = { "id": self.__id__, "description": "Google Books", diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py index aab50bb6..ee66d1b4 100644 --- a/cps/metadata_provider/lubimyczytac.py +++ b/cps/metadata_provider/lubimyczytac.py @@ -15,47 +15,47 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import datetime import json import re -from typing import Dict, List +from typing import Dict, Generator, List, Optional, Tuple, Union from urllib.parse import quote import requests -from cps.services.Metadata import Metadata -from lxml.html import fromstring, tostring +from dateutil import parser +from html2text import HTML2Text +from lxml.html import HtmlElement, fromstring, tostring +from markdown2 import Markdown + +from cps.services.Metadata import MetaRecord, Metadata + +SYMBOLS_TO_TRANSLATE = ( + "öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ", + "oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ", +) +SYMBOL_TRANSLATION_MAP = dict( + [(ord(a), ord(b)) for (a, b) in zip(*SYMBOLS_TO_TRANSLATE)] +) -def get_int_or_float(v): - number_as_float = float(v) +def get_int_or_float(value: str) -> Union[int, float]: + number_as_float = float(value) number_as_int = int(number_as_float) return number_as_int if number_as_float == number_as_int else number_as_float -def strip_accents(s): - if s is None: - return s - else: - symbols = ( - "öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ", - "oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ", - ) - tr = dict([(ord(a), ord(b)) for (a, b) in zip(*symbols)]) - return s.translate(tr) # .lower() +def strip_accents(s: Optional[str]) -> Optional[str]: + return s.translate(SYMBOL_TRANSLATION_MAP) if s is not None else s -def sanitize_comments_html(html): - from markdown2 import Markdown - +def sanitize_comments_html(html: str) -> str: text = html2text(html) md = Markdown() html = md.convert(text) return html -def html2text(html): - from html2text import HTML2Text - import re - +def html2text(html: str) -> str: # replace tags with as becomes emphasis in html2text if isinstance(html, bytes): html = html.decode("utf-8") @@ -92,26 +92,36 @@ class LubimyCzytac(Metadata): PUBLISHER = f"{CONTAINER}//dt[contains(text(),'Wydawnictwo:')]{SIBLINGS}/a/text()" LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()" DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']" - SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]" + SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()" DETAILS = "//div[@id='book-details']" 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()" + RATING = "//meta[@property='books:rating:value']/@content" COVER = "//meta[@property='og:image']/@content" + ISBN = "//meta[@property='books:isbn']/@content" + META_TITLE = "//meta[@property='og:description']/@content" SUMMARY = "//script[@type='application/ld+json']//text()" - def search(self, query, __): + def search(self, query: str, generic_cover: str = "") -> Optional[List]: if self.active: result = requests.get(self._prepare_query(title=query)) root = fromstring(result.text) - matches = self._parse_search_results(root=root) + lc_parser = LubimyCzytacParser(root=root, metadata=self) + matches = lc_parser.parse_search_results() if matches: - for ind, match in enumerate(matches): - matches[ind] = self._parse_single_book(match=match) + final_matches = [] + for match in matches: + response = requests.get(match.get("url")) + match = lc_parser.parse_single_book( + match=match, response=response, generic_cover=generic_cover + ) + final_matches.append(match) + return final_matches return matches def _prepare_query(self, title: str) -> str: @@ -128,9 +138,7 @@ class LubimyCzytac(Metadata): token for token in title.lower().split(" ") if len(token) > 1 ] else: - title_tokens = list( - self.get_title_tokens(title, strip_joiners=False, strip_subtitle=True) - ) + title_tokens = list(self.get_title_tokens(title, strip_joiners=False)) if title_tokens: tokens = [quote(t.encode("utf-8")) for t in title_tokens] query = query + "%20".join(tokens) @@ -138,215 +146,21 @@ class LubimyCzytac(Metadata): return "" return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}" - def _parse_search_results(self, root) -> List[Dict]: - matches = [] - results = root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH) - for result in results: - title = result.xpath( - f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.TITLE_TEXT_PATH}" - ) - book_url = result.xpath( - f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" f"{LubimyCzytac.URL_PATH}" - ) - authors = result.xpath( - f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.AUTHORS_PATH}" - ) - - if not title or not book_url or not authors: - continue - title = title[0].strip() - book_url = LubimyCzytac.BASE_URL + book_url[0] - book_id = book_url.replace(f"{LubimyCzytac.BASE_URL}/ksiazka/", "").split( - "/" - )[0] - matches.append( - {"id": book_id, "title": title, "authors": authors, "url": book_url} - ) - return matches - - def _parse_single_book(self, match: Dict) -> Dict: - url = match.get("url") - result = requests.get(url) - root = fromstring(result.text) - match["series"], match["series_index"] = self._parse_series(root=root) - match["tags"] = self._parse_tags(root=root) - match["publisher"] = self._parse_publisher(root=root) - match["publishedDate"] = self._parse_from_summary( - root=root, attribute_name="datePublished" - ) - match["rating"] = self._parse_rating(root=root) - match["description"] = self._parse_description(root=root) - match["cover"] = self._parse_cover(root=root) - match["source"] = { - "id": self.__id__, - "description": self.__name__, - "link": LubimyCzytac.BASE_URL, - } - match['languages'] = self._parse_languages(root=root) - match["identifiers"] = { - "isbn": self._parse_isbn(root=root), - "lubimyczytac": match["id"], - } - return match - - def _parse_cover(self, root): - imgcol_node = root.xpath('//meta[@property="og:image"]/@content') - if imgcol_node: - img_url = imgcol_node[0] - return img_url - - def _parse_publisher(self, root): - publisher = root.xpath(LubimyCzytac.PUBLISHER) - if publisher: - return publisher[0] - else: - return None - - def _parse_languages(self, root): - lang = root.xpath(LubimyCzytac.LANGUAGES) - languages = list() - if lang: - lang = lang[0].strip() - if "polski" in lang: - languages.append("Polish") - if "angielski" in lang: - languages.append("English") - if not languages: - return ['Polish'] - return languages - - def _parse_series(self, root): - try: - series_node = root.xpath(LubimyCzytac.SERIES) - if series_node: - series_lst = root.xpath(f"{LubimyCzytac.SERIES}/text()") - if series_lst: - series_txt = series_lst - else: - series_txt = None - else: - return (None, None) - - if series_txt: - ser_string = [series_txt[0].replace("\n", "").strip()] - ser_nazwa = ser_string - for ser in ser_string: - if "tom " in ser: - ser_info = ser.split(" (tom ", 1) - ser_nazwa = ser.split(" (tom ")[0] - break - - if ser_info: - series_index_unicode = ser_info[1] - series_index_string = str( - series_index_unicode.replace(" ", "").replace(")", "") - ) - # Sprawdzamy, czy cykl nie jest kompletem/pakietem tomów, np. 1-3 - if "-" in series_index_string: - series_index_string_temp = series_index_string.split("-", 1) - series_index_string = series_index_string_temp[0] - if series_index_string.replace(".", "").isdigit() is True: - series_index = get_int_or_float(series_index_string) - else: - series_index = 0 - else: - series_index = 0 - series = ser_nazwa - return (series, series_index) - except: - return (None, None) - - def _parse_tags(self, root): - tags = None - try: - tags_from_genre = root.xpath(LubimyCzytac.TAGS) - if tags_from_genre: - tags = tags_from_genre - tags = [w.replace(", itd.", " itd.") for w in tags] - return tags - else: - return None - except: - return tags - - def _parse_from_summary(self, root, attribute_name: str) -> str: - data = json.loads(root.xpath(LubimyCzytac.SUMMARY)[0]) - value = data.get(attribute_name) - return value.strip() if value is not None else value - - def _parse_rating(self, root): - rating_node = root.xpath(LubimyCzytac.RATING) - if rating_node: - rating_value = round(float((rating_node[0]).replace(",", ".")) / 2) - return rating_value - return None - - def _parse_date(self, root, xpath="first_publish"): - options = { - "first_publish": LubimyCzytac.FIRST_PUBLISH_DATE, - "first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL, - } - path = options.get(xpath) - from dateutil import parser - - data = root.xpath(path) - if data: - first_pub_date = data[0].strip() - return parser.parse(first_pub_date) - return None - - def _parse_isbn(self, root): - isbn_node = root.xpath('//meta[@property="books:isbn"]/@content')[0] - return isbn_node - - def _parse_description(self, root): - comments = "" - description_node = root.xpath(LubimyCzytac.DESCRIPTION) - if description_node: - for zrodla in root.xpath('//p[@class="source"]'): - zrodla.getparent().remove(zrodla) - comments = tostring(description_node[0], method="html") - comments = sanitize_comments_html(comments) - - else: - # try - description_node = root.xpath('//meta[@property="og:description"]/@content') - if description_node: - comments = description_node[0] - comments = sanitize_comments_html(comments) - - pages = self._parse_from_summary(root=root, attribute_name="numberOfPages") - if pages: - comments += f'

Książka ma {pages} stron(y).

' - - first_publish_date = self._parse_date(root=root) - if first_publish_date: - comments += f'

Data pierwszego wydania: {first_publish_date.strftime("%d.%m.%Y")}

' - - first_publish_date_pl = self._parse_date(root=root, xpath="first_publish_pl") - if first_publish_date_pl: - comments += f'

Data pierwszego wydania w Polsce: {first_publish_date_pl.strftime("%d.%m.%Y")}

' - - return comments - - def get_title_tokens(self, title, strip_joiners=True, strip_subtitle=False): + @staticmethod + def get_title_tokens( + title: str, strip_joiners: bool = True + ) -> Generator[str, None, None]: """ - Taken from https://github.com/kovidgoyal/calibre/blob/master/src/calibre/ebooks/metadata/sources/base.py. + Taken from calibre source code """ - # strip sub-titles - if strip_subtitle: - subtitle = re.compile(r"([\(\[\{].*?[\)\]\}]|[/:\\].*$)") - if len(subtitle.sub("", title)) > 1: - title = subtitle.sub("", title) - title_patterns = [ (re.compile(pat, re.IGNORECASE), repl) for pat, repl in [ # Remove things like: (2010) (Omnibus) etc. ( - r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|audiobook|audio\scd|paperback|turtleback|mass\s*market|edition|ed\.)[\])}]", + r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|" + r"audiobook|audio\scd|paperback|turtleback|" + r"mass\s*market|edition|ed\.)[\])}]", "", ), # Remove any strings that contain the substring edition inside @@ -371,3 +185,193 @@ class LubimyCzytac(Metadata): not strip_joiners or token.lower() not in ("a", "and", "the", "&") ): yield token + + +class LubimyCzytacParser: + PAGES_TEMPLATE = "

Książka ma {0} stron(y).

" + PUBLISH_DATE_TEMPLATE = "

Data pierwszego wydania: {0}

" + PUBLISH_DATE_PL_TEMPLATE = ( + "

Data pierwszego wydania w Polsce: {0}

" + ) + + def __init__(self, root: HtmlElement, metadata: Metadata) -> None: + self.root = root + self.metadata = metadata + + def parse_search_results(self) -> List[Dict]: + matches = [] + results = self.root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH) + for result in results: + title = self._parse_xpath_node( + root=result, + xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" + f"{LubimyCzytac.TITLE_TEXT_PATH}", + ) + + book_url = self._parse_xpath_node( + root=result, + xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" + f"{LubimyCzytac.URL_PATH}", + ) + authors = self._parse_xpath_node( + root=result, + xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" + f"{LubimyCzytac.AUTHORS_PATH}", + take_first=False, + ) + if not all([title, book_url, authors]): + continue + matches.append( + { + "id": book_url.replace(f"/ksiazka/", "").split("/")[0], + "title": title, + "authors": [strip_accents(author) for author in authors], + "url": LubimyCzytac.BASE_URL + book_url, + } + ) + return matches + + def parse_single_book( + self, match: Dict, response, generic_cover: str + ) -> MetaRecord: + self.root = fromstring(response.text) + match["series"], match["series_index"] = self._parse_series() + match["tags"] = self._parse_tags() + match["publisher"] = self._parse_publisher() + match["publishedDate"] = self._parse_from_summary( + attribute_name="datePublished" + ) + match["rating"] = self._parse_rating() + match["description"] = self._parse_description() + match["cover"] = self._parse_cover(generic_cover=generic_cover) + match["source"] = { + "id": self.metadata.__id__, + "description": self.metadata.__name__, + "link": LubimyCzytac.BASE_URL, + } + match["languages"] = self._parse_languages() + match["identifiers"] = { + "isbn": self._parse_isbn(), + "lubimyczytac": match["id"], + } + return match + + def _parse_xpath_node( + self, + xpath: str, + root: HtmlElement = None, + take_first: bool = True, + strip_element: bool = True, + ) -> Optional[Union[str, List[str]]]: + root = root if root is not None else self.root + node = root.xpath(xpath) + if not node: + return None + return ( + (node[0].strip() if strip_element else node[0]) + if take_first + else [x.strip() for x in node] + ) + + def _parse_cover(self, generic_cover) -> Optional[str]: + return ( + self._parse_xpath_node(xpath=LubimyCzytac.COVER, take_first=True) + or generic_cover + ) + + def _parse_publisher(self) -> Optional[str]: + return self._parse_xpath_node(xpath=LubimyCzytac.PUBLISHER, take_first=True) + + def _parse_languages(self) -> List[str]: + languages = list() + lang = self._parse_xpath_node(xpath=LubimyCzytac.LANGUAGES, take_first=True) + if lang: + if "polski" in lang: + languages.append("Polish") + if "angielski" in lang: + languages.append("English") + return languages + + def _parse_series(self) -> Tuple[Optional[str], Optional[Union[float, int]]]: + series_index = 0 + series = self._parse_xpath_node(xpath=LubimyCzytac.SERIES, take_first=True) + if series: + if "tom " in series: + series_name, series_info = series.split(" (tom ", 1) + series_info = series_info.replace(" ", "").replace(")", "") + # Check if book is not a bundle, i.e. chapter 1-3 + if "-" in series_info: + series_info = series_info.split("-", 1)[0] + if series_info.replace(".", "").isdigit() is True: + series_index = get_int_or_float(series_info) + return series_name, series_index + return None, None + + def _parse_tags(self) -> List[str]: + tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False) + return [ + strip_accents(w.replace(", itd.", " itd.")) + for w in tags + if isinstance(w, str) + ] + + def _parse_from_summary(self, attribute_name: str) -> Optional[str]: + value = None + summary_text = self._parse_xpath_node(xpath=LubimyCzytac.SUMMARY) + if summary_text: + data = json.loads(summary_text) + value = data.get(attribute_name) + return value.strip() if value is not None else value + + def _parse_rating(self) -> Optional[str]: + rating = self._parse_xpath_node(xpath=LubimyCzytac.RATING) + return round(float(rating.replace(",", ".")) / 2) if rating else rating + + def _parse_date(self, xpath="first_publish") -> Optional[datetime.datetime]: + options = { + "first_publish": LubimyCzytac.FIRST_PUBLISH_DATE, + "first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL, + } + date = self._parse_xpath_node(xpath=options.get(xpath)) + return parser.parse(date) if date else None + + def _parse_isbn(self) -> Optional[str]: + return self._parse_xpath_node(xpath=LubimyCzytac.ISBN) + + def _parse_description(self) -> str: + description = "" + description_node = self._parse_xpath_node( + xpath=LubimyCzytac.DESCRIPTION, strip_element=False + ) + if description_node is not None: + for source in self.root.xpath('//p[@class="source"]'): + source.getparent().remove(source) + description = tostring(description_node, method="html") + description = sanitize_comments_html(description) + + else: + description_node = self._parse_xpath_node(xpath=LubimyCzytac.META_TITLE) + if description_node is not None: + description = description_node + description = sanitize_comments_html(description) + description = self._add_extra_info_to_description(description=description) + return description + + def _add_extra_info_to_description(self, description: str) -> str: + pages = self._parse_from_summary(attribute_name="numberOfPages") + if pages: + description += LubimyCzytacParser.PAGES_TEMPLATE.format(pages) + + first_publish_date = self._parse_date() + if first_publish_date: + description += LubimyCzytacParser.PUBLISH_DATE_TEMPLATE.format( + first_publish_date.strftime("%d.%m.%Y") + ) + + first_publish_date_pl = self._parse_date(xpath="first_publish_pl") + if first_publish_date_pl: + description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format( + first_publish_date_pl.strftime("%d.%m.%Y") + ) + + return description diff --git a/cps/services/Metadata.py b/cps/services/Metadata.py index d6e4e7d5..17a9e38e 100644 --- a/cps/services/Metadata.py +++ b/cps/services/Metadata.py @@ -15,13 +15,44 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import abc +from typing import Dict, List, Optional, TypedDict, Union -class Metadata(): +class Metadata: __name__ = "Generic" + __id__ = "generic" def __init__(self): self.active = True def set_status(self, state): self.active = state + + @abc.abstractmethod + def search(self, query: str, generic_cover: str): + pass + + +class MetaSourceInfo(TypedDict): + id: str + description: str + link: str + + +class MetaRecord(TypedDict): + id: Union[str, int] + title: str + authors: List[str] + url: str + cover: str + series: Optional[str] + series_index: Optional[Union[int, float]] + tags: Optional[List[str]] + publisher: Optional[str] + publishedDate: Optional[str] + rating: Optional[int] + description: Optional[str] + source: MetaSourceInfo + languages: Optional[List[str]] + identifiers: Dict[str, Union[str, int]] diff --git a/requirements.txt b/requirements.txt index d1f58a8d..d09c2157 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ lxml>=3.8.0,<4.7.0 flask-wtf>=0.14.2,<1.1.0 markdown2==2.4.2 html2text==2020.1.16 +python-dateutil==2.8.2 From 362fdc57166e778dd8f08f326adbaaa6b0bf3d5d Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 13 Dec 2021 02:14:53 +0100 Subject: [PATCH 024/220] run lubimyczytac detail pages in threadpool --- cps/metadata_provider/lubimyczytac.py | 60 ++++---------------- cps/search_metadata.py | 81 +++++++++++++++------------ cps/services/Metadata.py | 45 ++++++++++++++- 3 files changed, 99 insertions(+), 87 deletions(-) diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py index ee66d1b4..1d4e18e1 100644 --- a/cps/metadata_provider/lubimyczytac.py +++ b/cps/metadata_provider/lubimyczytac.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- - # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # Copyright (C) 2021 OzzieIsaacs # @@ -18,7 +17,8 @@ import datetime import json import re -from typing import Dict, Generator, List, Optional, Tuple, Union +from multiprocessing.pool import ThreadPool +from typing import Dict, List, Optional, Tuple, Union from urllib.parse import quote import requests @@ -114,13 +114,14 @@ class LubimyCzytac(Metadata): lc_parser = LubimyCzytacParser(root=root, metadata=self) matches = lc_parser.parse_search_results() if matches: - final_matches = [] - for match in matches: - response = requests.get(match.get("url")) - match = lc_parser.parse_single_book( - match=match, response=response, generic_cover=generic_cover + with ThreadPool(processes=10) as pool: + final_matches = pool.starmap( + lc_parser.parse_single_book, + [ + (match, generic_cover) + for match in matches + ], ) - final_matches.append(match) return final_matches return matches @@ -146,46 +147,6 @@ class LubimyCzytac(Metadata): return "" return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}" - @staticmethod - def get_title_tokens( - title: str, strip_joiners: bool = True - ) -> Generator[str, None, None]: - """ - Taken from calibre source code - """ - title_patterns = [ - (re.compile(pat, re.IGNORECASE), repl) - for pat, repl in [ - # Remove things like: (2010) (Omnibus) etc. - ( - r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|" - r"audiobook|audio\scd|paperback|turtleback|" - r"mass\s*market|edition|ed\.)[\])}]", - "", - ), - # Remove any strings that contain the substring edition inside - # parentheses - (r"(?i)[({\[].*?(edition|ed.).*?[\]})]", ""), - # Remove commas used a separators in numbers - (r"(\d+),(\d+)", r"\1\2"), - # Remove hyphens only if they have whitespace before them - (r"(\s-)", " "), - # Replace other special chars with a space - (r"""[:,;!@$%^&*(){}.`~"\s\[\]/]《》「」“”""", " "), - ] - ] - - for pat, repl in title_patterns: - title = pat.sub(repl, title) - - tokens = title.split() - for token in tokens: - token = token.strip().strip('"').strip("'") - if token and ( - not strip_joiners or token.lower() not in ("a", "and", "the", "&") - ): - yield token - class LubimyCzytacParser: PAGES_TEMPLATE = "

Książka ma {0} stron(y).

" @@ -232,8 +193,9 @@ class LubimyCzytacParser: return matches def parse_single_book( - self, match: Dict, response, generic_cover: str + self, match: Dict, generic_cover: str ) -> MetaRecord: + response = requests.get(match.get("url")) self.root = fromstring(response.text) match["series"], match["series_index"] = self._parse_series() match["tags"] = self._parse_tags() diff --git a/cps/search_metadata.py b/cps/search_metadata.py index e837fe21..7d9b6e05 100644 --- a/cps/search_metadata.py +++ b/cps/search_metadata.py @@ -16,25 +16,23 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os -import json -import importlib -import sys -import inspect -import datetime import concurrent.futures +import importlib +import inspect +import json +import os +import sys -from flask import Blueprint, request, Response, url_for +from flask import Blueprint, Response, request, url_for from flask_login import current_user from flask_login import login_required +from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.orm.attributes import flag_modified -from sqlalchemy.exc import OperationalError, InvalidRequestError -from . import constants, logger, ub from cps.services.Metadata import Metadata +from . import constants, logger, ub - -meta = Blueprint('metadata', __name__) +meta = Blueprint("metadata", __name__) log = logger.create() @@ -42,7 +40,7 @@ new_list = list() meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider") modules = os.listdir(os.path.join(constants.BASE_DIR, "cps", "metadata_provider")) for f in modules: - if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith('__init__.py'): + if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith("__init__.py"): a = os.path.basename(f)[:-3] try: importlib.import_module("cps.metadata_provider." + a) @@ -51,34 +49,46 @@ for f in modules: log.error("Import error for metadata source: {}".format(a)) pass + def list_classes(provider_list): classes = list() for element in provider_list: - for name, obj in inspect.getmembers(sys.modules["cps.metadata_provider." + element]): - if inspect.isclass(obj) and name != "Metadata" and issubclass(obj, Metadata): + for name, obj in inspect.getmembers( + sys.modules["cps.metadata_provider." + element] + ): + if ( + inspect.isclass(obj) + and name != "Metadata" + and issubclass(obj, Metadata) + ): classes.append(obj()) return classes + cl = list_classes(new_list) + @meta.route("/metadata/provider") @login_required def metadata_provider(): - active = current_user.view_settings.get('metadata', {}) + active = current_user.view_settings.get("metadata", {}) provider = list() for c in cl: ac = active.get(c.__id__, True) - provider.append({"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}) - return Response(json.dumps(provider), mimetype='application/json') + provider.append( + {"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__} + ) + return Response(json.dumps(provider), mimetype="application/json") -@meta.route("/metadata/provider", methods=['POST']) -@meta.route("/metadata/provider/", methods=['POST']) + +@meta.route("/metadata/provider", methods=["POST"]) +@meta.route("/metadata/provider/", methods=["POST"]) @login_required def metadata_change_active_provider(prov_name): new_state = request.get_json() - active = current_user.view_settings.get('metadata', {}) - active[new_state['id']] = new_state['value'] - current_user.view_settings['metadata'] = active + active = current_user.view_settings.get("metadata", {}) + active[new_state["id"]] = new_state["value"] + current_user.view_settings["metadata"] = active try: try: flag_modified(current_user, "view_settings") @@ -91,27 +101,26 @@ def metadata_change_active_provider(prov_name): if "initial" in new_state and prov_name: for c in cl: if c.__id__ == prov_name: - data = c.search(new_state.get('query', "")) + data = c.search(new_state.get("query", "")) break - return Response(json.dumps(data), mimetype='application/json') + return Response(json.dumps(data), mimetype="application/json") return "" -@meta.route("/metadata/search", methods=['POST']) + +@meta.route("/metadata/search", methods=["POST"]) @login_required def metadata_search(): - query = request.form.to_dict().get('query') + query = request.form.to_dict().get("query") data = list() - active = current_user.view_settings.get('metadata', {}) + active = current_user.view_settings.get("metadata", {}) if query: - static_cover = url_for('static', filename='generic_cover.jpg') + static_cover = url_for("static", filename="generic_cover.jpg") with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: - meta = {executor.submit(c.search, query, static_cover): c for c in cl if active.get(c.__id__, True)} + meta = { + executor.submit(c.search, query, static_cover): c + for c in cl + if active.get(c.__id__, True) + } for future in concurrent.futures.as_completed(meta): data.extend(future.result()) - return Response(json.dumps(data), mimetype='application/json') - - - - - - + return Response(json.dumps(data), mimetype="application/json") diff --git a/cps/services/Metadata.py b/cps/services/Metadata.py index 17a9e38e..1464411a 100644 --- a/cps/services/Metadata.py +++ b/cps/services/Metadata.py @@ -16,7 +16,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import abc -from typing import Dict, List, Optional, TypedDict, Union +import re +from typing import Dict, Generator, List, Optional, TypedDict, Union class Metadata: @@ -30,9 +31,49 @@ class Metadata: self.active = state @abc.abstractmethod - def search(self, query: str, generic_cover: str): + def search(self, query: str, generic_cover: str = ""): pass + @staticmethod + def get_title_tokens( + title: str, strip_joiners: bool = True + ) -> Generator[str, None, None]: + """ + Taken from calibre source code + """ + title_patterns = [ + (re.compile(pat, re.IGNORECASE), repl) + for pat, repl in [ + # Remove things like: (2010) (Omnibus) etc. + ( + r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|" + r"audiobook|audio\scd|paperback|turtleback|" + r"mass\s*market|edition|ed\.)[\])}]", + "", + ), + # Remove any strings that contain the substring edition inside + # parentheses + (r"(?i)[({\[].*?(edition|ed.).*?[\]})]", ""), + # Remove commas used a separators in numbers + (r"(\d+),(\d+)", r"\1\2"), + # Remove hyphens only if they have whitespace before them + (r"(\s-)", " "), + # Replace other special chars with a space + (r"""[:,;!@$%^&*(){}.`~"\s\[\]/]《》「」“”""", " "), + ] + ] + + for pat, repl in title_patterns: + title = pat.sub(repl, title) + + tokens = title.split() + for token in tokens: + token = token.strip().strip('"').strip("'") + if token and ( + not strip_joiners or token.lower() not in ("a", "and", "the", "&") + ): + yield token + class MetaSourceInfo(TypedDict): id: str From d64589914fdf69ce78111c2a5d29a967f7a881e3 Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 13 Dec 2021 15:14:19 +0100 Subject: [PATCH 025/220] add series, languages and isbn to google provider --- cps/metadata_provider/google.py | 104 +++++++++++++++++++------- cps/metadata_provider/lubimyczytac.py | 24 +++--- cps/search_metadata.py | 5 +- cps/services/Metadata.py | 52 ++++++------- 4 files changed, 119 insertions(+), 66 deletions(-) diff --git a/cps/metadata_provider/google.py b/cps/metadata_provider/google.py index 8be8ad74..1074fe3d 100644 --- a/cps/metadata_provider/google.py +++ b/cps/metadata_provider/google.py @@ -17,41 +17,93 @@ # along with this program. If not, see . # Google Books api document: https://developers.google.com/books/docs/v1/using +from typing import Dict, List, Optional +from urllib.parse import quote + import requests -from cps.services.Metadata import Metadata +from cps.isoLanguages import get_lang3, get_language_name +from cps.services.Metadata import MetaRecord, Metadata class Google(Metadata): __name__ = "Google" __id__ = "google" - BASE_URL = "https://www.googleapis.com/books/v1/volumes?q=" + DESCRIPTION = "Google Books" + META_URL = "https://books.google.com/" + BOOK_URL = "https://books.google.com/books?id=" + SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q=" + ISBN_TYPE = "ISBN_13" - def search(self, query, generic_cover=""): + def search( + self, query: str, generic_cover: str = "", locale: str = "en" + ) -> Optional[List[MetaRecord]]: if self.active: val = list() - result = requests.get(Google.BASE_URL + query.replace(" ","+")) - for r in result.json()['items']: - v = dict() - v['id'] = r['id'] - v['title'] = r['volumeInfo']['title'] - v['authors'] = r['volumeInfo'].get('authors', []) - v['description'] = r['volumeInfo'].get('description', "") - v['publisher'] = r['volumeInfo'].get('publisher', "") - v['publishedDate'] = r['volumeInfo'].get('publishedDate', "") - v['tags'] = r['volumeInfo'].get('categories', []) - v['rating'] = r['volumeInfo'].get('averageRating', 0) - if r['volumeInfo'].get('imageLinks'): - v['cover'] = r['volumeInfo']['imageLinks']['thumbnail'].replace("http://", "https://") - else: - # v['cover'] = "/../../../static/generic_cover.jpg" - v['cover'] = generic_cover - v['source'] = { - "id": self.__id__, - "description": "Google Books", - "link": "https://books.google.com/"} - v['url'] = "https://books.google.com/books?id=" + r['id'] - val.append(v) - return val + title_tokens = list(self.get_title_tokens(query, strip_joiners=False)) + if title_tokens: + tokens = [quote(t.encode("utf-8")) for t in title_tokens] + query = "+".join(tokens) + results = requests.get(Google.SEARCH_URL + query) + for result in results.json()["items"]: + val.append( + self._parse_search_result( + result=result, generic_cover=generic_cover, locale=locale + ) + ) + return val + def _parse_search_result( + self, result: Dict, generic_cover: str, locale: str + ) -> MetaRecord: + match = dict() + match["id"] = result["id"] + match["title"] = result["volumeInfo"]["title"] + match["authors"] = result["volumeInfo"].get("authors", []) + match["url"] = Google.BOOK_URL + result["id"] + match["cover"] = self._parse_cover(result=result, generic_cover=generic_cover) + match["description"] = result["volumeInfo"].get("description", "") + match["languages"] = self._parse_languages(result=result, locale=locale) + match["publisher"] = result["volumeInfo"].get("publisher", "") + match["publishedDate"] = result["volumeInfo"].get("publishedDate", "") + match["rating"] = result["volumeInfo"].get("averageRating", 0) + match["series"], match["series_index"] = "", 1 + match["tags"] = result["volumeInfo"].get("categories", []) + match["source"] = { + "id": self.__id__, + "description": Google.DESCRIPTION, + "link": Google.META_URL, + } + + match["identifiers"] = { + "google": match.get("id"), + } + match = self._parse_isbn(result=result, match=match) + return match + + @staticmethod + def _parse_isbn(result: Dict, match: Dict) -> Dict: + identifiers = result["volumeInfo"].get("industryIdentifiers", []) + for identifier in identifiers: + if identifier.get("type") == Google.ISBN_TYPE: + match["identifiers"]["isbn"] = identifier.get("identifier") + break + return match + + @staticmethod + def _parse_cover(result: Dict, generic_cover: str) -> str: + if result["volumeInfo"].get("imageLinks"): + cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"] + return cover_url.replace("http://", "https://") + return generic_cover + + @staticmethod + def _parse_languages(result: Dict, locale: str) -> List[str]: + language_iso2 = result.get("language", "") + languages = ( + [get_language_name(locale, get_lang3(language_iso2))] + if language_iso2 + else [] + ) + return languages diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py index 1d4e18e1..fd9ca4a7 100644 --- a/cps/metadata_provider/lubimyczytac.py +++ b/cps/metadata_provider/lubimyczytac.py @@ -107,7 +107,9 @@ class LubimyCzytac(Metadata): SUMMARY = "//script[@type='application/ld+json']//text()" - def search(self, query: str, generic_cover: str = "") -> Optional[List]: + def search( + self, query: str, generic_cover: str = "", locale: str = "en" + ) -> Optional[List[MetaRecord]]: if self.active: result = requests.get(self._prepare_query(title=query)) root = fromstring(result.text) @@ -117,10 +119,7 @@ class LubimyCzytac(Metadata): with ThreadPool(processes=10) as pool: final_matches = pool.starmap( lc_parser.parse_single_book, - [ - (match, generic_cover) - for match in matches - ], + [(match, generic_cover) for match in matches], ) return final_matches return matches @@ -192,26 +191,25 @@ class LubimyCzytacParser: ) return matches - def parse_single_book( - self, match: Dict, generic_cover: str - ) -> MetaRecord: + def parse_single_book(self, match: Dict, generic_cover: str) -> MetaRecord: response = requests.get(match.get("url")) self.root = fromstring(response.text) - match["series"], match["series_index"] = self._parse_series() - match["tags"] = self._parse_tags() + match["cover"] = self._parse_cover(generic_cover=generic_cover) + match["description"] = self._parse_description() + match["languages"] = self._parse_languages() match["publisher"] = self._parse_publisher() match["publishedDate"] = self._parse_from_summary( attribute_name="datePublished" ) match["rating"] = self._parse_rating() - match["description"] = self._parse_description() - match["cover"] = self._parse_cover(generic_cover=generic_cover) + match["series"], match["series_index"] = self._parse_series() + match["tags"] = self._parse_tags() + match["source"] = { "id": self.metadata.__id__, "description": self.metadata.__name__, "link": LubimyCzytac.BASE_URL, } - match["languages"] = self._parse_languages() match["identifiers"] = { "isbn": self._parse_isbn(), "lubimyczytac": match["id"], diff --git a/cps/search_metadata.py b/cps/search_metadata.py index 7d9b6e05..a128f9ac 100644 --- a/cps/search_metadata.py +++ b/cps/search_metadata.py @@ -30,7 +30,7 @@ from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.orm.attributes import flag_modified from cps.services.Metadata import Metadata -from . import constants, logger, ub +from . import constants, get_locale, logger, ub meta = Blueprint("metadata", __name__) @@ -113,11 +113,12 @@ def metadata_search(): query = request.form.to_dict().get("query") data = list() active = current_user.view_settings.get("metadata", {}) + locale = get_locale() if query: static_cover = url_for("static", filename="generic_cover.jpg") with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: meta = { - executor.submit(c.search, query, static_cover): c + executor.submit(c.search, query, static_cover, locale): c for c in cl if active.get(c.__id__, True) } diff --git a/cps/services/Metadata.py b/cps/services/Metadata.py index 1464411a..09fc70ce 100644 --- a/cps/services/Metadata.py +++ b/cps/services/Metadata.py @@ -20,6 +20,30 @@ import re from typing import Dict, Generator, List, Optional, TypedDict, Union +class MetaSourceInfo(TypedDict): + id: str + description: str + link: str + + +class MetaRecord(TypedDict): + id: Union[str, int] + title: str + authors: List[str] + url: str + cover: str + series: Optional[str] + series_index: Optional[Union[int, float]] + tags: Optional[List[str]] + publisher: Optional[str] + publishedDate: Optional[str] + rating: Optional[int] + description: Optional[str] + source: MetaSourceInfo + languages: Optional[List[str]] + identifiers: Dict[str, Union[str, int]] + + class Metadata: __name__ = "Generic" __id__ = "generic" @@ -31,7 +55,9 @@ class Metadata: self.active = state @abc.abstractmethod - def search(self, query: str, generic_cover: str = ""): + def search( + self, query: str, generic_cover: str = "", locale: str = "en" + ) -> Optional[List[MetaRecord]]: pass @staticmethod @@ -73,27 +99,3 @@ class Metadata: not strip_joiners or token.lower() not in ("a", "and", "the", "&") ): yield token - - -class MetaSourceInfo(TypedDict): - id: str - description: str - link: str - - -class MetaRecord(TypedDict): - id: Union[str, int] - title: str - authors: List[str] - url: str - cover: str - series: Optional[str] - series_index: Optional[Union[int, float]] - tags: Optional[List[str]] - publisher: Optional[str] - publishedDate: Optional[str] - rating: Optional[int] - description: Optional[str] - source: MetaSourceInfo - languages: Optional[List[str]] - identifiers: Dict[str, Union[str, int]] From 51bf35c2e41a16032e1250a8cac252195116a147 Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 13 Dec 2021 17:21:41 +0100 Subject: [PATCH 026/220] unify scholar --- cps/metadata_provider/comicvine.py | 87 ++++++++++++++++----------- cps/metadata_provider/google.py | 49 ++++++++------- cps/metadata_provider/lubimyczytac.py | 57 +++++++++--------- cps/metadata_provider/scholar.py | 66 +++++++++++--------- cps/search_metadata.py | 15 +++-- cps/services/Metadata.py | 32 ++++++---- optional-requirements.txt | 3 + requirements.txt | 3 - 8 files changed, 172 insertions(+), 140 deletions(-) diff --git a/cps/metadata_provider/comicvine.py b/cps/metadata_provider/comicvine.py index 195e68f8..56618d4b 100644 --- a/cps/metadata_provider/comicvine.py +++ b/cps/metadata_provider/comicvine.py @@ -17,49 +17,68 @@ # along with this program. If not, see . # ComicVine api document: https://comicvine.gamespot.com/api/documentation +from typing import Dict, List, Optional +from urllib.parse import quote import requests -from cps.services.Metadata import Metadata +from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata class ComicVine(Metadata): __name__ = "ComicVine" __id__ = "comicvine" + DESCRIPTION = "ComicVine Books" + META_URL = "https://comicvine.gamespot.com/" + API_KEY = "57558043c53943d5d1e96a9ad425b0eb85532ee6" + BASE_URL = ( + f"https://comicvine.gamespot.com/api/search?api_key={API_KEY}" + f"&resources=issue&query=" + ) + QUERY_PARAMS = "&sort=name:desc&format=json" + HEADERS = {"User-Agent": "Not Evil Browser"} - def search(self, query, generic_cover=""): + def search( + self, query: str, generic_cover: str = "", locale: str = "en" + ) -> Optional[List[MetaRecord]]: val = list() - apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6" if self.active: - headers = { - 'User-Agent': 'Not Evil Browser' - } - - result = requests.get("https://comicvine.gamespot.com/api/search?api_key=" - + apikey + "&resources=issue&query=" + query + "&sort=name:desc&format=json", headers=headers) - for r in result.json()['results']: - seriesTitle = r['volume'].get('name', "") - if r.get('store_date'): - dateFomers = r.get('store_date') - else: - dateFomers = r.get('date_added') - v = dict() - v['id'] = r['id'] - v['title'] = seriesTitle + " #" + r.get('issue_number', "0") + " - " + ( r.get('name', "") or "") - v['authors'] = r.get('authors', []) - v['description'] = r.get('description', "") - v['publisher'] = "" - v['publishedDate'] = dateFomers - v['tags'] = ["Comics", seriesTitle] - v['rating'] = 0 - v['series'] = seriesTitle - v['cover'] = r['image'].get('original_url', generic_cover) - v['source'] = { - "id": self.__id__, - "description": "ComicVine Books", - "link": "https://comicvine.gamespot.com/" - } - v['url'] = r.get('site_detail_url', "") - val.append(v) + title_tokens = list(self.get_title_tokens(query, strip_joiners=False)) + if title_tokens: + tokens = [quote(t.encode("utf-8")) for t in title_tokens] + query = "%20".join(tokens) + result = requests.get( + f"{ComicVine.BASE_URL}{query}{ComicVine.QUERY_PARAMS}", + headers=ComicVine.HEADERS, + ) + for result in result.json()["results"]: + match = self._parse_search_result( + result=result, generic_cover=generic_cover, locale=locale + ) + val.append(match) return val - + def _parse_search_result( + self, result: Dict, generic_cover: str, locale: str + ) -> MetaRecord: + series = result["volume"].get("name", "") + series_index = result.get("issue_number", 0) + issue_name = result.get("name", "") + match = MetaRecord( + id=result["id"], + title=f"{series}#{series_index} - {issue_name}", + authors=result.get("authors", []), + url=result.get("site_detail_url", ""), + source=MetaSourceInfo( + id=self.__id__, + description=ComicVine.DESCRIPTION, + link=ComicVine.META_URL, + ), + series=series, + ) + match.cover = result["image"].get("original_url", generic_cover) + match.description = result.get("description", "") + match.publishedDate = result.get("store_date", result.get("date_added")) + match.series_index = series_index + match.tags = ["Comics", series] + match.identifiers = {"comicvine": match.id} + return match diff --git a/cps/metadata_provider/google.py b/cps/metadata_provider/google.py index 1074fe3d..5ac3e7ee 100644 --- a/cps/metadata_provider/google.py +++ b/cps/metadata_provider/google.py @@ -23,7 +23,7 @@ from urllib.parse import quote import requests from cps.isoLanguages import get_lang3, get_language_name -from cps.services.Metadata import MetaRecord, Metadata +from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata class Google(Metadata): @@ -56,38 +56,37 @@ class Google(Metadata): def _parse_search_result( self, result: Dict, generic_cover: str, locale: str ) -> MetaRecord: - match = dict() - match["id"] = result["id"] - match["title"] = result["volumeInfo"]["title"] - match["authors"] = result["volumeInfo"].get("authors", []) - match["url"] = Google.BOOK_URL + result["id"] - match["cover"] = self._parse_cover(result=result, generic_cover=generic_cover) - match["description"] = result["volumeInfo"].get("description", "") - match["languages"] = self._parse_languages(result=result, locale=locale) - match["publisher"] = result["volumeInfo"].get("publisher", "") - match["publishedDate"] = result["volumeInfo"].get("publishedDate", "") - match["rating"] = result["volumeInfo"].get("averageRating", 0) - match["series"], match["series_index"] = "", 1 - match["tags"] = result["volumeInfo"].get("categories", []) + match = MetaRecord( + id=result["id"], + title=result["volumeInfo"]["title"], + authors=result["volumeInfo"].get("authors", []), + url=Google.BOOK_URL + result["id"], + source=MetaSourceInfo( + id=self.__id__, + description=Google.DESCRIPTION, + link=Google.META_URL, + ), + ) - match["source"] = { - "id": self.__id__, - "description": Google.DESCRIPTION, - "link": Google.META_URL, - } + match.cover = self._parse_cover(result=result, generic_cover=generic_cover) + match.description = result["volumeInfo"].get("description", "") + match.languages = self._parse_languages(result=result, locale=locale) + match.publisher = result["volumeInfo"].get("publisher", "") + match.publishedDate = result["volumeInfo"].get("publishedDate", "") + match.rating = result["volumeInfo"].get("averageRating", 0) + match.series, match.series_index = "", 1 + match.tags = result["volumeInfo"].get("categories", []) - match["identifiers"] = { - "google": match.get("id"), - } + match.identifiers = {"google": match.id} match = self._parse_isbn(result=result, match=match) return match @staticmethod - def _parse_isbn(result: Dict, match: Dict) -> Dict: + def _parse_isbn(result: Dict, match: MetaRecord) -> MetaRecord: identifiers = result["volumeInfo"].get("industryIdentifiers", []) for identifier in identifiers: if identifier.get("type") == Google.ISBN_TYPE: - match["identifiers"]["isbn"] = identifier.get("identifier") + match.identifiers["isbn"] = identifier.get("identifier") break return match @@ -100,7 +99,7 @@ class Google(Metadata): @staticmethod def _parse_languages(result: Dict, locale: str) -> List[str]: - language_iso2 = result.get("language", "") + language_iso2 = result["volumeInfo"].get("language", "") languages = ( [get_language_name(locale, get_lang3(language_iso2))] if language_iso2 diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py index fd9ca4a7..4f6aca1e 100644 --- a/cps/metadata_provider/lubimyczytac.py +++ b/cps/metadata_provider/lubimyczytac.py @@ -27,7 +27,7 @@ from html2text import HTML2Text from lxml.html import HtmlElement, fromstring, tostring from markdown2 import Markdown -from cps.services.Metadata import MetaRecord, Metadata +from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata SYMBOLS_TO_TRANSLATE = ( "öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ", @@ -158,61 +158,60 @@ class LubimyCzytacParser: self.root = root self.metadata = metadata - def parse_search_results(self) -> List[Dict]: + def parse_search_results(self) -> List[MetaRecord]: matches = [] results = self.root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH) for result in results: title = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.TITLE_TEXT_PATH}", + f"{LubimyCzytac.TITLE_TEXT_PATH}", ) book_url = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.URL_PATH}", + f"{LubimyCzytac.URL_PATH}", ) authors = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.AUTHORS_PATH}", + f"{LubimyCzytac.AUTHORS_PATH}", take_first=False, ) if not all([title, book_url, authors]): continue matches.append( - { - "id": book_url.replace(f"/ksiazka/", "").split("/")[0], - "title": title, - "authors": [strip_accents(author) for author in authors], - "url": LubimyCzytac.BASE_URL + book_url, - } + MetaRecord( + id=book_url.replace(f"/ksiazka/", "").split("/")[0], + title=title, + authors=[strip_accents(author) for author in authors], + url=LubimyCzytac.BASE_URL + book_url, + source=MetaSourceInfo( + id=self.metadata.__id__, + description=self.metadata.__name__, + link=LubimyCzytac.BASE_URL, + ) + ) ) return matches - def parse_single_book(self, match: Dict, generic_cover: str) -> MetaRecord: - response = requests.get(match.get("url")) + def parse_single_book(self, match: MetaRecord, generic_cover: str) -> MetaRecord: + response = requests.get(match.url) self.root = fromstring(response.text) - match["cover"] = self._parse_cover(generic_cover=generic_cover) - match["description"] = self._parse_description() - match["languages"] = self._parse_languages() - match["publisher"] = self._parse_publisher() - match["publishedDate"] = self._parse_from_summary( + match.cover = self._parse_cover(generic_cover=generic_cover) + match.description = self._parse_description() + match.languages = self._parse_languages() + match.publisher = self._parse_publisher() + match.publishedDate = self._parse_from_summary( attribute_name="datePublished" ) - match["rating"] = self._parse_rating() - match["series"], match["series_index"] = self._parse_series() - match["tags"] = self._parse_tags() - - match["source"] = { - "id": self.metadata.__id__, - "description": self.metadata.__name__, - "link": LubimyCzytac.BASE_URL, - } - match["identifiers"] = { + match.rating = self._parse_rating() + match.series, match.series_index = self._parse_series() + match.tags = self._parse_tags() + match.identifiers = { "isbn": self._parse_isbn(), - "lubimyczytac": match["id"], + "lubimyczytac": match.id, } return match diff --git a/cps/metadata_provider/scholar.py b/cps/metadata_provider/scholar.py index 6e13c768..0becaef0 100644 --- a/cps/metadata_provider/scholar.py +++ b/cps/metadata_provider/scholar.py @@ -15,47 +15,53 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import itertools +from typing import Dict, List, Optional +from urllib.parse import quote from scholarly import scholarly -from cps.services.Metadata import Metadata +from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata class scholar(Metadata): __name__ = "Google Scholar" __id__ = "googlescholar" + META_URL = "https://scholar.google.com/" - def search(self, query, generic_cover=""): + def search( + self, query: str, generic_cover: str = "", locale: str = "en" + ) -> Optional[List[MetaRecord]]: val = list() if self.active: - scholar_gen = scholarly.search_pubs(' '.join(query.split('+'))) - i = 0 - for publication in scholar_gen: - v = dict() - v['id'] = "1234" # publication['bib'].get('title') - v['title'] = publication['bib'].get('title') - v['authors'] = publication['bib'].get('author', []) - v['description'] = publication['bib'].get('abstract', "") - v['publisher'] = publication['bib'].get('venue', "") - if publication['bib'].get('pub_year'): - v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01" - else: - v['publishedDate'] = "" - v['tags'] = "" - v['ratings'] = 0 - v['series'] = "" - v['cover'] = generic_cover - v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "", - v['source'] = { - "id": self.__id__, - "description": "Google Scholar", - "link": "https://scholar.google.com/" - } - val.append(v) - i += 1 - if (i >= 10): - break + title_tokens = list(self.get_title_tokens(query, strip_joiners=False)) + if title_tokens: + tokens = [quote(t.encode("utf-8")) for t in title_tokens] + query = " ".join(tokens) + scholar_gen = itertools.islice(scholarly.search_pubs(query), 10) + for result in scholar_gen: + match = self._parse_search_result( + result=result, generic_cover=generic_cover, locale=locale + ) + val.append(match) return val + def _parse_search_result( + self, result: Dict, generic_cover: str, locale: str + ) -> MetaRecord: + match = MetaRecord( + id=result.get("pub_url", result.get("eprint_url", "")), + title=result["bib"].get("title"), + authors=result["bib"].get("author", []), + url=result.get("pub_url", result.get("eprint_url", "")), + source=MetaSourceInfo( + id=self.__id__, description=self.__name__, link=scholar.META_URL + ), + ) - + match.cover = result.get("image", {}).get("original_url", generic_cover) + match.description = result["bib"].get("abstract", "") + match.publisher = result["bib"].get("venue", "") + match.publishedDate = result["bib"].get("pub_year") + "-01-01" + match.identifiers = {"scholar": match.id} + return match diff --git a/cps/search_metadata.py b/cps/search_metadata.py index a128f9ac..53cbf553 100644 --- a/cps/search_metadata.py +++ b/cps/search_metadata.py @@ -22,6 +22,7 @@ import inspect import json import os import sys +from dataclasses import asdict from flask import Blueprint, Response, request, url_for from flask_login import current_user @@ -99,11 +100,13 @@ def metadata_change_active_provider(prov_name): log.error("Invalid request received: {}".format(request)) return "Invalid request", 400 if "initial" in new_state and prov_name: - for c in cl: - if c.__id__ == prov_name: - data = c.search(new_state.get("query", "")) - break - return Response(json.dumps(data), mimetype="application/json") + data = [] + provider = next((c for c in cl if c.__id__ == prov_name), None) + if provider is not None: + data = provider.search(new_state.get("query", "")) + return Response( + json.dumps([asdict(x) for x in data]), mimetype="application/json" + ) return "" @@ -123,5 +126,5 @@ def metadata_search(): if active.get(c.__id__, True) } for future in concurrent.futures.as_completed(meta): - data.extend(future.result()) + data.extend([asdict(x) for x in future.result()]) return Response(json.dumps(data), mimetype="application/json") diff --git a/cps/services/Metadata.py b/cps/services/Metadata.py index 09fc70ce..f4a5662c 100644 --- a/cps/services/Metadata.py +++ b/cps/services/Metadata.py @@ -16,32 +16,38 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import abc +import dataclasses +import os import re -from typing import Dict, Generator, List, Optional, TypedDict, Union +from typing import Dict, Generator, List, Optional, Union + +from cps import constants -class MetaSourceInfo(TypedDict): +@dataclasses.dataclass +class MetaSourceInfo: id: str description: str link: str -class MetaRecord(TypedDict): +@dataclasses.dataclass +class MetaRecord: id: Union[str, int] title: str authors: List[str] url: str - cover: str - series: Optional[str] - series_index: Optional[Union[int, float]] - tags: Optional[List[str]] - publisher: Optional[str] - publishedDate: Optional[str] - rating: Optional[int] - description: Optional[str] source: MetaSourceInfo - languages: Optional[List[str]] - identifiers: Dict[str, Union[str, int]] + cover: str = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg') + description: Optional[str] = "" + series: Optional[str] = None + series_index: Optional[Union[int, float]] = 0 + identifiers: Dict[str, Union[str, int]] = dataclasses.field(default_factory=dict) + publisher: Optional[str] = None + publishedDate: Optional[str] = None + rating: Optional[int] = 0 + languages: Optional[List[str]] = dataclasses.field(default_factory=list) + tags: Optional[List[str]] = dataclasses.field(default_factory=list) class Metadata: diff --git a/optional-requirements.txt b/optional-requirements.txt index 03f58bb5..17c4b878 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -32,6 +32,9 @@ SQLAlchemy-Utils>=0.33.5,<0.38.0 # extracting metadata rarfile>=2.7 scholarly>=1.2.0, <1.5 +markdown2==2.4.2 +html2text==2020.1.16 +python-dateutil==2.8.2 # other natsort>=2.2.0,<8.1.0 diff --git a/requirements.txt b/requirements.txt index d09c2157..1db961fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,3 @@ Wand>=0.4.4,<0.7.0 unidecode>=0.04.19,<1.3.0 lxml>=3.8.0,<4.7.0 flask-wtf>=0.14.2,<1.1.0 -markdown2==2.4.2 -html2text==2020.1.16 -python-dateutil==2.8.2 From bea14d1784184f16040e99b0259664c9d78e98c3 Mon Sep 17 00:00:00 2001 From: collerek Date: Wed, 15 Dec 2021 15:20:01 +0100 Subject: [PATCH 027/220] fix locale for lubimyczytac languages --- cps/metadata_provider/lubimyczytac.py | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/cps/metadata_provider/lubimyczytac.py b/cps/metadata_provider/lubimyczytac.py index 4f6aca1e..814a785e 100644 --- a/cps/metadata_provider/lubimyczytac.py +++ b/cps/metadata_provider/lubimyczytac.py @@ -18,7 +18,7 @@ import datetime import json import re from multiprocessing.pool import ThreadPool -from typing import Dict, List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union from urllib.parse import quote import requests @@ -27,6 +27,7 @@ from html2text import HTML2Text from lxml.html import HtmlElement, fromstring, tostring from markdown2 import Markdown +from cps.isoLanguages import get_language_name from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata SYMBOLS_TO_TRANSLATE = ( @@ -119,7 +120,7 @@ class LubimyCzytac(Metadata): with ThreadPool(processes=10) as pool: final_matches = pool.starmap( lc_parser.parse_single_book, - [(match, generic_cover) for match in matches], + [(match, generic_cover, locale) for match in matches], ) return final_matches return matches @@ -165,18 +166,18 @@ class LubimyCzytacParser: title = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.TITLE_TEXT_PATH}", + f"{LubimyCzytac.TITLE_TEXT_PATH}", ) book_url = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.URL_PATH}", + f"{LubimyCzytac.URL_PATH}", ) authors = self._parse_xpath_node( root=result, xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}" - f"{LubimyCzytac.AUTHORS_PATH}", + f"{LubimyCzytac.AUTHORS_PATH}", take_first=False, ) if not all([title, book_url, authors]): @@ -191,21 +192,21 @@ class LubimyCzytacParser: id=self.metadata.__id__, description=self.metadata.__name__, link=LubimyCzytac.BASE_URL, - ) + ), ) ) return matches - def parse_single_book(self, match: MetaRecord, generic_cover: str) -> MetaRecord: + def parse_single_book( + self, match: MetaRecord, generic_cover: str, locale: str + ) -> MetaRecord: response = requests.get(match.url) self.root = fromstring(response.text) match.cover = self._parse_cover(generic_cover=generic_cover) match.description = self._parse_description() - match.languages = self._parse_languages() + match.languages = self._parse_languages(locale=locale) match.publisher = self._parse_publisher() - match.publishedDate = self._parse_from_summary( - attribute_name="datePublished" - ) + match.publishedDate = self._parse_from_summary(attribute_name="datePublished") match.rating = self._parse_rating() match.series, match.series_index = self._parse_series() match.tags = self._parse_tags() @@ -241,15 +242,15 @@ class LubimyCzytacParser: def _parse_publisher(self) -> Optional[str]: return self._parse_xpath_node(xpath=LubimyCzytac.PUBLISHER, take_first=True) - def _parse_languages(self) -> List[str]: + def _parse_languages(self, locale: str) -> List[str]: languages = list() lang = self._parse_xpath_node(xpath=LubimyCzytac.LANGUAGES, take_first=True) if lang: if "polski" in lang: - languages.append("Polish") + languages.append("pol") if "angielski" in lang: - languages.append("English") - return languages + languages.append("eng") + return [get_language_name(locale, language) for language in languages] def _parse_series(self) -> Tuple[Optional[str], Optional[Union[float, int]]]: series_index = 0 From b85627da5c285e07ecd7893216aef9ed7a0aca70 Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sun, 19 Dec 2021 11:29:54 +0100 Subject: [PATCH 028/220] Fix for adding/deleting visibility restrictions on custom columns/tags --- cps/admin.py | 15 +++++++++++++-- optional-requirements.txt | 2 +- setup.cfg | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cps/admin.py b/cps/admin.py index 04d9138f..1c228e49 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -756,7 +756,12 @@ def prepare_tags(user, action, tags_name, id_list): return ",".join(saved_tags_list) -@admi.route("/ajax/addrestriction/", defaults={"user_id": 0}, methods=['POST']) +@admi.route("/ajax/addrestriction/", methods=['POST']) +@login_required +@admin_required +def add_user_0_restriction(res_type): + return add_restriction(res_type, 0) + @admi.route("/ajax/addrestriction//", methods=['POST']) @login_required @admin_required @@ -803,7 +808,13 @@ def add_restriction(res_type, user_id): return "" -@admi.route("/ajax/deleterestriction/", defaults={"user_id": 0}, methods=['POST']) +@admi.route("/ajax/deleterestriction/", methods=['POST']) +@login_required +@admin_required +def delete_user_0_restriction(res_type): + return delete_restriction(res_type, 0) + + @admi.route("/ajax/deleterestriction//", methods=['POST']) @login_required @admin_required diff --git a/optional-requirements.txt b/optional-requirements.txt index 03f58bb5..3fac14ca 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -12,7 +12,7 @@ rsa>=3.4.2,<4.8.0 six>=1.10.0,<1.17.0 # Gdrive and Gmail integration -google-api-python-client>=1.7.11,<2.32.0 +google-api-python-client>=1.7.11,<2.34.0 # Gmail google-auth-oauthlib>=0.4.3,<0.5.0 diff --git a/setup.cfg b/setup.cfg index fc74a1ed..87f6055f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,7 +56,7 @@ install_requires = [options.extras_require] gdrive = - google-api-python-client>=1.7.11,<2.32.0 + google-api-python-client>=1.7.11,<2.34.0 gevent>20.6.0,<22.0.0 greenlet>=0.4.17,<1.2.0 httplib2>=0.9.2,<0.21.0 @@ -70,7 +70,7 @@ gdrive = six>=1.10.0,<1.17.0 gmail = google-auth-oauthlib>=0.4.3,<0.5.0 - google-api-python-client>=1.7.11,<2.32.0 + google-api-python-client>=1.7.11,<2.34.0 goodreads = goodreads>=0.3.2,<0.4.0 python-Levenshtein>=0.12.0,<0.13.0 From d45085215f231533ae62749f0e74c637b2168faa Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Wed, 22 Dec 2021 19:05:41 +0100 Subject: [PATCH 029/220] Update Translation --- cps/iso_language_names.py | 41 ---- cps/translations/cs/LC_MESSAGES/messages.mo | Bin 39546 -> 39546 bytes cps/translations/cs/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/de/LC_MESSAGES/messages.mo | Bin 53605 -> 56158 bytes cps/translations/de/LC_MESSAGES/messages.po | 212 +++++++++--------- cps/translations/el/LC_MESSAGES/messages.mo | Bin 57741 -> 57741 bytes cps/translations/el/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/es/LC_MESSAGES/messages.mo | Bin 51330 -> 51330 bytes cps/translations/es/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/fi/LC_MESSAGES/messages.mo | Bin 27430 -> 27430 bytes cps/translations/fi/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/fr/LC_MESSAGES/messages.mo | Bin 53669 -> 53669 bytes cps/translations/fr/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/hu/LC_MESSAGES/messages.mo | Bin 25180 -> 25180 bytes cps/translations/hu/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/it/LC_MESSAGES/messages.mo | Bin 56299 -> 56299 bytes cps/translations/it/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/ja/LC_MESSAGES/messages.mo | Bin 19843 -> 19843 bytes cps/translations/ja/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/km/LC_MESSAGES/messages.mo | Bin 24546 -> 24546 bytes cps/translations/km/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/nl/LC_MESSAGES/messages.mo | Bin 50497 -> 50497 bytes cps/translations/nl/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/pl/LC_MESSAGES/messages.mo | Bin 52413 -> 52413 bytes cps/translations/pl/LC_MESSAGES/messages.po | 148 ++++++------ .../pt_BR/LC_MESSAGES/messages.mo | Bin 46930 -> 46930 bytes .../pt_BR/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/ru/LC_MESSAGES/messages.mo | Bin 47969 -> 47969 bytes cps/translations/ru/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/sv/LC_MESSAGES/messages.mo | Bin 48760 -> 48760 bytes cps/translations/sv/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/tr/LC_MESSAGES/messages.mo | Bin 22790 -> 22790 bytes cps/translations/tr/LC_MESSAGES/messages.po | 148 ++++++------ cps/translations/uk/LC_MESSAGES/messages.mo | Bin 18028 -> 18028 bytes cps/translations/uk/LC_MESSAGES/messages.po | 148 ++++++------ .../zh_Hans_CN/LC_MESSAGES/messages.mo | Bin 50104 -> 50104 bytes .../zh_Hans_CN/LC_MESSAGES/messages.po | 148 ++++++------ .../zh_Hant_TW/LC_MESSAGES/messages.mo | Bin 48886 -> 48886 bytes .../zh_Hant_TW/LC_MESSAGES/messages.po | 148 ++++++------ messages.pot | 148 ++++++------ 40 files changed, 1507 insertions(+), 1558 deletions(-) diff --git a/cps/iso_language_names.py b/cps/iso_language_names.py index c6267ffd..11e5e67b 100644 --- a/cps/iso_language_names.py +++ b/cps/iso_language_names.py @@ -55,7 +55,6 @@ LANGUAGE_NAMES = { "bik": "bikolština", "bin": "bini", "bis": "bislamština", - "bit": "berinomo", "bla": "siksika", "bod": "tibetština", "bos": "bosenština", @@ -164,7 +163,6 @@ LANGUAGE_NAMES = { "hil": "hiligayonština", "hin": "hindština", "hit": "chetitština", - "hmj": "Ge", "hmn": "hmongština", "hmo": "hiri motu", "hrv": "chorvatština", @@ -479,7 +477,6 @@ LANGUAGE_NAMES = { "bik": "Bikol", "bin": "Bini", "bis": "Bislama", - "bit": "Berinomo", "bla": "Blackfoot", "bod": "Tibetisch", "bos": "Bosnisch", @@ -588,7 +585,6 @@ LANGUAGE_NAMES = { "hil": "Hiligaynon", "hin": "Hindi", "hit": "Hethitisch", - "hmj": "Ge", "hmn": "Miao-Sprachen", "hmo": "Hiri-Motu", "hrv": "Kroatisch", @@ -898,7 +894,6 @@ LANGUAGE_NAMES = { "bel": "Λευκωρωσικά", "bem": "Bemba (Zambia)", "ben": "Μπενγκάλι", - "bit": "Berinomo", "bho": "Bhojpuri", "bik": "Bikol", "byn": "Bilin", @@ -973,7 +968,6 @@ LANGUAGE_NAMES = { "lug": "Ganda", "gay": "Gayo", "gba": "Gbaya (Central African Republic)", - "hmj": "Ge", "gez": "Geez", "kat": "Γεωργιανά", "deu": "Γερμανικά", @@ -1282,7 +1276,6 @@ LANGUAGE_NAMES = { "bik": "Bicolano", "bin": "Bini", "bis": "Bislama", - "bit": "Berinomo", "bla": "Siksiká", "bod": "Tibetano", "bos": "Bosnio", @@ -1391,7 +1384,6 @@ LANGUAGE_NAMES = { "hil": "Hiligainón", "hin": "Hindi", "hit": "Hitita", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri motu", "hrv": "Croata", @@ -1706,7 +1698,6 @@ LANGUAGE_NAMES = { "bik": "bikol", "bin": "bini", "bis": "bislama", - "bit": "Berinomo", "bla": "mustajalka (siksika)", "bod": "tiibetti", "bos": "bosnia", @@ -1815,7 +1806,6 @@ LANGUAGE_NAMES = { "hil": "hiligaynon", "hin": "hindi", "hit": "heetti", - "hmj": "Ge", "hmn": "hmong", "hmo": "hiri-motu", "hrv": "kroatia", @@ -2130,7 +2120,6 @@ LANGUAGE_NAMES = { "bik": "bikol", "bin": "bini", "bis": "bislama", - "bit": "berinomo", "bla": "pied-noir", "bod": "tibétain", "bos": "bosniaque", @@ -2239,7 +2228,6 @@ LANGUAGE_NAMES = { "hil": "hiligaynon", "hin": "hindi", "hit": "hittite", - "hmj": "ge", "hmn": "hmong", "hmo": "hiri Motu", "hrv": "croate", @@ -2554,7 +2542,6 @@ LANGUAGE_NAMES = { "bik": "bikol", "bin": "bini", "bis": "biszlama", - "bit": "Berinomo", "bla": "szikszika", "bod": "tibeti", "bos": "bosnyák", @@ -2663,7 +2650,6 @@ LANGUAGE_NAMES = { "hil": "hiligajnon", "hin": "hindi", "hit": "hettita", - "hmj": "Ge", "hmn": "hmong", "hmo": "hiri motu", "hrv": "horvát", @@ -2978,7 +2964,6 @@ LANGUAGE_NAMES = { "bik": "bicol", "bin": "Bini", "bis": "bislama", - "bit": "Berinomo", "bla": "Siksika", "bod": "Tibetano", "bos": "Bosniaco", @@ -3087,7 +3072,6 @@ LANGUAGE_NAMES = { "hil": "Hiligayna", "hin": "Hindi", "hit": "hittite", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri motu", "hrv": "Croato", @@ -3402,7 +3386,6 @@ LANGUAGE_NAMES = { "bik": "ビコル語", "bin": "ビニ語", "bis": "ビスラマ語", - "bit": "Berinomo", "bla": "ブラックフット語", "bod": "チベット語", "bos": "ボスニア語", @@ -3511,7 +3494,6 @@ LANGUAGE_NAMES = { "hil": "ヒリジャノン語", "hin": "ヒンディー語", "hit": "ヒッタイト語", - "hmj": "Ge", "hmn": "フモング語", "hmo": "ヒリモトゥ語", "hrv": "クロアチア語", @@ -3826,7 +3808,6 @@ LANGUAGE_NAMES = { "bik": "Bikol", "bin": "Bini", "bis": "Bislama", - "bit": "Berinomo", "bla": "Siksika", "bod": "Tibetan", "bos": "Bosnian", @@ -3935,7 +3916,6 @@ LANGUAGE_NAMES = { "hil": "Hiligaynon", "hin": "Hindi", "hit": "Hittite", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri Motu", "hrv": "Croatian", @@ -4250,7 +4230,6 @@ LANGUAGE_NAMES = { "bik": "Bikol", "bin": "Bini; Edo", "bis": "Bislama", - "bit": "Berinomo", "bla": "Siksika", "bod": "Tibetaans", "bos": "Bosnisch", @@ -4359,7 +4338,6 @@ LANGUAGE_NAMES = { "hil": "Hiligainoons", "hin": "Hindi", "hit": "Hittitisch", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri Motu", "hrv": "Kroatisch", @@ -4674,7 +4652,6 @@ LANGUAGE_NAMES = { "bik": "bikol", "bin": "edo", "bis": "bislama", - "bit": "Berinomo", "bla": "siksika", "bod": "tybetański", "bos": "bośniacki", @@ -4783,7 +4760,6 @@ LANGUAGE_NAMES = { "hil": "hiligajnon", "hin": "hindi", "hit": "hetycki", - "hmj": "Ge", "hmn": "hmong", "hmo": "hiri motu", "hrv": "chorwacki", @@ -5093,7 +5069,6 @@ LANGUAGE_NAMES = { "bel": "Belarusian", "bem": "Bemba (Zambia)", "ben": "Bengali", - "bit": "Berinomo", "bho": "Bhojpuri", "bik": "Bikol", "byn": "Bilin", @@ -5168,7 +5143,6 @@ LANGUAGE_NAMES = { "lug": "Ganda", "gay": "Gayo", "gba": "Gbaya (Central African Republic)", - "hmj": "Ge", "gez": "Geez", "kat": "Georgiano", "deu": "Alemão", @@ -5475,7 +5449,6 @@ LANGUAGE_NAMES = { "bik": "Бикольский", "bin": "Бини", "bis": "Бислама", - "bit": "Berinomo", "bla": "Сиксика", "bod": "Тибетский", "bos": "Боснийский", @@ -5584,7 +5557,6 @@ LANGUAGE_NAMES = { "hil": "Хилигайнон", "hin": "Хинди", "hit": "Хиттит", - "hmj": "Ge", "hmn": "Хмонг", "hmo": "Хири Моту", "hrv": "Хорватский", @@ -5899,7 +5871,6 @@ LANGUAGE_NAMES = { "bik": "Bikol", "bin": "Edo (bini)", "bis": "Bislama", - "bit": "Berinomo", "bla": "Siksika (svartfotindianernas språk)", "bod": "Tibetanska", "bos": "Bosniska", @@ -6008,7 +5979,6 @@ LANGUAGE_NAMES = { "hil": "Hiligaynon", "hin": "Hindi", "hit": "Hettitiska språk", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri Motu", "hrv": "Kroatiska", @@ -6318,7 +6288,6 @@ LANGUAGE_NAMES = { "bel": "Beyaz Rusça", "bem": "Bemba (Zambia)", "ben": "Bengalce", - "bit": "Berinomo", "bho": "Bhojpuri (Hindistan)", "bik": "Bikol (Filipinler)", "byn": "Bilin", @@ -6393,7 +6362,6 @@ LANGUAGE_NAMES = { "lug": "Ganda Dili", "gay": "Gayo (Sumatra)", "gba": "Gbaya (Orta Afrika Cumhuriyeti)", - "hmj": "Ge", "gez": "Geez (Etiyopya)", "kat": "Gürcüce", "deu": "Almanca", @@ -6700,7 +6668,6 @@ LANGUAGE_NAMES = { "bik": "бікольська", "bin": "біні", "bis": "біслама", - "bit": "беріномо", "bla": "сісіка", "bod": "тибетська", "bos": "боснійська", @@ -6809,7 +6776,6 @@ LANGUAGE_NAMES = { "hil": "хілігайнон", "hin": "хінді", "hit": "хетська", - "hmj": "ге", "hmn": "хмонг", "hmo": "хірімоту", "hrv": "хорватська", @@ -7124,7 +7090,6 @@ LANGUAGE_NAMES = { "bik": "比科尔语", "bin": "比尼语", "bis": "比斯拉马语", - "bit": "Berinomo", "bla": "西克西卡语", "bod": "藏语", "bos": "波斯尼亚语", @@ -7233,7 +7198,6 @@ LANGUAGE_NAMES = { "hil": "希利盖农语", "hin": "印地语", "hit": "赫梯语", - "hmj": "亻革家语、重安江苗语", "hmn": "苗语", "hmo": "希里莫图语", "hrv": "克罗地亚语", @@ -7543,7 +7507,6 @@ LANGUAGE_NAMES = { "bel": "白俄羅斯文", "bem": "Bemba (Zambia)", "ben": "Bengali", - "bit": "Berinomo", "bho": "Bhojpuri", "bik": "Bikol", "byn": "Bilin", @@ -7618,7 +7581,6 @@ LANGUAGE_NAMES = { "lug": "Ganda", "gay": "Gayo", "gba": "Gbaya (Central African Republic)", - "hmj": "Ge", "gez": "Geez", "kat": "Georgian", "deu": "德文", @@ -7925,7 +7887,6 @@ LANGUAGE_NAMES = { "bik": "Bikol", "bin": "Bini", "bis": "Bislama", - "bit": "Berinomo", "bla": "Siksika", "bod": "Tibetan", "bos": "Bosnian", @@ -7960,7 +7921,6 @@ LANGUAGE_NAMES = { "cre": "Cree", "crh": "Turkish; Crimean", "csb": "Kashubian", - "csl": "Chinese Sign Language", "cym": "Welsh", "dak": "Dakota", "dan": "Danish", @@ -8035,7 +7995,6 @@ LANGUAGE_NAMES = { "hil": "Hiligaynon", "hin": "Hindi", "hit": "Hittite", - "hmj": "Ge", "hmn": "Hmong", "hmo": "Hiri Motu", "hrv": "Croatian", diff --git a/cps/translations/cs/LC_MESSAGES/messages.mo b/cps/translations/cs/LC_MESSAGES/messages.mo index c77a5c217131a2e988c551ff63cd7155744ce307..3733c6c4e23a2c3ed78a08e9ba5ea3edb538486a 100644 GIT binary patch delta 30 mcmeyhh3VH8rVY;`IE;)G3@xn;%r?J^uwi7h*sLAp?*IVBQwtja delta 30 mcmeyhh3VH8rVY;`I1Eh`49%@f4L84wuwi7h+^ikt?*IVBPzxIX diff --git a/cps/translations/cs/LC_MESSAGES/messages.po b/cps/translations/cs/LC_MESSAGES/messages.po index f835c699..581af829 100644 --- a/cps/translations/cs/LC_MESSAGES/messages.po +++ b/cps/translations/cs/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-06-09 21:11+0100\n" "Last-Translator: Lukas Heroudek \n" "Language: cs_CZ\n" @@ -15,7 +15,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "Uživatel admin" msgid "All" msgstr "Vše" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Uživatel nenalezen" @@ -92,7 +92,7 @@ msgstr "Zobrazit vše" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -100,7 +100,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Nezbývá žádný správce, nelze odebrat roli správce" @@ -144,7 +144,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Konfigurace Calibre-Web aktualizována" @@ -208,279 +208,279 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Zakázat" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Povolit" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json není nakonfigurováno pro webové aplikace" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "Umístění zápisového souboru není platné. Určete prosím platnou polohu" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "Umístění zápisového souboru pro přístup není platné. Určete prosím platnou polohu" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Prosím zadejte LDAP poskytovatele, port, DN a Identifikátor objektu uživatele" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Zadejte platné uživatelské jméno pro obnovení hesla" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "Filtr objektů skupiny LDAP musí mít jeden “%s” formátový identifikátor" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "Filtr objektů skupiny LDAP má nesrovnatelnou závorku" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "Filtr uživatelských objektů LDAP musí mít jeden “%s” formátový identifikátor" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "Filtr uživatelských objektů LDAP má nesrovnatelnou závorku" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "Umístění databáze není platné, opravte prosím cestu" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Databáze není zapisovatelná" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "Umístění souboru klíčů není platné, zadejte prosím správnou cestu" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "Umístění certifikátu není platné, zadejte prosím správnou cestu" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Nastavení e-mailového serveru aktualizováno" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Konfigurace funkcí" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Vyplňte všechna pole!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "E-mail není z platné domény" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Přidat nového uživatele" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Uživatel '%(user)s' vytvořen" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Byl nalezen existující účet pro tuto e-mailovou adresu nebo přezdívku." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Uživatel '%(nick)s' smazán" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Nezbývá žádný správce, nemůžete jej odstranit" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Upravit uživatele %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Uživatel '%(nick)s' aktualizován" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Neznámá chyba. Opakujte prosím později." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Změnit SMTP nastavení" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Při odesílání zkušebního e-mailu došlo k chybě: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Prvně nastavte svou e-mailovou adresu..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Nastavení e-mailového serveru aktualizováno" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Heslo pro uživatele %(user)s resetováno" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Nejprve nakonfigurujte nastavení pošty SMTP..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Prohlížeč log souborů" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Požadování balíčku aktualizace" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Stahování balíčku aktualizace" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Rozbalování balíčku aktualizace" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Nahrazování souborů" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Databázová připojení jsou uzavřena" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Zastavuji server" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Aktualizace dokončena, klepněte na tlačítko OK a znovu načtěte stránku" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Aktualizace selhala:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP chyba" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Chyba připojení" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Vypršel časový limit při navazování spojení" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Všeobecná chyba" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Aktualizační soubor nemohl být uložen do Temp Dir" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Nepodařilo se vytvořit nejméně jednoho uživatele LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Nepodařilo se vytvořit nejméně jednoho uživatele LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Chyba: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Chyba: Žádná reakce od uživatele LDAP serveru" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Nejméně jeden uživatel LDAP nenalezen v databázi" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1342,7 +1342,7 @@ msgstr "Převedený soubor nebyl nalezen nebo více než jeden soubor ve složce msgid "Ebook-converter failed: %(error)s" msgstr "Převaděč eknih selhal: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2581,7 +2581,7 @@ msgstr "Chyba" msgid "Upload done, processing, please wait..." msgstr "Nahrávání hotovo, zpracovávám, čekejte prosím..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Nastavení" @@ -2732,7 +2732,7 @@ msgstr "Calibre-Web katalog eknih" msgid "epub Reader" msgstr "Čtečka PDF" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Po otevření postranních panelů přeformátujte text." diff --git a/cps/translations/de/LC_MESSAGES/messages.mo b/cps/translations/de/LC_MESSAGES/messages.mo index 6721cea96ee9ae1f50721065e05b4216ed1dbe0e..ae7fbfcfd0a44656a9805698eee5cd981eb806fa 100644 GIT binary patch delta 13959 zcma*sd3==By~pt`MSylwD;JC&`m!U^0U>tIp=qN z=Xaioy`McAf3_w*a<)yv7K{IU-pH~#V@aBN|NPJ1zLuq-r*Qxt!|wPk_QCXi=6h4H z4SmP$uRxpr7UaKH4gctc=iGkF{+89$vLev(nn^=nxDHeC2296WQIYS& zPPiZS+(~3k>nt|IltE@Z9Z9Lx7ZvDGY=@Ju1Q((*TZbd@9~hZIW9VSZnuBX`8or0u zVb39!H63rkTs)24@JG}Rx(>Ch_Bb9@3)iAHuns%oHf)SF*c=aIGX4&AgzpdK{L^TB z!hllr4@|&B4!$iWq554=8R?G7$QZP7I%=T`)c0>io$V%6z%L+WWSv6=n8x-Ca3Zn? zD>9t?>+EV6&;*At507IGevR5uSH5*M4n_rhCw9We+bVC|0USmJ_%3?zZ*G6q zC^Nqob$yqkHXPYZLksRe71s;Mrma(`>TNRGERcpeyW!Xlr=p%;f(j&vlcvD}#n6o{d~ts~Gi#EvSVaMSbDdsGT1}6;sQxq!T-$&U`Vd zDA!_V+>HwG7?K3*3~Js~qSO&&VhZb9188W%38)=kg{)yMK`p!kxoOtZsG|HB_59bU z;*1+_Zb4(z5oBOj9E!^9)u;^yu?yaenfNGnV}0uw4W0d;P!oUQ`VUk10?1p{VA)cx2351;}+g1zw#%)#%mDfVPl6=z@6 zjt8RV9gWJQ50hEnT0>(1Zo&dQii7boHpjs;%~_4Zmh>m10-cAtUOvQO%$+2fe6?4x<+M4E6aBsOs&&TSpGW5qKqPXSbt{ZVM{l zZK$2^c6}Q4+_TsW52NNiKAZfvrtwDx6yZhG1PMf=;!DO9yb7sD%Y&NeDR;aEb*8Ui zD?E#B@vo@jjGJo$PDj=+U8o5>U2~F@=#|r z7Pa%K?)WvR7soQx8Q*|8ScS^O!>A)Wgv!)wsM>fB`IEx>2vs{>7Bpmn`%j|-1J$Sn z@5c7{0H)#r9EQhHXKXDr0kp<`bW>3a6`~fJiK?NiQ5h;m?R+gNu!m4bz7HGj{~;RM z(MfEFr?DfRLlup+$kad=)B?RQ7xQo+UW>}aov00LLelX0YsV)UkUyYak$FXCrwdX2RhW*uaS|RyWu$pAe`et%)Dhf-3g~83 z(QZIpvj<$CbU%Lqm7)6Lh}qG322>P3pw6&aiHW=|YR8$V;u(V4`4qQ*E$aDYsOKtC z8C#E9cr$9=U8veQfU2=t*HaPqflpljhPn+upo%EfF%x890{!0Da3rWIFF-v%3ANx7 z)B*wQgtxf;9oU`zK~#p$xZ{zJY3RXkuotG3n%{t7*n|G{sD;*|GH?$z#+`2eNt{Ce z5KhL#G84!Q)GbcLvnHAP2Qz1T*k1)I57p&&5y+9!9-cPoR$SV~nU+F47o?O_!NejzpdP6jTN( zP;aa%cRY$Znp)Hg=vCKGQAPFxY6074GMSGm-U8J47}UJ8eD3f60tOV>Qq((t4QfaC zqQ3Ar+E|MU_&rnrU!b0ERAB7-q;LWJo*^fDR!8J8t-u+`-1K69-cVIT2h|tKSanb#t z{c`i;G7dFS1=@Ho>I=1~v;7!#War&}=NnAMx}%CSACqtxs+cFZ{b{K0&&SpnSxloF zjSB3E8!-=`!>;&8v@t$tQrH9I=?}q{Sb#d)DXue60nA2a@EV+oC76u+a5UB-&qb`u zG}_lZ?4=Uh;n1Z#g zC$P2d|A!3?{+$lB;~!9wcCIuj%ESKjN1;;b$5vS7e!do!(k-Z3+J?G@zeW|~+t>oX zL>1#tsOQtF$SmtyU1(^+F{l8pbo+kPPFJILz8h5oAD|{ks5aNKIqE2~F$;&_T)Ymo za10fA9k$0;P|yDXBTC6%X{6z0)C(hRg^9eI>nPM2UyFL~I@c=KyHFc=9CcfsLmj~p zR3?t10{F!J{LiTQF0LT|3g8k0I;*6WrWR6Bfn=j@K|U(-MW~5Fs0D6BrF;$QjJIK5 zd>ECf*RdB~Le*H;Rc7b;*pB|#Rpfs-jd|_|8&OsJ5GsH@s7Pa|9oC{!cN}#DZ=-7E zL)3GBMQ!9OxBnw5utqnUfEuGR)DM-JVG$Zi>3nyMi>z-w zLE{=sUT5yD7iZHyf&($-R&%C>sGXLgYGW;q#~rBR`~)-bOH?LX-)3qf6_vRxY=Xm3 zwK5hPe*YJ{9|TdUTZNk7PSk@@)KTomZg>cFwr5ed7~W*f{KG!OOt z8Q2q-qHf9Es0=-dxmdS>{5PlZ7Y0)C686GYcba$pVC+M`6lY=-saoq(_w(;jndozu zxyF<6diqPSH`bw2egX6GA|_$4jV90m5gIC*S*WvgP?7G&0r&#yNdAJlC70dL)9*G5 z=b-|dg393is0Cg_ZR|a_{~5NV|96~#KcUu%jK9a6$y`ik;7-&FX}jC6!CCZ=V+wZM zL`7pRPQ+E%A78>A_;*w$Q={g6l8deAhfw3cLLI?<$a)d0#x$(ssD;j;Qrdg7`5z1l zQB{94YG;pPV|*GF=s{F1yoz1%3sk0BZ81OJy)lP=0qRIfP-njpo9h0*NJFVPirV3u zs7!o{n&@jBgehD3w;7y-i*OgJ7UJ$T3v@v3bUZf0S=a&>qUJA0)k2j!eiPcd|98-6 zihEJ1c@F#HTd3k|bf5VnvLEWXC8+1hF%#FJ-UoZI1JJ(%+=iO?p!@k-sK`G>rSLn{88_W-`l+Z)^gsnV z3N_zc)c49!nO=>$Ew@By45YCKZTv5sj6dOZIBAEuHjm*b`Y)mu_zu&t&BJD)zNiHX zQ5#x-I*LlvbGM<2aF^?|sA7-Q(a=ub#9{a`j=)ZjnE$ugd>lkSiW+|f3ozkP;~2E* zuX245mHIz)mY8?R+NS&YQD$pbS!G3 zYfx{h2c356B>`vh{vaK zDjvX&_yd+;`=?AQZ^U-=*P}AB9aTgJP?@R6Tznrhu<>4VJ$s-6n24D;4|Sv~_L6^9 z|J@8Ixo&SyYH_$iLW%Qyi??lT$rB`V^Rs5AT{F2cn9W`QE?L4Q9c8yP>}4MHN#J+u%y< zjT^8F{@NXX2bKEIQ5(37t#$v?o-=2Wg(|uUn1hQ^3*3q-&bv`BsLePPPr09Ws4+Y3 zhT3_5Ou%8N8W@Gzz|GhlH=}B%7O&9#KSN_W1DU@z_cnmKW?N8a^#{zx9j_j~CvXU!Mg4Z99x?&WL5;7*$UqtgY4pMKsMMq$=4#~XxvAX})(-_CVBdApW5jD{T)EE962jEYr3Hu*20ggbO{Z**c7P;dAw|_GxGJZE| z16$qkXE2riiw)zP|7jXp_;bv|FL4BRec4>6h1j3|O{f6&p^EkeR0`k5#(2*4uhIvG538I>TE-(7tJPA z@ts6{|5HrFuTTMBLT#Y!?@Z>pV8mu%01Ztv8&wN8peDKvm61o>&tFH?$QP&`G(BNb zn}K?MFsgR0M5VaM9bbVuf_tzV?m^v_lP5U;o-{5npf9GJG%4zd>JPytI2EtJ*;t6% zumC^7X_)b_FJKOKI%TfkMAW!*iu@~en;FQ)Q>e(k!9LjjHS^}1 zfQ{*|!hX2M?H|Ba^lMS6KJNBUVKe$4Vsku)`urn{!Lhgkr=yPQ7|z1?QGxb3ZH_vUPh%tlSKw$| zhk6qpbH_iyEc#vFG&`S+HvMW;#_mJa&Tmi?e}t{^94f$XP_^(qDv*@7OfmLCYAs@o zqoI^vgDQ>{n1T;rU)+mIWdYL*4fys4t#DW#A+1j(@^N*!(?HL#1Tx1}-Qee-52L{;xhRPiiA1>`}cE{sXI&KeMKCjuQ^Ej}UtxE~g}V^_YoGr@K3kZW>eArPNdD3)}O(A#V|L z{QQv}D7D9v-Pl`WlH*!T2>2>1{Puj0uhNOVKlYh~=$gV)O^X92+Suet2O70-R)iIz z?Frky=(STaVwa}uXq4r1LUwVW(pS=u`Xa{;I?H{YVyDC|sSGN?Ca&n_Y12~3R70MY zDKScA?1O1L6WSC9s+^!*svQ;w{9*DFtDISt+QJhoF1MFCE2{&+lGu5tCZTn4&|#0H zdwFG%&s!W_wRC9omZhCCf=&fN{o6=5c4pZx;*vs~I|or>&xwt$NQ&<|H&_``cS;}>4%yig$ce|E<1FXA z7|w}JUcM}@TSMsok_1kQujnM5ishVEFt+=K`1t6;r5Q~)Mnx5SIDAK3N<%%d;h>i^ zs^X$Osk>)Su+pO?eqcke~RHF5m0 z*Q+PRMgHSzgh{2>@7QJahkRjgnPbmc&T*D=JH~s%B$ZA2e2(9q)J@==PgBMH3^f=Y&+6 z{vwZmnOzm||LdKyef2dV7W4P9D=X}V0G9HvBqS8}1oRff4wMUJ#0{8OO zWdXl#m0exGt30STX$i$Pui6kXBQpYf-xZGEND8#)ga>|0<($d(e!v_;HRcGZM zZOzo>ztt4AP+t=bY))(D9=cN*U0pwR%Kz~lH>cW;$~ELv{G8^XQ|9q$E>myO=j#vV zjH@i=Jh=*_#Lrc*C%P|)Ri1L)gis(DCb6W{cl7q`aYg^XhhtwKeJZZwI3+a1{wXIn zs)i7p*t(Z@{Krd0=j!45@>0=B=0s~ZcaKdz@mO3pT^z4_g6>(VoO!DRg7v$2??o?c zOOKs98I4by)^K|YgFa7X5m)BtWupu0d&O41ekv~We_f{#&xe_WS3#NMre)me9{&Ro Cv5?RJ delta 11962 zcmYM(33yG{`p5B|nMjaCB9TZA5)wotK_Wt8PKX*Rii(+1(om)5gOVDmD#xs1uCaz5 zMO8~{Rzq9WqPT9&)mF7=-D>;){;d5x|NGqMe%4xN@3r3bu6OP9-g|Yo*UdFv&a1)R z3oZV2*3+`0a6y=Q|NpOmM9WI18;CV93+v(l)bsCPFurg152Azq6->f^ung9#XIc4} zhTU*3HnuFMb%RDa1JOL~gD+qf7N8%lKrdX6m2nf+!q3nj?_zm;hymzT-?A!UMbvX~ zsPPV{=X%=xq3FZ<))*R^Xrk?#s0T~10bYB}c^;!O<3ZZ0 zV>AY0bJX)4F%*7sR;AdDFBdACnMKY?NhYDmM zR>jHo@9)~b@3H%5QAhG4YC}&j4nsK?WvU5k-WI4#7dUCe(~z;a5!>Qv48l;hqX%nY z1m>aw8i4(9g5AG@THq1tnm$GC*wMr+SO7t z1@aFz$BI;zQr!i08=R;kn}MqG5~NJ5RjB#SBlTvJ2pi+Jw_5NksJE(;pq5|^HHb)$WUFc_FE|y>wCymQA zG{GOJ0KCYv`ejf%tc1Ew)llDo1k@3wqP_z;sG8`Eb#W-Fx)-4`c@Xv79n`gZfZ^!V zg8XZz(KK{c4N<9XgWB-`)Pj>y3(Q2NdKK!Zw%h&RQS*2*7=sb0Of^OY(jBYeG}H#y zq2}MzlKd-0hZ#^r-(n5CkE-^NRwl4SjHKTTb%s4rI~jtyz7uUrPyug5)yfB`TW|!G z$uCjsT|v!%zZLn{L}go>g{q<=uZN1T18T=ZP!qk5nqV3#kOipBtw9}$8x`nrR1Ms) ze}9Myz^9D~Fc@{jQBE3~ARe_)W4oV&KJ`|&)J}tQ%nqxfUaW(@*c5AFD@?@UsOz*0b@pel zCjMd@nrr6GwC#z?@L24q`@fim&hR#Nz`s!uwr^(^>WSL%G}MAik!)JWFb)ITn+&C* zHqssyaCfYYFJl>8h~8L={LOz#6EG}i=bwt0S0{#QFV=wY7%b=bM#IhK!{A=RsG%8>-RD{o=Cg_7IvSFx*HzTKG z?MF@I+sTXvp^mTymdD1ZTa|+)N^^$+@C zT4$5;EY!jsumW~RZD5G)bkxGDkW;jF;R~qmt^)6lI;u6O_YZU-|N8KpwFmB?`hQ{y z25}yIEv;6lqANy4ehT$PyMf8*+tsqNF&*^*8;2Ef1!}%+sP~Scp8pSKV~{i7RA(0q zV&Fv##zNE?O~F8%ZI3TO72y`tneM~{+>Oe_HPk#$QJE^=&D2B$@(0v%plYT!Dih9S zG!)?;)Pi4P7+%6qe1PfbM;>*y*{A?IViM+~7Mg)7zIRZyvlx}3wOARCpaQ#sI`aF- zd`{~r4NX|CyZM3zp^By^s%WxNHP9Qiz+kL{g_w#vjg@gcDl;Xh1y`erd7a%q zfvSm9sQG@ydYJkmN1^-Qp9b}2m7;d^0CO>}mq~3AathXX48gBZJGqJavOPuz{%h+f zFsW~j3TyzXm|w-_xE>YIZFFkK<$9Z)XJR7#LX5`cc7Gp6(!Yak(7TVxNPA49UyRDg zr>KAqp^Ekt>Y81${n7sYf2a(3^(Ft>QG8!h6q%?q%tb~1JnHxUsNxxe+WAbozZCWS z7SwYeqKfeZYT<8C#dZT#I}flD{%IT7&uJd0-p`nX;XIIuDx!RQya2Vsmr)B8p{jg5 z>iJ^Sf-6xAY{x2i*zW%amGXzE42AbM&sB5M(1VSzCU(YJI2M(nRj7rIpfd0kYA4t2 z{*RbL|0%Y`)&oo+b1{Pc3RDL6pfYqAwXqARK%Lj@2OgnP?m5uB7=(JE4r;+HyWa+N zwmt3fA*cY0Pzy~!?QkaQx%sG!t+e}_QAf5Hy>$OSr_q>!qnLr#AQNE|)I=@O8}m>* z>Wn@(47I>WRO+3!^H3XEiCSnIY6E-i@dKzp&tRbL|M&I-cd<4<{EE6Rk%P_AJcrfk z4@FJ*7AlZMsD*Z5RXl*ccm?&`4b(!vU>N>|I>O+WOzLAXmG!O0G?cqmJY*>cjK5ZRikFT#2X!I-?&BKxJ;2Jw5`<(4U6N*c^QJ&;QG5=v)5*YDdRW z6Mcscyo)Lp|Dh&;7}WErs6cX2--mv7e-i3eEJV$-4V8g|sCm9a75z^`$-f@(d)Z7F zfyzV@mcb_Ii>Wqvg7NhGqmFJ4*4Fc=_b#FXy@r#2Js3OO zEYty0>36mJGf^3thdx+}{zF)$k(5;a`}D)e6nR9Z?$};iSPe zu%=-k7NgE=f$dU!wlJ1sd>yvK9jGtiU)T%-UNO(L$6)%MQJ?mKs0}Sd9l;6IZM%jl za%YtylbQ|~&A?bp#>J?}PoM(0kJ|YoRKV5dUD2d|uV<;`{?>1Tv6-wJ;64Vjm2^U8smZ!)GZ(J%0^@@E6oAutu4CAC3yRx@|Mm zk@iGA*T?pCsrx^Vh9cj9s`}lilpjE4;0P*!>-O*WP!m2x1@HuQH0541XCH=T=_jD7 zJrxyr0cxJtQ1eejr&2wQhR$>aDn+ZYCZ5D#e1O`q_h@r}gHc6P3pL&kRh&hrg(si_ zoPpZdY}9j0QK{d66>-~W@~;OEFrXbAM)l940{R9O&;?XxJjR%e_}j+gED!#n1RL@D zm#>>z@EdFXmc*m3;Q&;oW}v>Pi_w9*#*+Um8s`~MgrVd3FB4b;U&gnvE&hRdm^I#{ z?k&urUxG^M_oxh9#~OGa>tVe|O2KS?C;B!>QzCl0T|C=O7ehEh{$y7}NlVFUUJsEK-`7G8+_+xme2(9Y6k81qqqj6w%4#2UC8WAGB{{r_Pc zRxUP~Xo^lv)R9IVEJW>WDTd;Dtcf3Eef$CIV#u5P&#st_D%Sa^-#4N%aS`>Udx(88 zV5a#SQ;2@_mtYF6no0guBBI8lYbS(4F=NjF;>K+Ic5jhsD5|Ub3^etoPe$HBx>gs z-ZE94jmq30Y^&!`#dydb{~DF?JE(bmopVj2GInMl9<`J4*c2C{s{9=K<8SDTf1^_D z{kF+m6e=T`n1J1}HcrDb_&x^WZq!B&qXKY#OG7W-My1Sa9+i#h*b5h-Cj1@CW7T&| zmDj}p`i)Q%w?v(7XM4N{I_M8Z1@Ip(gs(26Tx?aJu_#3KNpP~Y-QDSN#1(mTkP??;KDYyxr{qKL5Y3P3XyUYaHs0m-f zL>!4)cqInoUQ|ZDuz!Dq3b4ikb97Bnfptd(T4?J+UDJ;+1i!~9-T(XcK)^zCogAq9 znufX+T~U$0hPu}-Q~-NW3;Y)=q2D5NrctOGO0@eqs0DkVYNil1-wbqCq_K{MCioDQ zx|680y=eDup)&9Y!?65f^L!jCwHX+T%`p`RqXU>GpDK^3HFcU+TnIi9m8T4l? zBmZg~WWa%c*+wllDQ=1b8Gi{w@f421AFu-Uc-Krg6syoL#XS57Tchs^^To`=%Jj#f zGCLo&(NZUkY#JY;uFqrCb@>OiL%&ipVJPYh`=bILgI#eaW~1*)6Hr_9rvCyeW4%xd z6=66QV*^}ik2}Aip=)ywRZPLFOcAA{#&c0sI|3uI1YgH(sEKN>HamL}_53KTgUc`l zk76!9!E)GgjrnWZ5ve_=l}|(0Z7?eG5vVWSX!C$I1#8itk1Dp0P?7#qFp-zQHnh%kJMtz3;Kv9AyNmAB~N$5$gRyR56!qCjXlFJqC1dw_#iS1hpfd zEoKL~s2vYOEm(|EIL{v6idpoJp+0DSTTPLLqvnf7y*C(ru?STQW44n2+B9Y{5RJR+ zAI_mte+#vPr>L{6^u9TYSX8lPV*>U>EifIua4zZtG#}gHN&ENEZDym+CcW_r)&s*Q4(BpQxg$^MR@MnK+uh3$^_YT>P@j2yrUtZyB+f4G2U>EFZ3_{cVJulaVzp~jn`s=pg*;!#)uov5OkhZS)# z`r}5_dv1ID3smMVqcfbwQyQw~(2vYN%_d<0{l4hHLX5z-Q5o5Yb?`i@*gW=`YgHQ+ zNHS_8%~650$FA55Riryn&pqBp{?+jP*nCjJP{k9EN>N*k#7?M*hNDtC2^G)+RO+{( zcKSJLr>8LpZ=&Y;1Iwee-z;1KH6F8{{0GvAXFwA)LiL-YcF+-ZpL<|77NEZQrKnV2 zLrruC_51@&#=lVWC4OQ8Oh=u49xAha?D64FdteIs@WWix4qW#577V4o7q#FSOvG=o z33`8O{zzqGefk4X3opPbxDu7QeW)+r7q(xc0(M@cp(47Dk$4~VLb(Ix&t?>6(N9J_ zHxhOBlTex3gtc)GCg2aY0SC=JZ-mi|cgJ*m9aVEXkoTR|2^!k@cc_T3pmy*lDrH`u znIa5BO_YYJfo`aY3Q-v;wtwG+s*Q7434caq)bn%md=*s9q~f#x|F5n6Ljmf+F<1@X z#u(gzarhgr8O;n&251ao6)C?U?1`6$gw^1qj z1hw#E)O8CxV!miCQD-kUchYqB8XdR>jya%=0Z!f#jnv z4#GM(9CiPfU;?gp($Iuwu_@ld+8BGvyx0--U^i6JO~D2@AG7c%2B7b06L1KscB*0! zHbu?f5w(%Q7>MJrIy&E^p%*q|89a&F$vJf31JscOpE0*13AJEb48*ReBj}HQI2AR| zEW2NVnr|5@BYW}Lkstv%t*bQB_~8NS-X@$i6SYL8u07VkF6fD4Pz#Pj-IgV&v;7Jk zcnfun{k}9Aia}l5KIo65upCZCZ`QYF(@<*Wp;ER6_24dh`~YfaU!!W`66!YGu)Tvi zqMz*F!@e@9b)agiC0cRugNt4*?Dty2D2Hh{UMYHwru$00vL5cH^&5D*DmL_YUuzKW z?+VJ=?+(ol@^odl$Z*YRk?FqMBEZA#-)g%Dm(J*picc1SL2Bnyc^f6@9$2U)W*a8EzYk1mD!zcgt0q1~ef40+A^KPxxr{r~^~ diff --git a/cps/translations/de/LC_MESSAGES/messages.po b/cps/translations/de/LC_MESSAGES/messages.po index 272328a3..10cc56e3 100644 --- a/cps/translations/de/LC_MESSAGES/messages.po +++ b/cps/translations/de/LC_MESSAGES/messages.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" -"PO-Revision-Date: 2021-08-01 17:24+0200\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" +"PO-Revision-Date: 2021-12-22 19:05+0100\n" "Last-Translator: Ozzie Isaacs\n" "Language: de\n" "Language-Team: \n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "Benutzer bearbeiten" msgid "All" msgstr "Alle" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Benutzer nicht gefunden" @@ -92,7 +92,7 @@ msgstr "Zeige alle" msgid "Malformed request" msgstr "Ungültige Anfrage" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Guest Name kann nicht geändert werden" @@ -100,7 +100,7 @@ msgstr "Guest Name kann nicht geändert werden" msgid "Guest can't have this role" msgstr "Guest Benutzer kann diese Rolle nicht haben" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Kein Admin Benutzer verblieben Admin Berechtigung kann nicht entfernt werden" @@ -144,7 +144,7 @@ msgstr "Ungültige Lese Spalte" msgid "Invalid Restricted Column" msgstr "Ungültiger Spaltenname für Einschränkung" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Konfiguration von Calibre-Web wurde aktualisiert" @@ -194,7 +194,7 @@ msgstr "Ort der Calibre Datenbank editieren?" #: cps/admin.py:594 msgid "Are you sure you want delete Calibre-Web's sync database to force a full sync with your Kobo Reader?" -msgstr "" +msgstr "Möchten Sie wirklich die Synchronisationsdatenbank von Calibre-Web löschen, um eine komplette Synchronisation zu erzwingen?" #: cps/admin.py:743 msgid "Tag not found" @@ -204,276 +204,273 @@ msgstr "Tag nicht gefunden" msgid "Invalid Action" msgstr "Ungültige Aktion" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Verbieten" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Erlauben" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" -msgstr "" +msgstr "{} Synchronisationseinträge gelöscht" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json ist nicht für Web Anwendungen konfiguriert" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "Logdatei Pfad ist ungültig, bitte einen gültigen Pfad angeben" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "Zugriffs Logdatei Pfad ist ungültig, bitte einen gültigen Pfad angeben" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Bitte einen LDAP Server, Port, DN und Benutzer Objekt angeben" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "Bitte einen LDAP Service Account und Password eingeben" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Bitte einen LDAP Service Account eingeben" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP Gruppen Objekt Filter benötigt genau eine \"%s\" Format Kennung" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP Gruppen Objekt Filter hat ungleiche Anzahl von Klammern" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP Benutzer Objekt Filter benötigt genau eine \"%s\" Format Kennung" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP Benutzer Objekt Filter hat ungleiche Anzahl von Klammern" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "Der LDAP Member User Filter benötigt genau eine \"%s\" Formatierungsmarkierung" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "LDAP Member User Filter hat eine ungleiche Anzahl von geöffneten und geschlossenen Klammern" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CA-Zertifikat, Zertifikat oder Key Datei ist kein gültiger Pfad" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "Einstellungsdatenbank ist nicht schreibbar" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "DB Pfad ist nicht gültig, bitte einen gültigen Pfad angeben" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Datenbank ist nicht schreibbar" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "Schlüsseldatei ist ungültig, bitte einen gültigen Pfad angeben" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "Zertifikatsdatei ist ungültig, bitte einen gültigen Pfad angeben" -#: cps/admin.py:1335 -#, fuzzy +#: cps/admin.py:1346 msgid "Database Settings updated" -msgstr "Einstellungen des E-Mail-Servers aktualisiert" +msgstr "Datenbankeinstellung aktualisiert" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "Datenbank-Konfiguration" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Bitte alle Felder ausfüllen!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "E-Mail bezieht sich nicht auf eine gültige Domain" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Neuen Benutzer hinzufügen" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Benutzer '%(user)s' angelegt" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "Es existiert bereits ein Account für diese E-Mailadresse oder diesen Benutzernamen." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Benutzer '%(nick)s' gelöscht" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "Guest Benutzer kann nicht gelöscht werden" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Benutzer kann nicht gelöscht werden, es wäre kein Admin Benutzer übrig" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Benutzer %(nick)s bearbeiten" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Benutzer '%(nick)s' aktualisiert" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Es ist ein unbekannter Fehler aufgetreten. Bitte später erneut versuchen." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "SMTP-Einstellungen ändern" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "Gmail Account Verifikation erfolgreich" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Test E-Mail an %(email)s wurde zum Senden in die Warteschlange eingereiht, für das Ergebnis bitte Aufgaben überprüfen" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Es trat ein Fehler beim Versenden der Test-E-Mail auf: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Bitte zuerst E-Mail Adresse konfigurieren..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Einstellungen des E-Mail-Servers aktualisiert" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Passwort für Benutzer %(user)s wurde zurückgesetzt" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Bitte zuerst die SMTP-Einstellung konfigurieren ..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Logdatei Anzeige" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Frage Update an" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Lade Update herunter" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Entpacke Update" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Ersetze Dateien" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Schließe Datenbankverbindungen" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Stoppe Server" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Update abgeschlossen, bitte okay drücken und Seite neu laden" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Update fehlgeschlagen:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP Fehler" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Verbindungsfehler" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Timeout beim Verbindungsaufbau" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Allgemeiner Fehler" -#: cps/admin.py:1720 -#, fuzzy +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "Updatedatei konnte nicht in Temporärem Ordner gespeichert werden" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" -msgstr "" +msgstr "Dateien konnten während des Updates nicht ausgetauscht werden" -#: cps/admin.py:1745 -#, fuzzy +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" -msgstr "Mindestens ein LDAP Benutzer konnte nicht erzeugt werden" +msgstr "Mindestens ein LDAP Benutzer konnte nicht extrahiert werden" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Mindestens ein LDAP Benutzer konnte nicht erzeugt werden" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Fehler: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Fehler: Keine Benutzerinformationen von LDAP Server empfangen" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Mindestens ein LDAP Benutzer wurde nicht in der Datenbank gefudnen" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} Benutzer erfolgreich importiert" @@ -513,9 +510,9 @@ msgid "%(seriesindex)s is not a valid number, skipping" msgstr "%(seriesindex)s ist keine gültige Zahl, Eintrag wird ignoriert" #: cps/editbooks.py:490 cps/editbooks.py:954 -#, fuzzy, python-format +#, python-format msgid "'%(langname)s' is not a valid language" -msgstr "%(langname)s ist keine gültige Sprache" +msgstr "'%(langname)s' ist keine gültige Sprache" #: cps/editbooks.py:630 cps/editbooks.py:981 #, python-format @@ -651,9 +648,9 @@ msgid "Send %(format)s to Kindle" msgstr "Sende %(format)s an Kindle" #: cps/helper.py:219 cps/tasks/convert.py:90 -#, fuzzy, python-format +#, python-format msgid "%(book)s send to Kindle" -msgstr "An Kindle senden" +msgstr "%(book)s an Kindle senden" #: cps/helper.py:224 msgid "The requested file could not be read. Maybe wrong permissions?" @@ -728,7 +725,7 @@ msgstr "Nur jpg/jpeg/png/webp/bmp Dateien werden als Coverdatei unterstützt" #: cps/helper.py:651 msgid "Invalid cover file content" -msgstr "" +msgstr "Ungültiger Cover Dateiinhalt" #: cps/helper.py:655 msgid "Only jpg/jpeg files are supported as coverfile" @@ -1016,7 +1013,7 @@ msgstr "Das Buch wurde dem Bücherregal %(sname)s hinzugefügt" #: cps/shelf.py:126 msgid "You are not allowed to add a book to the shelf" -msgstr "" +msgstr "Keine Erlaubnis Bücher zu diesem Bücherregal hinzuzufügen" #: cps/shelf.py:144 #, python-format @@ -1040,7 +1037,7 @@ msgstr "Das Buch wurde aus dem Bücherregal: %(sname)s entfernt" #: cps/shelf.py:218 msgid "Sorry you are not allowed to remove a book from this shelf" -msgstr "" +msgstr "Sie haben keine Berechtigung um Bücher aus diesem Bücherregal zu löschen" #: cps/shelf.py:228 cps/templates/layout.html:140 msgid "Create a Shelf" @@ -1056,7 +1053,7 @@ msgstr "Bücherregal editieren" #: cps/shelf.py:249 msgid "Sorry you are not allowed to create a public shelf" -msgstr "" +msgstr "Sie haben keine Berechtigung um öffentliche Bücherregal zu erzeugen" #: cps/shelf.py:261 #, python-format @@ -1332,7 +1329,7 @@ msgstr "Konvertierte Datei nicht gefunden, oder mehr als eine Datei im Pfad %(fo msgid "Ebook-converter failed: %(error)s" msgstr "Fehler des EBook-Converters: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre fehlgeschlagen mit Fehler: %(error)s" @@ -1755,9 +1752,8 @@ msgid "Keyword" msgstr "Suchbegriff" #: cps/templates/book_edit.html:233 -#, fuzzy msgid "Search keyword" -msgstr " Suchbegriff " +msgstr "Suche Schlüsselbegriff" #: cps/templates/book_edit.html:239 msgid "Click the cover to load metadata to the form" @@ -1865,21 +1861,19 @@ msgid "Enter Publishers" msgstr "Herausgeber eingeben" #: cps/templates/book_table.html:73 -#, fuzzy msgid "Enter comments" -msgstr "Domainnamen eingeben" +msgstr "Kommentare eingeben" #: cps/templates/book_table.html:73 msgid "Comments" -msgstr "" +msgstr "Kommentare" #: cps/templates/book_table.html:77 cps/templates/book_table.html:79 #: cps/templates/book_table.html:81 cps/templates/book_table.html:83 #: cps/templates/book_table.html:87 cps/templates/book_table.html:89 #: cps/templates/book_table.html:91 cps/templates/book_table.html:93 -#, fuzzy msgid "Enter " -msgstr "IDs" +msgstr "Eingeben " #: cps/templates/book_table.html:110 cps/templates/modal_dialogs.html:46 msgid "Are you really sure?" @@ -1955,7 +1949,7 @@ msgstr "Nightly" #: cps/templates/config_edit.html:50 msgid "Trusted Hosts (Comma Separated)" -msgstr "" +msgstr "Trusted Hosts (Komma separiert)" #: cps/templates/config_edit.html:61 msgid "Logfile Configuration" @@ -1979,7 +1973,7 @@ msgstr "Feature-Konfiguration" #: cps/templates/config_edit.html:104 msgid "Convert non-English characters in title and author while saving to disk" -msgstr "" +msgstr "Nicht-englische Zeichen in Titel und Autor beim Speichern auf Festplatte ersetzen" #: cps/templates/config_edit.html:108 msgid "Enable Uploads" @@ -1987,7 +1981,7 @@ msgstr "Hochladen aktivieren" #: cps/templates/config_edit.html:108 msgid "(Please ensure users having also upload rights)" -msgstr "" +msgstr "(Bitte stellen Sie sicher das sie über die Upload Berechtigung verfügen)" #: cps/templates/config_edit.html:112 msgid "Allowed Upload Fileformats" @@ -2274,14 +2268,12 @@ msgid "Allow Editing Public Shelves" msgstr "Editieren öffentlicher Bücherregale erlauben" #: cps/templates/config_view_edit.html:123 -#, fuzzy msgid "Default Language" -msgstr "Sprachen ausschließen" +msgstr "Default Sprache" #: cps/templates/config_view_edit.html:131 -#, fuzzy msgid "Default Visible Language of Books" -msgstr "Zeige nur Bücher mit dieser Sprache" +msgstr "Defaulteinstellung sichtbare Büchersprache" #: cps/templates/config_view_edit.html:147 msgid "Default Visibilities for New Users" @@ -2451,11 +2443,11 @@ msgstr "Benutzer ausloggem" #: cps/templates/index.html:69 msgid "Sort ascending according to download count" -msgstr "" +msgstr "Aufsteigend nach Downloadzahlen sortieren" #: cps/templates/index.html:70 msgid "Sort descending according to download count" -msgstr "" +msgstr "Absteigend nach Downloadzahlen sortieren" #: cps/templates/index.html:76 cps/templates/search.html:33 #: cps/templates/shelf.html:21 @@ -2568,7 +2560,7 @@ msgstr "Fehler" msgid "Upload done, processing, please wait..." msgstr "Hochladen beendet, verarbeite Daten, bitte warten..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Einstellungen" @@ -2717,7 +2709,7 @@ msgstr "Calibre-Web E-Book-Katalog" msgid "epub Reader" msgstr "epub-Leser" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Text umbrechen, wenn Seitenleiste geöffnet ist." @@ -3071,7 +3063,7 @@ msgstr "Erzeugen/Ansehen" #: cps/templates/user_edit.html:70 msgid "Force full kobo sync" -msgstr "" +msgstr "Komplettsynchronisation Kobo erzwingen" #: cps/templates/user_edit.html:88 msgid "Add allowed/Denied Custom Column Values" @@ -3150,9 +3142,8 @@ msgid "Edit Denied Column Values" msgstr "Erlaubte/Verbotene Calibre Spalten bearbeiten" #: cps/templates/user_table.html:142 -#, fuzzy msgid "Denied Column Values" -msgstr "Verbotene Calibre Spalten" +msgstr "Verbotene Spaltennamen" #: cps/templates/user_table.html:144 msgid "Change Password" @@ -3167,7 +3158,6 @@ msgid "Edit Public Shelves" msgstr "Öffentliche Bücherregale bearbeiten" #: cps/templates/user_table.html:152 -#, fuzzy msgid "Sync selected Shelves with Kobo" msgstr "Ausgesuchte Bücherregale mit Kobo synchronisieren" diff --git a/cps/translations/el/LC_MESSAGES/messages.mo b/cps/translations/el/LC_MESSAGES/messages.mo index bbafe7de47247c277900badefb9cde8cbc43c6d7..4068708c27897b675a4e7d1e2ee166871fbc2096 100644 GIT binary patch delta 30 mcmeA@%-nmJdBfy54kIH4LrW_Iv(2;PI2jo&HZP5T(Fg#i)e7wZ delta 30 mcmeA@%-nmJdBfy54nq?KLvt%r!_BkfI2jo&H!qEU(Fg#i(hBVW diff --git a/cps/translations/el/LC_MESSAGES/messages.po b/cps/translations/el/LC_MESSAGES/messages.po index c4389f7a..2afe1a0a 100644 --- a/cps/translations/el/LC_MESSAGES/messages.po +++ b/cps/translations/el/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Depountis Georgios\n" "Language: el\n" @@ -15,7 +15,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "Χρήστης Διαχειριστής" msgid "All" msgstr "Όλα" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Δεν βρέθηκε χρήστης" @@ -92,7 +92,7 @@ msgstr "Προβολή Όλων" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -100,7 +100,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Δεν έχει απομείνει χρήστης διαχειριστής, δεν μπορεί να αφαιρεθεί ο ρόλος διαχειριστή" @@ -144,7 +144,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Ενημερώθηκε η διαμόρφωση Calibre-Web" @@ -208,279 +208,279 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Απόρριψη" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Επιτρέπεται" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json Δεν Έχει Διαμορφωθεί Για Διαδικτυακή Εφαρμογή" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "Το Φύλλο Καταγραφής Τοποθεσίας δεν είναι Έγκυρο, Παρακαλούμε Συμπλήρωσε Τη Σωστή Πορεία" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "Η Πρόσβαση Φύλλου Καταγραφης Τοποθεσίας δεν είναι έγκυρη, Παρακαλούμε Συμπλήρωσε Τη Σωστή Πορεία" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Παρακαλούμε Συμπλήρωσε ένα Πάροχο LDAP, Θύρα, DN και Αντικείμενο Αναγνώρισης Χρήστη" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Παρακαλούμε συμπλήρωσε ένα έγκυρο όνομα χρήστη για επαναφορά του κωδικού" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "Το Αντικείμενο Φίλτρου Ομάδας LDAP Πρέπει να Έχει Μια \"%s\" Αναγνώριση Μορφής" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "Το Αντικείμενο Φίλτρου Ομάδας LDAP Έχει Παρενθέσεις Που Δεν Ταιριάζουν" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "Το Αντικείμενο Φίλτρου Χρήστη LDAP πρέπει να Έχει Μια \"%s\" Αναγνώριση Μορφής" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "Το Αντικείμενο Φίλτρου Χρήστη LDAP Έχει Παρενθέσεις Που Δεν Ταιριάζουν" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "Οι ρυθμίσεις DB δεν μπορούν να Γραφτούν" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "Η Τοποθεσία DB δεν είναι Έγκυρη, Παρακαλούμε Συμπληρώστε Τη Σωστή Πορεία" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Η DB δεν μπορεί να Γραφτεί" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "Το Αρχειο Κλειδί Τοποθεσίας δεν είναι Έγκυρο, Παρακαλούμε Συμπληρώστε Τη Σωστή Πορεία" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "Η Τοποθεσία Certfile δεν είναι Έγκυρη, Παρακαλούμε Συμπληρώστε Τη Σωστή Πορεία" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Ενημερώθηκαν οι ρυθμίσεις E-mail διακομιστή" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Διαμόρφωση Λειτουργίας" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Παρακαλούμε συμπλήρωσε όλα τα πεδία!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "Το E-mail δεν είναι από έγκυρο domain" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Προσθήκη νέου χρήστη" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Χρήστης/ες '%(user)s' δημιουργήθηκαν" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Βρέθηκε ένας ήδη υπάρχον λογαριασμός για αυτή τη διεύθυνση e-mail ή όνομα χρήστη." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Χρήστης/ες '%(nick)s' διαγράφηκαν" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Δεν έχει απομείνει χρήστης διαχειριστής, δεν μπορεί να διαγραφεί ο χρήστης" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Επεξεργασία χρήστη %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Χρήστης/ες '%(nick)s' ενημερώθηκαν" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Προέκυψε ένα άγνωστο σφάλμα. Παρακαλούμε δοκίμασε ξανά αργότερα." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Επεξεργασία Ρυθμίσεων E-mail Διακομιστή" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Παρουσιάστηκε σφάλμα κατά την αποστολή του δοκιμαστικού e-mail:% (res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Παρακαλούμε ρύθμισε πρώτα τη διεύθυνση e-mail σου..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Ενημερώθηκαν οι ρυθμίσεις E-mail διακομιστή" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Κωδικός για επαναφορά %(user) χρήστη/ών" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Παρακαλούμε διαμόρφωσε πρώτα τις ρυθμίσεις ταχυδρομείου SMTP..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Προβολέας αρχείου φύλλου καταγραφής" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Αίτημα πακέτου ενημέρωσης" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Κατεβάζει πακέτο ενημέρωσης" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Ανοίγει πακέτο ενημέρωσης" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Αντικατάσταση αρχείων" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Οι συνδέσεις βάσης δεδομένων είναι κλειστές" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Σταματάει το διακομιστή" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Η ενημέρωση τελειώσε, παρακαλούμε πιέστε το εντάξει και φορτώστε ξανά τη σελίδα" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Η ενημέρωση απέτυχε:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP Σφάλμα" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Σφάλμα σύνδεσης" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Τελείωσε ο χρόνος κατά την προσπάθεια δημιουργίας σύνδεσης" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Γενικό σφάλμα" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Το Αρχείο Ενημέρωσης Δεν Μπόρεσε Να Αποθηκευτεί σε" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Αποτυχία Δημιουργίας Τουλάχιστον Ενός Χρήστη LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Αποτυχία Δημιουργίας Τουλάχιστον Ενός Χρήστη LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Σφάλμα: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Σφάλμα: Δεν επιστράφηκε χρήστης σε απάντηση του διακομιστή LDAP" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Τουλάχιστον Ένας Χρήστης LDAP Δεν Βρέθηκε Στη Βάση Δεδομένων" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1342,7 +1342,7 @@ msgstr "Το τροποποιημένο αρχείο δεν βρέθηκε ή υ msgid "Ebook-converter failed: %(error)s" msgstr "Ο μετατροπέας Ebook απέτυχε: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Το Calibre απέτυχε με σφάλμα: %(error)s" @@ -2581,7 +2581,7 @@ msgstr "Σφάλμα" msgid "Upload done, processing, please wait..." msgstr "Το ανέβασμα έγινε, γίνεται επεξεργασία, παρακαλούμε περίμενε..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Ρυθμίσεις" @@ -2732,7 +2732,7 @@ msgstr "Calibre-Web Κατάλογος eBook" msgid "epub Reader" msgstr "PDF πρόγραμμα ανάγνωσης" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Επανάληψη ροής κειμένου όταν οι μπάρες στο πλάι είναι ανοιχτές." diff --git a/cps/translations/es/LC_MESSAGES/messages.mo b/cps/translations/es/LC_MESSAGES/messages.mo index a476236c2e4db4569fabd707fd52271759f95fda..aaa84a38823366c0be19402b88b798683e733d90 100644 GIT binary patch delta 30 mcmZpg$lNrMdBf>^4kIH4LrW_Iv(1^4nq?KLvt%r!_AlT6&M*UH-9PU3kLwDkqY<# diff --git a/cps/translations/es/LC_MESSAGES/messages.po b/cps/translations/es/LC_MESSAGES/messages.po index 4254df10..3047167f 100644 --- a/cps/translations/es/LC_MESSAGES/messages.po +++ b/cps/translations/es/LC_MESSAGES/messages.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-05-25 17:22+0200\n" "Last-Translator: minakmostoles \n" "Language: es\n" @@ -18,7 +18,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -79,7 +79,7 @@ msgstr "Editar usuarios" msgid "All" msgstr "Todo" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Usuario no encontrado" @@ -96,7 +96,7 @@ msgstr "Mostrar todo" msgid "Malformed request" msgstr "Petición mal formulada" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "El nombre de invitado no se puede cambiar" @@ -104,7 +104,7 @@ msgstr "El nombre de invitado no se puede cambiar" msgid "Guest can't have this role" msgstr "El invitado no puede tener ese rol" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "No queda ningún usuario administrador, no se puede eliminar al usuario" @@ -148,7 +148,7 @@ msgstr "Columna de lectura no válida" msgid "Invalid Restricted Column" msgstr "Columna restringida no válida" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Configuración de Calibre-Web actualizada" @@ -212,279 +212,279 @@ msgstr "Etiqueta no encontrada" msgid "Invalid Action" msgstr "Acción no válida" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Denegar" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Permitir" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json no está configurado para la aplicación web" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "La ruta del Logfile no es válida. Por favor, Introduce la ruta correcta" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "La ruta del Access Logfile no es válida. Por favor, Introduce la ruta correcta" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Por favor, Introduce un proveedor LDAP, puerto, DN y el User Object Identifier" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Por favor, introduce una cuenta de servicio LDAP y su contraseña" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Por favor, introduce una cuenta de servicio LDAP" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP Group Object Filter necesita tener un identificador de formato \"%s\"" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "El LDAP Group Object Filter tiene un paréntesis diferente" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP Group Object Filter necesita tener un identificador de formato \"%s\"" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "El LDAP Group Object Filter tiene un paréntesis diferente" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "El filtro de usuarios LDAP necesita tener un identificador de formato \"%s\"" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "El filtro de LDAP \"Member User\" tiene paréntesis no coincidentes" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "Ubicaciones del certificado de la CA del LDAP, del certificado o de la clave no válidos. Por favor introduce la ruta correcta" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "La base de datos de configuración no es modificable" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "La ruta de la base de datos no es válida. Por favor, Introduce la ruta correcta" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "La base de datos no es modificable" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "La ruta del Keyfile no es válida, por favor, Introduce la ruta correcta" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "La ruta de Certfile no es válida, por favor, Introduce la ruta correcta" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Actualizados los ajustes del servidor de correo electrónico" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Configuración de la base de datos" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "¡Por favor, rellena todos los campos!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "El correo electrónico no tiene un dominio válido" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Añadir un nuevo usuario" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Usuario '%(user)s' creado" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Encontrada una cuenta existente para este correo electrónico o nombre de usuario." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Usuario '%(nick)s' eliminado" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "No puedes borrar al Usuario Invitado" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "No queda ningún usuario administrador, no se puede borrar al usuario" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Editar Usuario %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Usuario '%(nick)s' actualizado" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Ha ocurrido un error desconocido. Por favor vuelva a intentarlo más tarde." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Cambiar parámetros de correo" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "Verificación de cuenta de Gmail exitosa" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Puesto en cola un correo electrónico de prueba enviado a %(email)s, por favor, comprueba el resultado en Tareas" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Ocurrió un error enviando el correo electrónico de prueba: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Por favor, configure su correo electrónico primero..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Actualizados los ajustes del servidor de correo electrónico" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Contraseña para el usuario %(user)s reinicializada" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Configura primero los parámetros del servidor SMTP..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Visor del fichero de log" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Solicitando paquete de actualización" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Descargando paquete de actualización" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Descomprimiendo paquete de actualización" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Remplazando archivos" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Los conexiones con la base datos están cerradas" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Parando el servidor" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Actualización finalizada. Por favor, pulse OK y recargue la página" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Falló la actualización:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "Error HTTP" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Error de conexión" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Tiempo agotado mientras se trataba de establecer la conexión" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Error general" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "La actualización del archivo no pudo guardarse en el directorio temporal (Temp Dir)" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Error al crear al menos un usuario LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Error al crear al menos un usuario LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Error: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Error: el servidor LDAP no ha devuelto ningún usuario" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Al menos, un usuario LDAP no se ha encontrado en la base de datos" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} Usuario importado con éxito" @@ -1346,7 +1346,7 @@ msgstr "Archivo convertido no encontrado, o más de un archivo en el directorio msgid "Ebook-converter failed: %(error)s" msgstr "Falló Ebook-converter: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre falló con el error: %(error)s" @@ -2585,7 +2585,7 @@ msgstr "Error" msgid "Upload done, processing, please wait..." msgstr "Carga hecha, procesando, por favor espere ..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Ajustes" @@ -2736,7 +2736,7 @@ msgstr "Catálogo de ebooks de Calibre-Web" msgid "epub Reader" msgstr "Lector PDF" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Redimensionar el texto cuando las barras laterales están abiertas." diff --git a/cps/translations/fi/LC_MESSAGES/messages.mo b/cps/translations/fi/LC_MESSAGES/messages.mo index a6a8eea22ae2015996cf9171da50daf962aad316..6493ba4325f76fbee08206f00e39284f80e0ea76 100644 GIT binary patch delta 30 mcmZ2>jd9sE#tkL*97aY8hL%jd9sE#tkL*9EK(ehUQkLhMQ~br5G74H&1uy*8%{j3kmrE diff --git a/cps/translations/fi/LC_MESSAGES/messages.po b/cps/translations/fi/LC_MESSAGES/messages.po index 5fe3845e..ae6cbd12 100644 --- a/cps/translations/fi/LC_MESSAGES/messages.po +++ b/cps/translations/fi/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-01-12 13:56+0100\n" "Last-Translator: Samuli Valavuo \n" "Language: fi\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -76,7 +76,7 @@ msgstr "Pääkäyttäjä" msgid "All" msgstr "Kaikki" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -93,7 +93,7 @@ msgstr "Näytä kaikki" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -101,7 +101,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -145,7 +145,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web asetukset päivitetty" @@ -209,276 +209,276 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Sähköpostipalvelimen tiedot päivitetty" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Ominaisuuksien asetukset" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Ole hyvä ja täytä kaikki kentät!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "Sähköpostiosoite ei ole toimivasta domainista" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Lisää uusi käyttäjä" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Käyttäjä '%(user)s' lisätty" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Tälle sähköpostiosoitteelle tai tunnukselle löytyi jo tili." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Käyttäjä '%(nick)s' poistettu" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Pääkäyttäjiä ei jää jäljelle, käyttäjää ei voi poistaa" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Muokkaa käyttäjää %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Käyttäjä '%(nick)s' päivitetty" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Tapahtui tuntematon virhe. Yritä myöhemmin uudelleen." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Muuta SMTP asetuksia" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Testisähköpostin lähetyksessä tapahtui virhe: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Sähköpostipalvelimen tiedot päivitetty" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Käyttäjän %(user)s salasana palautettu" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Ole hyvä ja aseta SMTP postiasetukset ensin..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Lokitiedoston katselin" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Haetaan päivitystiedostoa" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Ladataan päivitystiedostoa" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Puretaan päivitystiedostoa" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Korvataan tiedostoja" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Tietokantayhteydet on katkaistu" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Sammutetaan palvelin" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Päivitys valmistui, ole hyvä ja paina OK ja lataa sivu uudelleen" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Päivitys epäonnistui:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP virhe" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Yhteysvirhe" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Aikakatkaisu yhteyttä luotaessa" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Yleinen virhe" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1340,7 +1340,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "E-kirjan muunnos epäonnistui: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2577,7 +2577,7 @@ msgstr "Virhe" msgid "Upload done, processing, please wait..." msgstr "Lataus tehty, prosessoidaan, ole hyvä ja odota..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Asetukset" @@ -2728,7 +2728,7 @@ msgstr "Calibre-Web e-kirjaluettelo" msgid "epub Reader" msgstr "PDF lukija" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Uudelleenjärjestä teksti kun sivut on auki." diff --git a/cps/translations/fr/LC_MESSAGES/messages.mo b/cps/translations/fr/LC_MESSAGES/messages.mo index 1c4e6de1da68267433118f1c0b12870ff2029506..ed7751543b7f4b78e543f112eedb1f6a3dcadf29 100644 GIT binary patch delta 30 mcmZ3wn0e`9<_-1v97aY8hL%\n" "Language: fr\n" @@ -31,7 +31,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -91,7 +91,7 @@ msgstr "Utilisateur admin" msgid "All" msgstr "Tout" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "L'utilisateur n'a pas été trouvé" @@ -108,7 +108,7 @@ msgstr "Montrer tout" msgid "Malformed request" msgstr "Demande malformée" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Le nom de l’invité ne peut pas être modifié" @@ -116,7 +116,7 @@ msgstr "Le nom de l’invité ne peut pas être modifié" msgid "Guest can't have this role" msgstr "L’invité ne peut pas avoir ce rôle" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Aucun utilisateur admin restant, impossible de supprimer le rôle admin" @@ -160,7 +160,7 @@ msgstr "Colonne de lecture non valide" msgid "Invalid Restricted Column" msgstr "Colonne restreinte non valide" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Configuration de Calibre-Web mise à jour" @@ -224,279 +224,279 @@ msgstr "Étiquette introuvable" msgid "Invalid Action" msgstr "Action invalide" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Refuser" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Autoriser" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json n'est pas configuré pour l'application Web" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "L'emplacement du fichier logfile est incorrect, veuillez saisir un chemin valide" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "L'emplacement du fichier Access Logfile est incorrect, veuillez saisir un chemin valide" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Veuillez saisir un fournisseur LDAP, Port, DN et l'identifiant objet de l'utilisateur" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Veuillez entrer un nom d'utilisateur valide pour réinitialiser le mot de passe" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Veuillez entrer un compte de service LDAP" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "Le filtre objet du groupe LDAP a besoin d'un identifiant de format \"%s\"" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "Le filtre objet du groupe LDAP a une parenthèse non gérée" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "Le filtre objet de l'utilisateur LDAP a besoin d'un identifiant de format \"%s\"" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "Le filtre objet de l'utilisateur LDAP a une parenthèse non gérée" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "Le filtre utilisateur des membres LDAP doit avoir un identificateur de format \"%s\\ »" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "Le filtre utilisateur de membre LDAP a des parenthèses non appariées" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CACertificat, certificat ou emplacement de clé non valide, veuillez entrer le chemin correct" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "Les paramètres de la base de données ne sont pas accessibles en écriture" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "L'emplacement de la base de données est incorrect, veuillez saisir un chemin valide" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "La base de données n'est pas accessible en écriture" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "L'emplacement du fichier Keyfile est incorrect, veuillez saisir un chemin valide" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "L'emplacement du fichier Certfile est incorrect, veuillez saisir un chemin valide" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Les paramètres du serveur de courriels ont été mis à jour" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Configuration des options" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Veuillez compléter tous les champs !" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "Cette adresse de courriel n’appartient pas à un domaine valide" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Ajouter un nouvel utilisateur" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Utilisateur '%(user)s' créé" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Un compte existant a été trouvé pour cette adresse de courriel ou pour ce surnom." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Utilisateur '%(nick)s' supprimé" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "Impossible de supprimer l’utilisateur Invité" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Aucun utilisateur admin restant, impossible de supprimer l’utilisateur" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Éditer l'utilisateur %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Utilisateur '%(nick)s' mis à jour" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Une erreur inconnue est survenue. Veuillez réessayer plus tard." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Modifier les paramètres du serveur de courriels" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "La vérification du compte Gmail réussie" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Teste les courriels en file d’attente pour l’envoi à %(email)s, veuillez vérifier le résultat des tâches" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Il y a eu une erreur pendant l’envoi du courriel de test : %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Veuillez d'abord configurer votre adresse de courriel..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Les paramètres du serveur de courriels ont été mis à jour" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Le mot de passe de l’utilisateur %(user)s a été réinitialisé" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Veuillez configurer les paramètres SMTP au préalable..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Visualiseur de fichier journal" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Demande de mise à jour" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Téléchargement de la mise à jour" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Décompression de la mise à jour" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Remplacement des fichiers" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Les connexions à la base de données ont été fermées" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Arrêt du serveur" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Mise à jour terminée, merci d’appuyer sur okay et de rafraîchir la page" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "La mise à jour a échoué :" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "Erreur HTTP" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Erreur de connexion" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Délai d'attente dépassé lors de l'établissement de connexion" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Erreur générale" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Le fichier de mise à jour ne peut pas être sauvegardé dans le répertoire temporaire" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Impossible de créer au moins un utilisateur LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Impossible de créer au moins un utilisateur LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Erreur : %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Erreur : Aucun utilisateur renvoyé dans la réponse LDAP du serveur" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Au moins un utilisateur LDAP n'a pas été trouvé dans la base de données" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} utilisateur importé avec succès" @@ -1358,7 +1358,7 @@ msgstr "Fichier converti non trouvé ou plus d'un fichier dans le chemin %(folde msgid "Ebook-converter failed: %(error)s" msgstr "La commande ebook-convert a échouée : %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre a échoué avec l’erreur : %(error)s" @@ -2597,7 +2597,7 @@ msgstr "Erreur" msgid "Upload done, processing, please wait..." msgstr "Téléversement terminé, traitement en cours, veuillez patienter…." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Paramètres" @@ -2748,7 +2748,7 @@ msgstr "Catalogue de livres électroniques Calibre-Web" msgid "epub Reader" msgstr "Lecteur PDF" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Mettre à jour la mise en page du texte quand les bandeaux latéraux sont ouverts." diff --git a/cps/translations/hu/LC_MESSAGES/messages.mo b/cps/translations/hu/LC_MESSAGES/messages.mo index 58ac79655713c9103519382572063fdfce35a686..58121e6b00bb63d7a8f3f1d9dd7e93db98673cd5 100644 GIT binary patch delta 29 lcmca}gz?T1#tj?HIE;)G3@xn;%qH(J`_5>wd4u^HEdaFV3y=T+ delta 29 lcmca}gz?T1#tj?HI1Eh`49%@f4JYp~`_5>&d4u^HEdaFT3y=T+ diff --git a/cps/translations/hu/LC_MESSAGES/messages.po b/cps/translations/hu/LC_MESSAGES/messages.po index 7e6b1ed7..6012214d 100644 --- a/cps/translations/hu/LC_MESSAGES/messages.po +++ b/cps/translations/hu/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2019-04-06 23:36+0200\n" "Last-Translator: \n" "Language: hu\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -76,7 +76,7 @@ msgstr "Rendszergazda felhasználó" msgid "All" msgstr "" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -93,7 +93,7 @@ msgstr "Mindent mutass" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -101,7 +101,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -145,7 +145,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "A Calibre-Web konfigurációja frissítve." @@ -209,276 +209,276 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Az e-mail kiszolgáló beállításai frissítve." -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Funkciók beállítása" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Az összes mezőt ki kell tölteni!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "Az e-mail tartománya nem érvényes." -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Új felhasználó hozzáadása" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "A következő felhasználó létrehozva: %(user)s" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Már létezik felhasználó ehhez az e-mail címhez vagy felhasználói névhez." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "A felhasználó törölve: %(nick)s" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr " A felhasználó szerkesztése: %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "A felhasználó frissítve: %(nick)s" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Ismeretlen hiba történt. Próbáld újra később!" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "SMTP beállítások változtatása" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Hiba történt a teszt levél küldése során: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Az e-mail kiszolgáló beállításai frissítve." -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "A(z) %(user)s felhasználó jelszavának alaphelyzetbe állítása" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Először be kell állítani az SMTP levelező beállításokat..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Frissítési csomag kérése" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Frissítési csomag letöltése" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Frissítési csomag kitömörítése" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Fájlok cserélése" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Adatbázis kapcsolatok lezárva" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Szerver leállítása" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "A frissítés települt, kattints az OK-ra és újra tölt az oldal" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "A frissítés nem sikerült:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP hiba" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Kapcsolódási hiba" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Időtúllépés a kapcsolódás során" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Általános hiba" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1340,7 +1340,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "Az e-könyv átalakítás nem sikerült: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2577,7 +2577,7 @@ msgstr "Hiba" msgid "Upload done, processing, please wait..." msgstr "Feltöltés kész, feldolgozás alatt, kérlek várj..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Beállítások" @@ -2727,7 +2727,7 @@ msgstr "Calibre-Web e-könyv katalógus" msgid "epub Reader" msgstr "" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Szöveg újratördelése amikor az oldalsávok nyitva vannak" diff --git a/cps/translations/it/LC_MESSAGES/messages.mo b/cps/translations/it/LC_MESSAGES/messages.mo index f7ef8e8940133c1e4fabd9a8f7639243a319058f..f53f178d6988a9708ba6127dbcc2aac6247f86a9 100644 GIT binary patch delta 30 mcmaF8o%!{4<_$g-97aY8hL%\n" "Language: it\n" @@ -15,7 +15,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -74,7 +74,7 @@ msgstr "Modifica gli utenti" msgid "All" msgstr "Tutti" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Utente non trovato" @@ -91,7 +91,7 @@ msgstr "tutte le lingue presenti" msgid "Malformed request" msgstr "Richiesta non formulata correttamente" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Il nome dell'utente Guest (ospite) non può essere modificato" @@ -99,7 +99,7 @@ msgstr "Il nome dell'utente Guest (ospite) non può essere modificato" msgid "Guest can't have this role" msgstr "L'utente Guest (ospite) non può avere questo ruolo" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Non rimarrebbe nessun utente amministratore, non posso rimuovere il ruolo di amministratore" @@ -143,7 +143,7 @@ msgstr "Colonna di lettura non valida" msgid "Invalid Restricted Column" msgstr "Colonna con restrizioni non valida" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "La configurazione di Calibre-Web è stata aggiornata" @@ -204,273 +204,273 @@ msgstr "Etichetta non trovata" msgid "Invalid Action" msgstr "Azione non valida" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Nega" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Permetti" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json non è configurato per Web Application" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "La posizione del Logfile non è valida, per favore indica il percorso corretto" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "La posizione dell'Access Logfile non è valida, per favore indica il percorso corretto" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Per favore digita un Provider LDAP, porta, DN e User Object Identifier" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "Per favore digita nome di utente e password del servizio LDAP" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Per favore indica un account di servizio LDAP" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP Group Object Filter deve avere un \"%s\" Format Identifier" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP Group Object Filter contiene una parentesi senza la corrispettiva" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP User Object Filter deve avere un \"%s\" Format Identifier" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP User Object Filter contiene una parentesi senza la corrispettiva" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP Member User Filter deve avere un \"%s\" Format Identifier" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "LDAP Member User Filter contiene una parentesi senza la corrispettiva" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CACertificate, il certificato o la posizione della chiave non sono corretti, per favore indica il percorso corretto" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "I parametri del DB non sono scrivibili" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "La posizione del DB non è valida, per favore indica il percorso corretto" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Il DB non è scrivibile" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "La posizione del Keyfile non è valida, per favore indica il percorso corretto" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "La posizione del Certfile non è valida, per favore indica il percorso corretto" -#: cps/admin.py:1335 +#: cps/admin.py:1346 msgid "Database Settings updated" msgstr "Configurazione del Database aggiornata" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "Configurazione del Database" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Per favore compila tutti i campi!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "L'e-mail non proviene da un dominio valido" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Aggiungi un nuovo utente" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "L'utente '%(user)s' è stato creato" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "Trovato un account esistente con questo e-mail o nome di utente" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "L'utente '%(nick)s' è stato eliminato" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "Non posso eliminare l'utente Guest (ospite)" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Non rimarrebbe nessun utente amministratore, non posso eliminare l'utente" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Modifica l'utente %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "L'utente '%(nick)s' è stato aggiornato" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Si è verificato un errore sconosciuto: per favore riprova." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Modifica le impostazioni del server e-mail" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "L'account Gmail è stato verificato con successo" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "L'e-mail di test è stato accodato con successo per essere spedito a %(email)s, per favore verifica tramite il pulsante 'Compito' il risultato" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Si è verificato un errore nell'invio dell'e-mail di test: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Per favore prima configura il tuo indirizzo e-mail..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Configurazione del server e-mail aggiornata" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "La password dell'utente %(user)s è stata resettata" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Configura dapprima le impostazioni del server SMTP..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Visualizzatore del Logfile" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Richiedo il pacchetto di aggiornamento" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Scarico il pacchetto di aggiornamento" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Decomprimo il pacchetto di aggiornamento" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Sostituisco i file" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Le connessioni al database sono chiuse" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Arresto il server" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Aggiornamento completato, per favore premi ok e ricarica la pagina" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Aggiornamento non riuscito:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "Errore HTTP" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Errore di connessione" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Tempo scaduto nello stabilire la connessione" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Errore generale" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "Il file di aggiornamento non può essere salvato nella cartella temporanea" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "Durante l'aggiornamento non è stato possibile sostituire alcuni file" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "Fallita l'estrazione di almeno un utente LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Fallita la creazione di almeno un utente LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Errore: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Errore: nessun utente restituito in risposta dal server LDAP" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Almeno un utente LDAP non è stato trovato nel database" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} utente importato con successo" @@ -1330,7 +1330,7 @@ msgstr "Non ho trovato il file convertito o ci sono più di un file nella cartel msgid "Ebook-converter failed: %(error)s" msgstr "Errore nel convertitore: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Si è verificato un errore con Calibre: %(error)s" @@ -2562,7 +2562,7 @@ msgstr "Errore" msgid "Upload done, processing, please wait..." msgstr "Caricamento riuscito, sto elaborando, per favore aspetta..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Configurazione" @@ -2711,7 +2711,7 @@ msgstr "Catalogo Calibre-Web" msgid "epub Reader" msgstr "Lettore epub" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Adatta il testo quando le barre laterali sono aperte." diff --git a/cps/translations/ja/LC_MESSAGES/messages.mo b/cps/translations/ja/LC_MESSAGES/messages.mo index afb7b8da638e2cdebe0aa2c13ad21330f93b5b73..e0ea3ef77ac4ddc2c8a45f3da5fe51e014946399 100644 GIT binary patch delta 30 mcmZpk&DcDfaYLCFhmnzjp{139+2%SeX+}ni&9k&$DgpqJy9rkS delta 30 mcmZpk&DcDfaYLCFhoOmrp}Cc*;pRFmX+}oN&9k&$DgpqJxCvJP diff --git a/cps/translations/ja/LC_MESSAGES/messages.po b/cps/translations/ja/LC_MESSAGES/messages.po index c8bfd79e..805e925b 100644 --- a/cps/translations/ja/LC_MESSAGES/messages.po +++ b/cps/translations/ja/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2018-02-07 02:20-0500\n" "Last-Translator: white \n" "Language: ja\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "" msgid "All" msgstr "" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -92,7 +92,7 @@ msgstr "" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -100,7 +100,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -144,7 +144,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web の設定を更新しました" @@ -204,276 +204,276 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "メールサーバの設定を更新しました" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "機能設定" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "全ての項目を入力してください" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "このメールは有効なドメインからのものではありません" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "新規ユーザ追加" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "ユーザ '%(user)s' を作成しました" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "このメールアドレスかニックネームで登録されたアカウントが見つかりました" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "ユーザ '%(nick)s' を削除しました" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "%(nick)s を編集" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "ユーザ '%(nick)s' を更新しました" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "不明なエラーが発生しました。あとで再試行してください。" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "SMTP設定を変更" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "テストメールを %(res)s に送信中にエラーが発生しました" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "メールサーバの設定を更新しました" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "%(user)s 用のパスワードをリセット" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "初めにSMTPメールの設定をしてください" -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "更新データを要求中" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "更新データをダウンロード中" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "更新データを展開中" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "ファイルを置換中" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "データベースの接続を切断完了" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "サーバ停止中" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "アップデート完了、OKを押してページをリロードしてください" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "アップデート失敗:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTPエラー" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "接続エラー" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "接続を確立中にタイムアウトしました" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "エラー発生" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1335,7 +1335,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "Ebook-converter が失敗しました: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2571,7 +2571,7 @@ msgstr "エラー" msgid "Upload done, processing, please wait..." msgstr "アップロード完了。現在処理中ですのでお待ち下さい..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "設定" @@ -2721,7 +2721,7 @@ msgstr "" msgid "epub Reader" msgstr "" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "" diff --git a/cps/translations/km/LC_MESSAGES/messages.mo b/cps/translations/km/LC_MESSAGES/messages.mo index ffd09157b05df02247331d69602fb202cd19cf90..59d510d611b0a5f7af96521f3ae1abd8d591b1fb 100644 GIT binary patch delta 29 lcmaE~pYhRt#tjEmIE;)G3@xn;%qE{y`OIjsd9SLf6#%ph3lIPR delta 29 lcmaE~pYhRt#tjEmI1Eh`49%@f4JV&e`OIj!d9SLf6#%pf3lIPR diff --git a/cps/translations/km/LC_MESSAGES/messages.po b/cps/translations/km/LC_MESSAGES/messages.po index e867d38c..196ea2a9 100644 --- a/cps/translations/km/LC_MESSAGES/messages.po +++ b/cps/translations/km/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2018-08-27 17:06+0700\n" "Last-Translator: \n" "Language: km_KH\n" @@ -17,7 +17,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -77,7 +77,7 @@ msgstr "អ្នកប្រើប្រាស់រដ្ឋបាល" msgid "All" msgstr "" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -94,7 +94,7 @@ msgstr "បង្ហាញទាំងអស់" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -102,7 +102,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -146,7 +146,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "" @@ -210,275 +210,275 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "ទំនាក់ទំនងទៅមូលដ្ឋានទិន្នន័យត្រូវបានផ្តាច់" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "ការកំណត់មុខងារ" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "សូមបំពេញចន្លោះទាំងអស់!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "បន្ថែមអ្នកប្រើប្រាស់ថ្មី" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "បានបង្កើតអ្នកប្រើប្រាស់ ‘%(user)s’" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "អ្នកប្រើប្រាស់ ‘%(nick)s’ ត្រូវបានលុប" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "កែប្រែអ្នកប្រើប្រាស់ %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "អ្នកប្រើប្រាស់ ‘%(nick)s’ ត្រូវបានកែប្រែ" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "ប្តូរការកំណត់ SMTP" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "សូមកំណត់អ៊ីមែល SMTP ជាមុនសិន" -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "កំពុងស្នើសុំឯកសារបច្ចុប្បន្នភាព" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "កំពុងទាញយកឯកសារបច្ចុប្បន្នភាព" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "កំពុងពន្លាឯកសារបច្ចុប្បន្នភាព" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "ទំនាក់ទំនងទៅមូលដ្ឋានទិន្នន័យត្រូវបានផ្តាច់" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "ការធ្វើបច្ចុប្បន្នភាពបានបញ្ចប់ សូមចុច okay រួចបើកទំព័រជាថ្មី" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1339,7 +1339,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "Ebook-converter បានបរាជ័យ៖ %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2575,7 +2575,7 @@ msgstr "" msgid "Upload done, processing, please wait..." msgstr "" -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "ការកំណត់" @@ -2725,7 +2725,7 @@ msgstr "" msgid "epub Reader" msgstr "" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "សេរេអត្ថបទនៅពេលបើកផ្ទាំងចំហៀង។" diff --git a/cps/translations/nl/LC_MESSAGES/messages.mo b/cps/translations/nl/LC_MESSAGES/messages.mo index 46b0374b19e4d7f4baf22424e3b5e1decea01fb2..d7e7c45ee39325b7c028cb75a234a08734bb2334 100644 GIT binary patch delta 30 mcmX@u#eA@fdBf8}4kIH4LrW_Iv(0Y{4H+3NHp>?)1pxrEKMHOD delta 30 mcmX@u#eA@fdBf8}4nq?KLvt%r!_99C4H+3NH_I0*1pxrEJPK|A diff --git a/cps/translations/nl/LC_MESSAGES/messages.po b/cps/translations/nl/LC_MESSAGES/messages.po index 88594080..fc45d08a 100644 --- a/cps/translations/nl/LC_MESSAGES/messages.po +++ b/cps/translations/nl/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web (GPLV3)\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/Calibre-Web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-12-12 08:20+0100\n" "Last-Translator: Marcel Maas \n" "Language: nl\n" @@ -17,7 +17,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -77,7 +77,7 @@ msgstr "Systeembeheerder" msgid "All" msgstr "Alles" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Gebruiker niet gevonden" @@ -94,7 +94,7 @@ msgstr "Alle talen" msgid "Malformed request" msgstr "Misvormd verzoek" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Gast naam kan niet worden veranderd" @@ -102,7 +102,7 @@ msgstr "Gast naam kan niet worden veranderd" msgid "Guest can't have this role" msgstr "Gast kan deze rol niet hebben" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Kan systeembeheerder rol niet verwijderen van de laatste systeembeheerder" @@ -146,7 +146,7 @@ msgstr "Ongeldige gelezen kolom" msgid "Invalid Restricted Column" msgstr "Ongeldige beperkte kolom" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web-configuratie bijgewerkt" @@ -210,278 +210,278 @@ msgstr "Tag niet gevonden" msgid "Invalid Action" msgstr "Ongeldige actie" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Weigeren" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Toestaan" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json is niet geconfigureerd voor webapplicatie" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "De locatie van het logbestand is onjuist, voer een geldige locatie in" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "De locatie vam het toegangslog is onjuist, voer een geldige locatie in" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Voer alsjeblieft een LDAP Provider, Port, DN en User Object Identifier in" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "Voer een geldig LDAP Service Account en wachtwoord in" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Voer een LDAP Service Account in" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP Groep Object Filter Moet Een \"%s\" Formaat Identificiatie hebben" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP Groep Object Filter heeft een niet-gebalanceerd haakje" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP Gebruiker Object Filter moet \"%s\" Formaat Identificatie hebben" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP Gebruiker Filter heeft een niet-gebalanceerd haakje" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP Lid Gebruiker Filter moet een \"%s\" Formaat Identificatie hebben" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "LDAP Lid Gebruiker Filter heeft een niet-gebalanceerd haakje" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CACertficaat, Certificaat of Sleutel Locatie is ongeldig. Voer alsjeblieft een geldig pad in." -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "DB-instellingen niet opgeslagen" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "Database niet gevonden, voer de juiste locatie in" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Kan niet schrijven naar database" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "SSL-sleutellocatie is niet geldig, voer een geldig pad in" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "SSL-certificaatlocatie is niet geldig, voer een geldig pad in" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "E-mailserver-instellingen bijgewerkt" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Databaseconfiguratie" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Vul alle velden in!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "Het e-mailadres bevat geen geldige domeinnaam" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Gebruiker toevoegen" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Gebruiker '%(user)s' aangemaakt" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Bestaand account met dit e-mailadres of deze gebruikersnaam aangetroffen." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Gebruiker '%(nick)s' verwijderd" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "Kan Gast gebruiker niet verwijderen" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Kan laatste systeembeheerder niet verwijderen" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Gebruiker '%(nick)s' bewerken" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Gebruiker '%(nick)s' bijgewerkt" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Onbekende fout opgetreden. Probeer het later nog eens." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "SMTP-instellingen bewerken" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "Gmail Account succesvol geverifieerd" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Test E-Mail wordt verzonden naar %(email)s, controleer de taken voor het resultaat" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Fout opgetreden bij het versturen van de test-e-mail: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Gelieve eerst je e-mail adres configureren..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "E-mailserver-instellingen bijgewerkt" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Wachtwoord voor gebruiker %(user)s is hersteld" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Stel eerst SMTP-mail in..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Logbestand lezer" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Update opvragen" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Update downloaden" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Update uitpakken" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Update toepassen" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Databaseverbindingen zijn gesloten" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Bezig met stoppen van Calibre-Web" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Update voltooid, klik op 'Oké' en vernieuw de pagina" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Update mislukt:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP-fout" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Verbindingsfout" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Time-out tijdens maken van verbinding" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Algemene fout" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Geüpload bestand kon niet opgeslagen worden in de tijdelijke map" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Mislukt om minstens een LDAP gebruiker aan te maken" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Het is niet gelukt tenminste een LDAP gebruiker aan te maken" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Fout: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Fout: No user returned in response of LDAP server" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Minstens een LDAP Gebruiker is niet gevonden in de Database" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} Gebruiker succesvol geïmporteerd" @@ -1343,7 +1343,7 @@ msgstr "Omgezette bestand is niet gevonden of meer dan een bestand in map %(fold msgid "Ebook-converter failed: %(error)s" msgstr "E-boek-conversie mislukt: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre mislukt met foutmelding: %(error)s" @@ -2582,7 +2582,7 @@ msgstr "Fout" msgid "Upload done, processing, please wait..." msgstr "Uploaden voltooid, bezig met verwerken..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Instellingen" @@ -2733,7 +2733,7 @@ msgstr "Calibre-Web - e-boekcatalogus" msgid "epub Reader" msgstr "PDF lezer" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Tekstindeling automatisch aanpassen als het zijpaneel geopend is." diff --git a/cps/translations/pl/LC_MESSAGES/messages.mo b/cps/translations/pl/LC_MESSAGES/messages.mo index 26286b36a7d211d76960cbb277672f98a950231f..3c1424b46a39204b6817d00519046c223aca62ed 100644 GIT binary patch delta 30 mcmdlxlX>q<<_&X-IE;)G3@xn;%r-A6N@HZS*vwtBDFOhkxC*QQ delta 30 mcmdlxlX>q<<_&X-I1Eh`49%@f4L2_-N@HZS+{|6FDFOhkwF;~N diff --git a/cps/translations/pl/LC_MESSAGES/messages.po b/cps/translations/pl/LC_MESSAGES/messages.po index 85da79f1..c7dfbcf7 100644 --- a/cps/translations/pl/LC_MESSAGES/messages.po +++ b/cps/translations/pl/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre Web - polski (POT: 2021-06-12 08:52)\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2021-06-12 15:35+0200\n" "Last-Translator: Radosław Kierznowski \n" "Language: pl\n" @@ -17,7 +17,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -78,7 +78,7 @@ msgstr "Edytuj użytkowników" msgid "All" msgstr "Wszystko" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Nie znaleziono użytkownika" @@ -95,7 +95,7 @@ msgstr "Pokaż wszystkie" msgid "Malformed request" msgstr "Nieprawidłowo sformułowane żądanie" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Nazwa gościa nie może być zmieniona" @@ -103,7 +103,7 @@ msgstr "Nazwa gościa nie może być zmieniona" msgid "Guest can't have this role" msgstr "Gość nie może pełnić tej roli" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Nie można odebrać praw administratora. Brak na serwerze innego konta z prawami administratora" @@ -149,7 +149,7 @@ msgstr "Nieprawidłowa kolumna odczytu" msgid "Invalid Restricted Column" msgstr "Nieprawidłowa kolumna z ograniczeniami" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Konfiguracja Calibre-Web została zaktualizowana" @@ -210,279 +210,279 @@ msgstr "Nie znaleziono znacznika" msgid "Invalid Action" msgstr "Nieprawidłowe działanie" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Zabroń" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Zezwalaj" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json nie został skonfigurowany dla aplikacji webowej" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "Lokalizacja pliku dziennika jest nieprawidłowa, wprowadź poprawną ścieżkę" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "Lokalizacja pliku dziennika dostępu jest nieprawidłowa, wprowadź poprawną ścieżkę" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Wprowadź dostawcę LDAP, port, nazwę wyróżniającą i identyfikator obiektu użytkownika" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "Proszę wprowadzić konto i hasło usługi LDAP" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "Proszę wprowadzić konto usługi LDAP" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "Filtr obiektów grupy LDAP musi mieć jeden identyfikator formatu \"% s\"" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "Filtr obiektów grupy LDAP ma niedopasowany nawias" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "Filtr obiektów użytkownika LDAP musi mieć jeden identyfikator formatu \"% s\"" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "Filtr obiektów użytkownika LDAP ma niedopasowany nawias" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "Filtr użytkownika członka LDAP musi mieć jedno \"%s\" identyfikator formatu" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "Filtr użytkownika członka LDAP ma niedopasowane nawiasy" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "Główny urząd certyfikatu LDAP, Certyfikat lub Lokalizacja Klucza nie jest prawidłowa, Proszę wprowadzić poprawną ścieżkę" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "Ustawienia Bazy Danych nie są zapisywalne" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "Lokalizacja bazy danych jest nieprawidłowa, wprowadź poprawną ścieżkę" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "Baza danych nie jest zapisywalna" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "Lokalizacja pliku klucza jest nieprawidłowa, wprowadź poprawną ścieżkę" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "Lokalizacja pliku certyfikatu jest nieprawidłowa, wprowadź poprawną ścieżkę" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Zaktualizowano ustawienia serwera poczty e-mail" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "Konfiguracja bazy danych" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Proszę wypełnić wszystkie pola!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "E-mail nie pochodzi z prawidłowej domeny" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Dodaj nowego użytkownika" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Użytkownik '%(user)s' został utworzony" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "Znaleziono istniejące konto dla tego adresu e-mail lub nazwy." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Użytkownik '%(nick)s' został usunięty" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 #, fuzzy msgid "Can't delete Guest User" msgstr "Nie można usunąć użytkownika gościa" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Nie można usunąć użytkownika. Brak na serwerze innego konta z prawami administratora" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Edytuj użytkownika %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Użytkownik '%(nick)s' został zaktualizowany" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Wystąpił nieznany błąd. Spróbuj ponownie później." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Zmień ustawienia SMTP" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "Weryfikacja konta Gmail przebiegła pomyślnie" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, fuzzy, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Testowy e-mail czeka w kolejce do wysłania do %(email)s, sprawdź zadania, aby uzyskać wynik" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Wystąpił błąd podczas wysyłania e-maila testowego: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Najpierw skonfiguruj swój adres e-mail..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Zaktualizowano ustawienia serwera poczty e-mail" # ??? -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Zrestartowano hasło użytkownika %(user)s" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Proszę najpierw skonfigurować ustawienia SMTP poczty e-mail..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Przeglądanie dziennika" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Żądanie o pakiet aktualizacji" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Pobieranie pakietu aktualizacji" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Rozpakowywanie pakietu aktualizacji" # ??? -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Zastępowanie plików" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Połączenia z bazą danych zostały zakończone" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Zatrzymywanie serwera" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Aktualizacja zakończona, proszę nacisnąć OK i odświeżyć stronę" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Aktualizacja nieudana:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "Błąd HTTP" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Błąd połączenia" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Przekroczono limit czasu podczas nawiązywania połączenia" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Błąd ogólny" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Plik aktualizacji nie mógł zostać zapisany w katalogu tymczasowym" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Błąd przy tworzeniu przynajmniej jednego użytkownika LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Błąd przy tworzeniu przynajmniej jednego użytkownika LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Błąd: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Błąd. LDAP nie zwrócił żadnego użytkownika" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Przynajmniej jeden użytkownik LDAP nie został znaleziony w bazie danych" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} Użytkownik pomyślnie zaimportowany" @@ -1347,7 +1347,7 @@ msgstr "Konwertowany plik nie został znaleziony, lub więcej niż jeden plik w msgid "Ebook-converter failed: %(error)s" msgstr "Konwertowanie nie powiodło się: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, fuzzy, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre nie powiodło się z błędem: %(error)s" @@ -2597,7 +2597,7 @@ msgstr "Błąd" msgid "Upload done, processing, please wait..." msgstr "Wysyłanie zakończone, przetwarzanie, proszę czekać…" -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Ustawienia" @@ -2749,7 +2749,7 @@ msgstr "Katalog e-booków Calibre-Web" msgid "epub Reader" msgstr "Czytnik PDF" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Przepływ tekstu, gdy paski boczne są otwarte." diff --git a/cps/translations/pt_BR/LC_MESSAGES/messages.mo b/cps/translations/pt_BR/LC_MESSAGES/messages.mo index 23b5a33f2199bd120ac7e3f66b1b9acd46b499ad..c6efe3c2bd789c787432ed2b1b4d82a3f36684a4 100644 GIT binary patch delta 30 mcmccgj_J}nrVTY|97aY8hL%\n" "Language: br\n" @@ -13,7 +13,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -73,7 +73,7 @@ msgstr "Usuário Admin" msgid "All" msgstr "Todos" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Usuário não encontrado" @@ -91,7 +91,7 @@ msgstr "Mostrar tudo" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -99,7 +99,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Nenhum usuário administrador restante, não pode remover a função de administrador" @@ -143,7 +143,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Configuração do Calibre-Web atualizada" @@ -207,279 +207,279 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Negar" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Permita" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json não está configurado para aplicativo da web" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "A localização do arquivo de log não é válida, digite o caminho correto" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "A localização do arquivo de log de acesso não é válida, digite o caminho correto" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Digite um provedor LDAP, porta, DN e identificador de objeto do usuário" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Por favor, digite um nome de usuário válido para redefinir a senha" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "O filtro de objeto de grupo LDAP precisa ter um identificador de formato \"%s\"" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "Filtro de objeto de grupo LDAP tem parênteses incomparáveis" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "O filtro de objeto de usuário LDAP precisa ter um identificador de formato \"%s\"" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "Filtro de objeto de usuário LDAP tem parênteses incomparáveis" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "O filtro de usuário membro do LDAP precisa ter um identificador de formato \"%s\"" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "Filtro de usuário de membro LDAP tem parênteses incomparáveis" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CACertificate, Certificados ou chave de localização não é válida, Insira o caminho correto" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "O banco de dados de configurações não é gravável" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "A localização do banco de dados não é válida, digite o caminho correto" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "DB não é gravável" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "A localização do arquivo-chave não é válida, por favor insira o caminho correto" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "A localização do arquivo de certificação não é válida, digite o caminho correto" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "Atualização das configurações do servidor de e-mail" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Configuração das Características" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Por favor, preencha todos os campos!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "O e-mail não é de um domínio válido" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Adicionar novo usuário" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Usuário '%(user)s' criado" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Encontrei uma conta existente para este endereço de e-mail ou apelido." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Usuário '%(nick)s' excluído" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Nenhum usuário administrador restante, não é possível excluir o usuário" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Editar usuário %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Usuário '%(nick)s' atualizado" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Ocorreu um erro desconhecido. Por favor, tente novamente mais tarde." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Editar configurações do servidor de e-mail" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Ocorreu um erro ao enviar o e-mail de teste: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Por favor, configure seu endereço de e-mail primeiro..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "Atualização das configurações do servidor de e-mail" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Senha para redefinição do usuário %(user)s" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Por favor, configure primeiro as configurações de correio SMTP..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "visualizador de arquivo de registro" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Solicitação de pacote de atualização" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Download do pacote de atualização" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Descompactação de pacote de atualização" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Substituição de arquivos" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "As ligações à base de dados estão fechadas" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Parar servidor" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Atualização concluída, pressione okay e recarregue a página" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Atualização falhou:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "Erro HTTP" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Erro de conexão" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Tempo limite durante o estabelecimento da conexão" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Erro geral" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Arquivo de atualização não pôde ser salvo no diretório temporário" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Falha na criação no mínimo de um usuário LDAP" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Falha na criação no mínimo de um usuário LDAP" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Erro: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Erro: Nenhum usuário retornado em resposta do servidor LDAP" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "No mínimo um usuário LDAP não encontrado no banco de dados" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} Usuário Importado com Sucesso" @@ -1341,7 +1341,7 @@ msgstr "Arquivo convertido não encontrado ou mais de um arquivo na pasta %(fold msgid "Ebook-converter failed: %(error)s" msgstr "Conversor de ebook falhou: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre falhou com erro: %(error)s" @@ -2580,7 +2580,7 @@ msgstr "Erro" msgid "Upload done, processing, please wait..." msgstr "Upload feito, processando, por favor aguarde ..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Configurações" @@ -2731,7 +2731,7 @@ msgstr "Catálogo de e-books Calibre-Web" msgid "epub Reader" msgstr "leitor de PDF" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Refluxo de texto quando as barras laterais estão abertas." diff --git a/cps/translations/ru/LC_MESSAGES/messages.mo b/cps/translations/ru/LC_MESSAGES/messages.mo index 255a7fd7f9e20aaaf25b6b77c4df1b9b9b2b189f..69d9e91fa593d79d7386beda5873cec19f1012b2 100644 GIT binary patch delta 30 mcmaF(jp^YxrVY|T97aY8hL%\n" "Language: sv\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "Redigera användare" msgid "All" msgstr "Alla" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "Användaren hittades inte" @@ -92,7 +92,7 @@ msgstr "Visa alla" msgid "Malformed request" msgstr "Felaktig begäran" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "Gästnamn kan inte ändras" @@ -100,7 +100,7 @@ msgstr "Gästnamn kan inte ändras" msgid "Guest can't have this role" msgstr "Gäst kan inte ha den här rollen" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "Ingen administratörsanvändare kvar, kan inte ta bort administratörsrollen" @@ -145,7 +145,7 @@ msgstr "Ogiltig roll" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web konfiguration uppdaterad" @@ -207,279 +207,279 @@ msgstr "Taggen hittades inte" msgid "Invalid Action" msgstr "Ogiltig åtgärd" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "Förneka" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "Tillåt" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json är inte konfigurerad för webbapplikation" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "Loggfilens plats är inte giltig, vänligen ange rätt sökväg" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "Åtkomstloggplatsens plats är inte giltig, vänligen ange rätt sökväg" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "Vänligen ange en LDAP-leverantör, port, DN och användarobjektidentifierare" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Ange giltigt användarnamn för att återställa lösenordet" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP-gruppobjektfilter måste ha en \"%s\"-formatidentifierare" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP-gruppobjektfilter har omatchande parentes" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP-användarobjektfilter måste ha en \"%s\"-formatidentifierare" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP-användarobjektfilter har omatchad parentes" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "Användarfilter för LDAP-medlemmar måste ha en \"%s\"-formatidentifierare" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "Användarfilter för LDAP-medlemmar har omatchad parentes" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP-certifikat, certifikat eller nyckelplats är inte giltigt, vänligen ange rätt sökväg" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "Inställningar för DB är inte skrivbara" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "DB-plats är inte giltig, vänligen ange rätt sökväg" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "DB är inte skrivbar" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "Keyfile-platsen är inte giltig, vänligen ange rätt sökväg" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "Certfile-platsen är inte giltig, vänligen ange rätt sökväg" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "E-postserverinställningar uppdaterade" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Funktion konfiguration" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Fyll i alla fält!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "E-posten är inte från giltig domän" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Lägg till ny användare" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Användaren '%(user)s' skapad" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "Hittade ett befintligt konto för den här e-postadressen eller namnet." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Användaren '%(nick)s' borttagen" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "Det går inte att ta bort gästanvändaren" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Ingen adminstratörsanvändare kvar, kan inte ta bort användaren" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Redigera användaren %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Användaren '%(nick)s' uppdaterad" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Ett okänt fel uppstod. Försök igen senare." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Ändra SMTP-inställningar" -#: cps/admin.py:1551 +#: cps/admin.py:1562 #, fuzzy msgid "Gmail Account Verification Successful" msgstr "Verifiering av G-mail-kontot lyckades" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "Testa e-post i kö för att skicka till %(email)s, vänligen kontrollera Uppgifter för resultat" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Det gick inte att skicka Testmeddelandet: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Vänligen konfigurera din e-postadress först..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "E-postserverinställningar uppdaterade" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "Lösenord för användaren %(user)s återställd" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Konfigurera SMTP-postinställningarna först..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Visaren för loggfil" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Begär uppdateringspaketet" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Hämtar uppdateringspaketet" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Packar upp uppdateringspaketet" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Ersätta filer" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Databasanslutningarna är stängda" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Stoppar server" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Uppdatering klar, tryck på okej och uppdatera sidan" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Uppdateringen misslyckades:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP-fel" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Anslutningsfel" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Tiden ute när du etablerade anslutning" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Allmänt fel" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "Uppdateringsfilen kunde inte sparas i Temp Dir" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 #, fuzzy msgid "Failed to extract at least One LDAP User" msgstr "Det gick inte att skapa minst en LDAP-användare" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "Det gick inte att skapa minst en LDAP-användare" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "Fel: %(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "Fel: Ingen användare återges som svar på LDAP-servern" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "Minst en LDAP-användare hittades inte i databasen" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} användare har importerats" @@ -1340,7 +1340,7 @@ msgstr "Konverterad fil hittades inte eller mer än en fil i mappen %(folder)s" msgid "Ebook-converter failed: %(error)s" msgstr "E-bokkonverteraren misslyckades: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "calibre misslyckades med fel: %(error)s" @@ -2580,7 +2580,7 @@ msgstr "Fel" msgid "Upload done, processing, please wait..." msgstr "Uppladdning klar, bearbetning, vänligen vänta ..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Inställningar" @@ -2730,7 +2730,7 @@ msgstr "Calibre-Web e-bokkatalog" msgid "epub Reader" msgstr "PDF-läsare" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Fyll i texten igen när sidofält är öppna." diff --git a/cps/translations/tr/LC_MESSAGES/messages.mo b/cps/translations/tr/LC_MESSAGES/messages.mo index fbad469794a36a215097b28241ad531de6c4e936..4354ea9aa3eb72cd7758b46d267bb21923274341 100644 GIT binary patch delta 30 mcmZqM#Mripal=hx4kIH4LrW_Iv&|2U1sNGFHh(q|RsjH|842hB delta 30 mcmZqM#Mripal=hx4nq?KLvt%r!_5zk1sNGFH-9z}RsjH|776G8 diff --git a/cps/translations/tr/LC_MESSAGES/messages.po b/cps/translations/tr/LC_MESSAGES/messages.po index 8a049549..9d6781da 100644 --- a/cps/translations/tr/LC_MESSAGES/messages.po +++ b/cps/translations/tr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-04-23 22:47+0300\n" "Last-Translator: iz \n" "Language: tr\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "" msgid "All" msgstr "Tümü" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -92,7 +92,7 @@ msgstr "" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -100,7 +100,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -144,7 +144,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web yapılandırması güncellendi" @@ -204,278 +204,278 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 #, fuzzy msgid "Please Enter a LDAP Service Account and Password" msgstr "Şifrenizi sıfırlayabilmek için lütfen geçerli bir kullanıcı adı giriniz" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "E-posta sunucusu ayarları güncellendi" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Özellik Yapılandırması" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Lütfen tüm alanları doldurun!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "E-posta izin verilen bir servisten değil" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Yeni kullanıcı ekle" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "'%(user)s' kullanıcısı oluşturuldu" -#: cps/admin.py:1390 +#: cps/admin.py:1401 #, fuzzy msgid "Found an existing account for this e-mail address or name." msgstr "Bu e-posta adresi veya kullanıcı adı için zaten bir hesap var." -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Kullanıcı '%(nick)s' silindi" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "Başka yönetici kullanıcı olmadığından silinemedi" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "%(nick)s kullanıcısını düzenle" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "'%(nick)s' kullanıcısı güncellendi" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "Bilinmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyiniz." -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "Deneme e-postası gönderilirken bir hata oluştu: %(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "Lütfen önce e-posta adresinizi ayarlayın..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "E-posta sunucusu ayarları güncellendi" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "%(user)s kullanıcısının şifresi sıfırlandı" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Lütfen önce SMTP e-posta ayarlarını ayarlayın..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "Log dosyası görüntüleyici" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Güncelleme paketi isteniyor" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Güncelleme paketi indiriliyor" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Güncelleme paketi ayıklanıyor" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "Dosyalar değiştiriliyor" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "Veritabanı bağlantıları kapalı" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "Sunucu durduruyor" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Güncelleme tamamlandı, sayfayı yenilemek için lütfen Tamam'a tıklayınız" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "Güncelleme başarısız:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP Hatası" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "Bağlantı hatası" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "Bağlantı kurulmaya çalışırken zaman aşımına uğradı" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "Genel hata" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "%(filename)s dosyası geçici dizine kaydedilemedi" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1337,7 +1337,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "eKitap-Dönüştürücü hatası: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2574,7 +2574,7 @@ msgstr "Hata" msgid "Upload done, processing, please wait..." msgstr "Yükleme tamamlandı, işleniyor, lütfen bekleyin..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Ayarlar" @@ -2725,7 +2725,7 @@ msgstr "" msgid "epub Reader" msgstr "PDF Okuyucu" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Kenar çubukları açıkken metni kaydır" diff --git a/cps/translations/uk/LC_MESSAGES/messages.mo b/cps/translations/uk/LC_MESSAGES/messages.mo index c62a20741224bb4b7ac0506a67af57add25127da..62855c0847be892c5299e2aba81e4e5efcd1c2ee 100644 GIT binary patch delta 30 mcmaFU!}z9$al-<64kIH4LrW_Iv&}2yQyCd8HuEXQsR970Q3-Yc delta 30 mcmaFU!}z9$al-<64nq?KLvt%r!_6z?QyCd8H}fgRsR970P6>7Z diff --git a/cps/translations/uk/LC_MESSAGES/messages.po b/cps/translations/uk/LC_MESSAGES/messages.po index 4e8801da..9a4907fd 100644 --- a/cps/translations/uk/LC_MESSAGES/messages.po +++ b/cps/translations/uk/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-web\n" "Report-Msgid-Bugs-To: https://github.com/janeczku/calibre-web\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2017-04-30 00:47+0300\n" "Last-Translator: ABIS Team \n" "Language: uk\n" @@ -15,7 +15,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "Керування сервером" msgid "All" msgstr "" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -92,7 +92,7 @@ msgstr "Показати всі" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -100,7 +100,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -144,7 +144,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "" @@ -208,275 +208,275 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "З'єднання з базою даних закрите" -#: cps/admin.py:1343 +#: cps/admin.py:1354 #, fuzzy msgid "Database Configuration" msgstr "Особливі налаштування" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "Будь-ласка, заповніть всі поля!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "Додати користувача" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "Користувач '%(user)s' додан" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "Користувача '%(nick)s' видалено" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "Змінити користувача %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "Користувача '%(nick)s' оновлено" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "Змінити налаштування SMTP" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "Будь-ласка, спочатку сконфігуруйте параметри SMTP" -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "Перевірка оновлень" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "Завантаження оновлень" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "Розпакування оновлення" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "З'єднання з базою даних закрите" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "Оновлення встановлені, натисніть ok і перезавантажте сторінку" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1337,7 +1337,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2573,7 +2573,7 @@ msgstr "" msgid "Upload done, processing, please wait..." msgstr "" -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "Налаштування" @@ -2723,7 +2723,7 @@ msgstr "" msgid "epub Reader" msgstr "" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "Переформатувати текст, коли відкриті бічні панелі." diff --git a/cps/translations/zh_Hans_CN/LC_MESSAGES/messages.mo b/cps/translations/zh_Hans_CN/LC_MESSAGES/messages.mo index f7b864c06419b6c1b50a7fa22e8effca884a8167..3bbd44758668b5cf44be2dc9deef7cb3aab922ba 100644 GIT binary patch delta 30 lcmdnd&b*_Yd4pCphmnzjp{139*=D0^bw);u%~3UR?f{t`310vJ delta 30 lcmdnd&b*_Yd4pCphoOmrp}Cc*;bx<1bw)\n" "Language: zh_CN\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "管理用户" msgid "All" msgstr "全部" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "找不到用户" @@ -92,7 +92,7 @@ msgstr "显示全部" msgid "Malformed request" msgstr "格式错误的请求" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "访客名称无法更改" @@ -100,7 +100,7 @@ msgstr "访客名称无法更改" msgid "Guest can't have this role" msgstr "游客无法拥有此角色" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "理员账户不存在,无法删除管理员角色" @@ -144,7 +144,7 @@ msgstr "无效的阅读列" msgid "Invalid Restricted Column" msgstr "无效的限制列" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web配置已更新" @@ -204,273 +204,273 @@ msgstr "标签未找到" msgid "Invalid Action" msgstr "无效的动作" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "拒绝" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "允许" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json 未为 Web 应用程序配置" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "日志文件路径无效,请输入正确的路径" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "访问日志路径无效,请输入正确的路径" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "请输入LDAP主机、端口、DN和用户对象标识符" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "请输入一个LDAP服务账号和密码 " -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "请输入一个LDAP服务账号" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP组对象过滤器需要一个具有“%s”格式标识符" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP组对象过滤器的括号不匹配" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP用户对象过滤器需要一个具有“%s”格式标识符" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP用户对象过滤器的括号不匹配" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP成员用户过滤器需要有一个“%s”格式标识符" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "LDAP成员用户过滤器中有不匹配的括号" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CA证书、证书或密钥位置无效,请输入正确的路径" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "设置数据库不可写入" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "数据库路径无效,请输入正确的路径" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "数据库不可写入" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "密钥文件路径无效,请输入正确的路径" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "证书文件路径无效,请输入正确的路径" -#: cps/admin.py:1335 +#: cps/admin.py:1346 msgid "Database Settings updated" msgstr "数据库设置已更新" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "数据库配置" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "请填写所有字段!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "邮箱不在有效域中" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "添加新用户" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "用户“%(user)s”已创建" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "使用此邮箱或用户名的账号已经存在。" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "用户“%(nick)s”已删除" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "无法删除游客用户" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "管理员账户不存在,无法删除用户" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "编辑用户 %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "用户“%(nick)s”已更新" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "发生一个未知错误,请稍后再试。" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "编辑邮件服务器设置" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "G-Mail账号校验成功" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "发送给%(email)s的测试邮件已进入队列。请检查任务结果" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "发送测试邮件时出错:%(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "请先配置您的邮箱地址..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "邮件服务器设置已更新" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "用户 %(user)s 的密码已重置" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "请先配置SMTP邮箱设置..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "日志文件查看器" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "正在请求更新包" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "正在下载更新包" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "正在解压更新包" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "正在替换文件" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "数据库连接已关闭" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "正在停止服务器" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "更新完成,请点击确定并刷新页面" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "更新失败:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP错误" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "连接错误" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "建立连接超时" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "一般错误" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "更新文件无法保存在临时目录中" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "更新期间无法替换文件" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "未能提取至少一个LDAP用户" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "未能创建至少一个LDAP用户" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "错误:%(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "错误:在LDAP服务器的响应中没有返回用户" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "数据库中没有找到至少一个LDAP用户" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} 用户被成功导入" @@ -1329,7 +1329,7 @@ msgstr "找不到转换后的文件或文件夹%(folder)s中有多个文件" msgid "Ebook-converter failed: %(error)s" msgstr "电子书转换器失败: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre 运行失败,错误信息:%(error)s" @@ -2560,7 +2560,7 @@ msgstr "错误" msgid "Upload done, processing, please wait..." msgstr "上传完成,正在处理,请稍候..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "设置" @@ -2709,7 +2709,7 @@ msgstr "Caliebre-Web电子书路径" msgid "epub Reader" msgstr "epub阅读器" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "打开侧栏时重排文本。" diff --git a/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.mo b/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.mo index 4a8c7cdda6dae1399802544661279fbb51224ac3..9f2dd7d88e9ed3c4c7bdc7ea6e0295deccf4b713 100644 GIT binary patch delta 30 mcmezNm+9MIrVa1PIgE@H3@xn;%r<{3S7&6j*eq9h!xaG8kqmwS delta 30 mcmezNm+9MIrVa1PISfq{49%@f4L5%)S7&6j+$>jl!xaG8jtqVP diff --git a/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.po b/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.po index a7c87e24..d9cbf08b 100644 --- a/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.po +++ b/cps/translations/zh_Hant_TW/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Calibre-Web\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: 2020-09-27 22:18+0800\n" "Last-Translator: xlivevil \n" "Language: zh_TW\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -75,7 +75,7 @@ msgstr "管理用戶" msgid "All" msgstr "全部" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "找不到用戶" @@ -92,7 +92,7 @@ msgstr "顯示全部" msgid "Malformed request" msgstr "格式錯誤的請求" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "訪客名稱無法更改" @@ -100,7 +100,7 @@ msgstr "訪客名稱無法更改" msgid "Guest can't have this role" msgstr "遊客無法擁有此角色" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "管理員賬戶不存在,無法刪除管理員角色" @@ -144,7 +144,7 @@ msgstr "無效的閱讀列" msgid "Invalid Restricted Column" msgstr "無效的限制列" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "Calibre-Web配置已更新" @@ -204,275 +204,275 @@ msgstr "標籤未找到" msgid "Invalid Action" msgstr "無效的動作" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "拒絕" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "允許" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "client_secrets.json 未為 Web 應用程序配置" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "日誌文件路徑無效,請輸入正確的路徑" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "訪問日誌路徑無效,請輸入正確的路徑" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "請輸入LDAP主機、端口、DN和用戶對象標識符" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "請輸入一個LDAP服務賬號和密碼 " -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "請輸入一個LDAP服務賬號" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "LDAP群組對象過濾器需要一個具有“%s”格式標識符號" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "LDAP群組對象過濾器的括號不匹配" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP用戶對象過濾器需要一個具有“%s”格式標識符" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "LDAP用戶對象過濾器的括號不匹配" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "LDAP成員用戶過濾器需要有一個“%s”格式標識符號" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "LDAP成員用戶過濾器中有不匹配的括號" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "LDAP CA證書、證書或密鑰位置無效,請輸入正確的路徑" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "設置數據庫不可寫入" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "數據庫路徑無效,請輸入正確的路徑" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "數據庫不可寫入" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "密鑰文件路徑無效,請輸入正確的路徑" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "證書文件路徑無效,請輸入正確的路徑" -#: cps/admin.py:1335 +#: cps/admin.py:1346 #, fuzzy msgid "Database Settings updated" msgstr "郵件服務器設置已更新" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "數據庫配置" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "請填寫所有欄位!" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "郵箱不在有效網域中" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "添加新用戶" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "用戶“%(user)s”已創建" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "使用此郵箱或用戶名的賬號已經存在。" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "用戶“%(nick)s”已刪除" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "無法刪除訪客用戶" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "管理員賬戶不存在,無法刪除用戶" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "編輯用戶 %(nick)s" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "用戶“%(nick)s”已更新" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "發生一個未知錯誤,請稍後再試。" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "編輯郵件服務器設置" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "G-Mail賬號驗證成功" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "發送給%(email)s的測試郵件已進入隊列。請檢查任務結果" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "發送測試郵件時出錯:%(res)s" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "請先配置您的郵箱地址..." -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "郵件服務器設置已更新" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "用戶 %(user)s 的密碼已重置" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "請先配置SMTP郵箱設置..." -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "日誌文件查看器" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "正在請求更新包" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "正在下載更新包" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "正在解壓更新包" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "正在替換文件" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "數據庫連接已關閉" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "正在停止服務器" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "更新完成,請點擊確定並刷新頁面" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "更新失敗:" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "HTTP錯誤" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "連接錯誤" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "建立連接超時" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "一般錯誤" -#: cps/admin.py:1720 +#: cps/admin.py:1731 #, fuzzy msgid "Update file could not be saved in temp dir" msgstr "更新文件無法保存在臨時目錄中" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "更新時檔案無法替換變更" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "未能提取至少一個LDAP用戶" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "未能創建至少一個LDAP用戶" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "錯誤:%(ldaperror)s" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "錯誤:在LDAP服務器的響應中沒有返回用戶" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "數據庫中沒有找到至少一個LDAP用戶" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "{} 用戶被成功導入" @@ -1331,7 +1331,7 @@ msgstr "找不到轉換後的文件或文件夾%(folder)s中有多個文件" msgid "Ebook-converter failed: %(error)s" msgstr "電子書轉換器失敗: %(error)s" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "Calibre 運行失敗,錯誤信息:%(error)s" @@ -2567,7 +2567,7 @@ msgstr "錯誤" msgid "Upload done, processing, please wait..." msgstr "上傳完成,正在處理中,請稍候..." -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "設置" @@ -2716,7 +2716,7 @@ msgstr "Caliebre-Web電子書路徑" msgid "epub Reader" msgstr "epub閱讀器" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "打開側欄時重排文本。" diff --git a/messages.pot b/messages.pot index 5588247f..15027461 100644 --- a/messages.pot +++ b/messages.pot @@ -8,14 +8,14 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-12-14 17:51+0100\n" +"POT-Creation-Date: 2021-12-22 19:06+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.0\n" +"Generated-By: Babel 2.8.0\n" #: cps/about.py:34 cps/about.py:49 cps/about.py:65 cps/converter.py:31 msgid "not installed" @@ -74,7 +74,7 @@ msgstr "" msgid "All" msgstr "" -#: cps/admin.py:343 cps/admin.py:1595 +#: cps/admin.py:343 cps/admin.py:1606 msgid "User not found" msgstr "" @@ -91,7 +91,7 @@ msgstr "" msgid "Malformed request" msgstr "" -#: cps/admin.py:418 cps/admin.py:1473 +#: cps/admin.py:418 cps/admin.py:1484 msgid "Guest Name can't be changed" msgstr "" @@ -99,7 +99,7 @@ msgstr "" msgid "Guest can't have this role" msgstr "" -#: cps/admin.py:442 cps/admin.py:1431 +#: cps/admin.py:442 cps/admin.py:1442 msgid "No admin user remaining, can't remove admin role" msgstr "" @@ -143,7 +143,7 @@ msgstr "" msgid "Invalid Restricted Column" msgstr "" -#: cps/admin.py:560 cps/admin.py:1312 +#: cps/admin.py:560 cps/admin.py:1323 msgid "Calibre-Web configuration updated" msgstr "" @@ -203,273 +203,273 @@ msgstr "" msgid "Invalid Action" msgstr "" -#: cps/admin.py:860 cps/admin.py:866 cps/admin.py:876 cps/admin.py:886 +#: cps/admin.py:871 cps/admin.py:877 cps/admin.py:887 cps/admin.py:897 #: cps/templates/modal_dialogs.html:29 cps/templates/user_table.html:41 #: cps/templates/user_table.html:58 msgid "Deny" msgstr "" -#: cps/admin.py:862 cps/admin.py:868 cps/admin.py:878 cps/admin.py:888 +#: cps/admin.py:873 cps/admin.py:879 cps/admin.py:889 cps/admin.py:899 #: cps/templates/modal_dialogs.html:28 cps/templates/user_table.html:44 #: cps/templates/user_table.html:61 msgid "Allow" msgstr "" -#: cps/admin.py:902 +#: cps/admin.py:913 msgid "{} sync entries deleted" msgstr "" -#: cps/admin.py:1025 +#: cps/admin.py:1036 msgid "client_secrets.json Is Not Configured For Web Application" msgstr "" -#: cps/admin.py:1070 +#: cps/admin.py:1081 msgid "Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1076 +#: cps/admin.py:1087 msgid "Access Logfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1106 +#: cps/admin.py:1117 msgid "Please Enter a LDAP Provider, Port, DN and User Object Identifier" msgstr "" -#: cps/admin.py:1112 +#: cps/admin.py:1123 msgid "Please Enter a LDAP Service Account and Password" msgstr "" -#: cps/admin.py:1115 +#: cps/admin.py:1126 msgid "Please Enter a LDAP Service Account" msgstr "" -#: cps/admin.py:1120 +#: cps/admin.py:1131 #, python-format msgid "LDAP Group Object Filter Needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1122 +#: cps/admin.py:1133 msgid "LDAP Group Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1126 +#: cps/admin.py:1137 #, python-format msgid "LDAP User Object Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1128 +#: cps/admin.py:1139 msgid "LDAP User Object Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1135 +#: cps/admin.py:1146 #, python-format msgid "LDAP Member User Filter needs to Have One \"%s\" Format Identifier" msgstr "" -#: cps/admin.py:1137 +#: cps/admin.py:1148 msgid "LDAP Member User Filter Has Unmatched Parenthesis" msgstr "" -#: cps/admin.py:1144 +#: cps/admin.py:1155 msgid "LDAP CACertificate, Certificate or Key Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1186 cps/admin.py:1297 cps/admin.py:1394 cps/admin.py:1501 -#: cps/admin.py:1570 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 +#: cps/admin.py:1197 cps/admin.py:1308 cps/admin.py:1405 cps/admin.py:1512 +#: cps/admin.py:1581 cps/shelf.py:100 cps/shelf.py:160 cps/shelf.py:203 #: cps/shelf.py:274 cps/shelf.py:335 cps/shelf.py:370 cps/shelf.py:445 msgid "Settings DB is not Writeable" msgstr "" -#: cps/admin.py:1197 +#: cps/admin.py:1208 msgid "DB Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1212 +#: cps/admin.py:1223 msgid "DB is not Writeable" msgstr "" -#: cps/admin.py:1224 +#: cps/admin.py:1235 msgid "Keyfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1228 +#: cps/admin.py:1239 msgid "Certfile Location is not Valid, Please Enter Correct Path" msgstr "" -#: cps/admin.py:1335 +#: cps/admin.py:1346 msgid "Database Settings updated" msgstr "" -#: cps/admin.py:1343 +#: cps/admin.py:1354 msgid "Database Configuration" msgstr "" -#: cps/admin.py:1359 cps/web.py:1478 +#: cps/admin.py:1370 cps/web.py:1478 msgid "Please fill out all fields!" msgstr "" -#: cps/admin.py:1367 +#: cps/admin.py:1378 msgid "E-mail is not from valid domain" msgstr "" -#: cps/admin.py:1373 cps/admin.py:1523 +#: cps/admin.py:1384 cps/admin.py:1534 msgid "Add new user" msgstr "" -#: cps/admin.py:1384 +#: cps/admin.py:1395 #, python-format msgid "User '%(user)s' created" msgstr "" -#: cps/admin.py:1390 +#: cps/admin.py:1401 msgid "Found an existing account for this e-mail address or name." msgstr "" -#: cps/admin.py:1410 +#: cps/admin.py:1421 #, python-format msgid "User '%(nick)s' deleted" msgstr "" -#: cps/admin.py:1412 cps/admin.py:1413 +#: cps/admin.py:1423 cps/admin.py:1424 msgid "Can't delete Guest User" msgstr "" -#: cps/admin.py:1416 +#: cps/admin.py:1427 msgid "No admin user remaining, can't delete user" msgstr "" -#: cps/admin.py:1489 cps/admin.py:1614 +#: cps/admin.py:1500 cps/admin.py:1625 #, python-format msgid "Edit User %(nick)s" msgstr "" -#: cps/admin.py:1493 +#: cps/admin.py:1504 #, python-format msgid "User '%(nick)s' updated" msgstr "" -#: cps/admin.py:1497 cps/admin.py:1629 cps/web.py:1503 cps/web.py:1563 +#: cps/admin.py:1508 cps/admin.py:1640 cps/web.py:1503 cps/web.py:1563 msgid "An unknown error occurred. Please try again later." msgstr "" -#: cps/admin.py:1532 cps/templates/admin.html:98 +#: cps/admin.py:1543 cps/templates/admin.html:98 msgid "Edit E-mail Server Settings" msgstr "" -#: cps/admin.py:1551 +#: cps/admin.py:1562 msgid "Gmail Account Verification Successful" msgstr "" -#: cps/admin.py:1577 +#: cps/admin.py:1588 #, python-format msgid "Test e-mail queued for sending to %(email)s, please check Tasks for result" msgstr "" -#: cps/admin.py:1580 +#: cps/admin.py:1591 #, python-format msgid "There was an error sending the Test e-mail: %(res)s" msgstr "" -#: cps/admin.py:1582 +#: cps/admin.py:1593 msgid "Please configure your e-mail address first..." msgstr "" -#: cps/admin.py:1584 +#: cps/admin.py:1595 msgid "E-mail server settings updated" msgstr "" -#: cps/admin.py:1626 +#: cps/admin.py:1637 #, python-format msgid "Password for user %(user)s reset" msgstr "" -#: cps/admin.py:1632 cps/web.py:1443 +#: cps/admin.py:1643 cps/web.py:1443 msgid "Please configure the SMTP mail settings first..." msgstr "" -#: cps/admin.py:1643 +#: cps/admin.py:1654 msgid "Logfile viewer" msgstr "" -#: cps/admin.py:1709 +#: cps/admin.py:1720 msgid "Requesting update package" msgstr "" -#: cps/admin.py:1710 +#: cps/admin.py:1721 msgid "Downloading update package" msgstr "" -#: cps/admin.py:1711 +#: cps/admin.py:1722 msgid "Unzipping update package" msgstr "" -#: cps/admin.py:1712 +#: cps/admin.py:1723 msgid "Replacing files" msgstr "" -#: cps/admin.py:1713 +#: cps/admin.py:1724 msgid "Database connections are closed" msgstr "" -#: cps/admin.py:1714 +#: cps/admin.py:1725 msgid "Stopping server" msgstr "" -#: cps/admin.py:1715 +#: cps/admin.py:1726 msgid "Update finished, please press okay and reload page" msgstr "" -#: cps/admin.py:1716 cps/admin.py:1717 cps/admin.py:1718 cps/admin.py:1719 -#: cps/admin.py:1720 cps/admin.py:1721 +#: cps/admin.py:1727 cps/admin.py:1728 cps/admin.py:1729 cps/admin.py:1730 +#: cps/admin.py:1731 cps/admin.py:1732 msgid "Update failed:" msgstr "" -#: cps/admin.py:1716 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 +#: cps/admin.py:1727 cps/updater.py:385 cps/updater.py:595 cps/updater.py:597 msgid "HTTP Error" msgstr "" -#: cps/admin.py:1717 cps/updater.py:387 cps/updater.py:599 +#: cps/admin.py:1728 cps/updater.py:387 cps/updater.py:599 msgid "Connection error" msgstr "" -#: cps/admin.py:1718 cps/updater.py:389 cps/updater.py:601 +#: cps/admin.py:1729 cps/updater.py:389 cps/updater.py:601 msgid "Timeout while establishing connection" msgstr "" -#: cps/admin.py:1719 cps/updater.py:391 cps/updater.py:603 +#: cps/admin.py:1730 cps/updater.py:391 cps/updater.py:603 msgid "General error" msgstr "" -#: cps/admin.py:1720 +#: cps/admin.py:1731 msgid "Update file could not be saved in temp dir" msgstr "" -#: cps/admin.py:1721 +#: cps/admin.py:1732 msgid "Files could not be replaced during update" msgstr "" -#: cps/admin.py:1745 +#: cps/admin.py:1756 msgid "Failed to extract at least One LDAP User" msgstr "" -#: cps/admin.py:1790 +#: cps/admin.py:1801 msgid "Failed to Create at Least One LDAP User" msgstr "" -#: cps/admin.py:1803 +#: cps/admin.py:1814 #, python-format msgid "Error: %(ldaperror)s" msgstr "" -#: cps/admin.py:1807 +#: cps/admin.py:1818 msgid "Error: No user returned in response of LDAP server" msgstr "" -#: cps/admin.py:1840 +#: cps/admin.py:1851 msgid "At Least One LDAP User Not Found in Database" msgstr "" -#: cps/admin.py:1842 +#: cps/admin.py:1853 msgid "{} User Successfully Imported" msgstr "" @@ -1328,7 +1328,7 @@ msgstr "" msgid "Ebook-converter failed: %(error)s" msgstr "" -#: cps/tasks/convert.py:241 +#: cps/tasks/convert.py:245 #, python-format msgid "Calibre failed with error: %(error)s" msgstr "" @@ -2559,7 +2559,7 @@ msgstr "" msgid "Upload done, processing, please wait..." msgstr "" -#: cps/templates/layout.html:76 cps/templates/read.html:71 +#: cps/templates/layout.html:76 cps/templates/read.html:72 #: cps/templates/readcbr.html:84 cps/templates/readcbr.html:108 msgid "Settings" msgstr "" @@ -2708,7 +2708,7 @@ msgstr "" msgid "epub Reader" msgstr "" -#: cps/templates/read.html:74 +#: cps/templates/read.html:75 msgid "Reflow text when sidebars are open." msgstr "" From bdedec90dd8df032cbc666a26618f17bcde3b6ea Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Thu, 23 Dec 2021 11:13:08 +0100 Subject: [PATCH 030/220] Catch more Gdrive errors (#2233) --- cps/gdrive.py | 2 +- cps/gdriveutils.py | 7 +++++++ cps/helper.py | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cps/gdrive.py b/cps/gdrive.py index 6ca73ca9..e782cb9e 100644 --- a/cps/gdrive.py +++ b/cps/gdrive.py @@ -109,7 +109,7 @@ def revoke_watch_gdrive(): try: gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'], last_watch_response['resourceId']) - except HttpError: + except (HttpError, AttributeError): pass config.config_google_drive_watch_changes_response = {} config.save() diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index 878c1f9f..c4445944 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -56,11 +56,13 @@ try: from pydrive2.auth import GoogleAuth from pydrive2.drive import GoogleDrive from pydrive2.auth import RefreshError + from pydrive2.files import ApiRequestError except ImportError as err: try: from pydrive.auth import GoogleAuth from pydrive.drive import GoogleDrive from pydrive.auth import RefreshError + from pydrive.files import ApiRequestError except ImportError as err: importError = err gdrive_support = False @@ -322,6 +324,11 @@ def getFolderId(path, drive): log.error("gdrive.db DB is not Writeable") log.debug('Database error: %s', ex) session.rollback() + except ApiRequestError as ex: + log.error('{} {}'.format(ex.error['message'], path)) + session.rollback() + except RefreshError as ex: + log.error(ex) return currentFolderId diff --git a/cps/helper.py b/cps/helper.py index e4305550..b8499a12 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -673,9 +673,9 @@ def save_cover(img, book_path): def do_download_file(book, book_format, client, data, headers): if config.config_use_google_drive: - startTime = time.time() + #startTime = time.time() df = gd.getFileFromEbooksFolder(book.path, data.name + "." + book_format) - log.debug('%s', time.time() - startTime) + #log.debug('%s', time.time() - startTime) if df: return gd.do_gdrive_download(df, headers) else: From ec73558b035e866442732074b717f00b729ae514 Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Thu, 23 Dec 2021 19:14:21 +0100 Subject: [PATCH 031/220] Bugfix advanced search for language Update results from testrun --- cps/db.py | 17 +- test/Calibre-Web TestSummary_Linux.html | 740 ++++++++++-------------- 2 files changed, 313 insertions(+), 444 deletions(-) diff --git a/cps/db.py b/cps/db.py index 4b0a7ac7..9c5b5657 100644 --- a/cps/db.py +++ b/cps/db.py @@ -796,23 +796,24 @@ class CalibreDB(): def speaking_language(self, languages=None, return_all_languages=False, with_count=False, reverse_order=False): from . import get_locale - if not languages: - if with_count: + if with_count: + if not languages: languages = self.session.query(Languages, func.count('books_languages_link.book'))\ .join(books_languages_link).join(Books)\ .filter(self.common_filters(return_all_languages=return_all_languages)) \ .group_by(text('books_languages_link.lang_code')).all() - for lang in languages: - lang[0].name = isoLanguages.get_language_name(get_locale(), lang[0].lang_code) - return sorted(languages, key=lambda x: x[0].name, reverse=reverse_order) - else: + for lang in languages: + lang[0].name = isoLanguages.get_language_name(get_locale(), lang[0].lang_code) + return sorted(languages, key=lambda x: x[0].name, reverse=reverse_order) + else: + if not languages: languages = self.session.query(Languages) \ .join(books_languages_link) \ .join(Books) \ .filter(self.common_filters(return_all_languages=return_all_languages)) \ .group_by(text('books_languages_link.lang_code')).all() - for lang in languages: - lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code) + for lang in languages: + lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code) return sorted(languages, key=lambda x: x.name, reverse=reverse_order) diff --git a/test/Calibre-Web TestSummary_Linux.html b/test/Calibre-Web TestSummary_Linux.html index 3c54e88b..d291d368 100644 --- a/test/Calibre-Web TestSummary_Linux.html +++ b/test/Calibre-Web TestSummary_Linux.html @@ -37,20 +37,20 @@
-

Start Time: 2021-12-05 19:18:28

+

Start Time: 2021-12-22 20:24:40

-

Stop Time: 2021-12-05 22:58:48

+

Stop Time: 2021-12-23 00:00:41

-

Duration: 3h 0 min

+

Duration: 2h 57 min

@@ -705,11 +705,11 @@ - + TestEditAdditionalBooks 16 - 14 - 0 + 13 + 1 0 2 @@ -844,11 +844,31 @@ - +
TestEditAdditionalBooks - test_writeonly_path
- PASS + +
+ FAIL +
+ + + + @@ -881,11 +901,11 @@ - + TestEditBooks 35 - 34 - 0 + 32 + 2 0 1 @@ -904,11 +924,31 @@ - +
TestEditBooks - test_edit_author
- PASS + +
+ FAIL +
+ + + + @@ -1057,11 +1097,31 @@ - +
TestEditBooks - test_edit_title
- PASS + +
+ FAIL +
+ + + + @@ -1405,11 +1465,11 @@ - + TestEditBooksOnGdrive 20 - 20 - 0 + 18 + 2 0 0 @@ -1428,11 +1488,31 @@ - +
TestEditBooksOnGdrive - test_edit_author
- PASS + +
+ FAIL +
+ + + + @@ -1554,11 +1634,31 @@ - +
TestEditBooksOnGdrive - test_edit_title
- PASS + +
+ FAIL +
+ + + + @@ -1858,11 +1958,11 @@ - + TestKoboSync 11 - 2 - 9 + 11 + 0 0 0 @@ -1872,35 +1972,11 @@ - +
TestKoboSync - test_book_download
- -
- FAIL -
- - - - + PASS @@ -1914,134 +1990,38 @@ AssertionError: {'Benefits': {}} != {} - +
TestKoboSync - test_kobo_sync_selected_shelfs
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_kobo_upload_book
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_shelves_add_remove_books
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_sync_changed_book
- -
- FAIL -
- - - - + PASS @@ -2055,144 +2035,48 @@ AssertionError: {'Benefits': {}} != {} - +
TestKoboSync - test_sync_reading_state
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_sync_shelf
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_sync_unchanged
- -
- FAIL -
- - - - + PASS - +
TestKoboSync - test_sync_upload
- -
- FAIL -
- - - - + PASS - + TestKoboSyncBig 5 - 1 - 4 + 5 + 0 0 0 @@ -2211,134 +2095,38 @@ AssertionError: {'Benefits': {}} != {} - +
TestKoboSyncBig - test_kobo_sync_selected_shelfs
- -
- FAIL -
- - - - + PASS - +
TestKoboSyncBig - test_sync_changed_book
- -
- FAIL -
- - - - + PASS - +
TestKoboSyncBig - test_sync_reading_state
- -
- FAIL -
- - - - + PASS - +
TestKoboSyncBig - test_sync_shelf
- -
- FAIL -
- - - - + PASS @@ -4377,11 +4165,11 @@ AssertionError: 0 != 1 - + TestCalibreHelper 16 - 16 - 0 + 11 + 5 0 0 @@ -4454,29 +4242,95 @@ AssertionError: 0 != 1 - +
TestCalibreHelper - test_check_chinese_Characters
- PASS + +
+ FAIL +
+ + + + - +
TestCalibreHelper - test_check_deg_eur_replacement
- PASS + +
+ FAIL +
+ + + + - +
TestCalibreHelper - test_check_doubleS
- PASS + +
+ FAIL +
+ + + + @@ -4490,20 +4344,64 @@ AssertionError: 0 != 1 - +
TestCalibreHelper - test_check_high23
- PASS + +
+ FAIL +
+ + + + - +
TestCalibreHelper - test_check_umlauts
- PASS + +
+ FAIL +
+ + + + @@ -4538,8 +4436,8 @@ AssertionError: 0 != 1 Total 378 - 358 - 14 + 361 + 11 0 6   @@ -4581,7 +4479,7 @@ AssertionError: 0 != 1 Babel - 2.9.0 + 2.9.1 Basic @@ -4593,13 +4491,13 @@ AssertionError: 0 != 1 Flask - 1.1.2 + 2.0.2 Basic Flask-Babel - 1.0.0 + 2.0.0 Basic @@ -4627,12 +4525,6 @@ AssertionError: 0 != 1 Basic - - httplib2 - 0.14.0 - Basic - - iso-639 0.4.5 @@ -4641,25 +4533,13 @@ AssertionError: 0 != 1 Jinja2 - 2.11.2 + 3.0.3 Basic lxml - 4.6.3 - Basic - - - - natsort - 7.1.0 - Basic - - - - pyasn1 - 0.4.8 + 4.6.5 Basic @@ -4671,31 +4551,19 @@ AssertionError: 0 != 1 pytz - 2019.3 - Basic - - - - PyYAML - 5.3.1 + 2021.3 Basic requests - 2.22.0 - Basic - - - - six - 1.14.0 + 2.24.0 Basic SQLAlchemy - 1.4.27 + 1.4.28 Basic @@ -4707,7 +4575,7 @@ AssertionError: 0 != 1 Unidecode - 1.1.1 + 1.2.0 Basic @@ -4719,13 +4587,13 @@ AssertionError: 0 != 1 Werkzeug - 1.0.1 + 2.0.2 Basic google-api-python-client - 2.32.0 + 2.33.0 TestCliGdrivedb @@ -4749,13 +4617,13 @@ AssertionError: 0 != 1 PyYAML - 5.3.1 + 6.0 TestCliGdrivedb google-api-python-client - 2.32.0 + 2.33.0 TestEbookConvertCalibreGDrive @@ -4779,13 +4647,13 @@ AssertionError: 0 != 1 PyYAML - 5.3.1 + 6.0 TestEbookConvertCalibreGDrive google-api-python-client - 2.32.0 + 2.33.0 TestEbookConvertGDriveKepubify @@ -4809,7 +4677,7 @@ AssertionError: 0 != 1 PyYAML - 5.3.1 + 6.0 TestEbookConvertGDriveKepubify @@ -4827,7 +4695,7 @@ AssertionError: 0 != 1 google-api-python-client - 2.32.0 + 2.33.0 TestEditBooksOnGdrive @@ -4851,13 +4719,13 @@ AssertionError: 0 != 1 PyYAML - 5.3.1 + 6.0 TestEditBooksOnGdrive google-api-python-client - 2.32.0 + 2.33.0 TestSetupGdrive @@ -4881,7 +4749,7 @@ AssertionError: 0 != 1 PyYAML - 5.3.1 + 6.0 TestSetupGdrive @@ -4899,13 +4767,13 @@ AssertionError: 0 != 1 jsonschema - 4.2.1 + 4.3.2 TestKoboSync jsonschema - 4.2.1 + 4.3.2 TestKoboSyncBig @@ -4917,7 +4785,7 @@ AssertionError: 0 != 1 jsonschema - 4.2.1 + 4.3.2 TestLdapLogin @@ -4935,7 +4803,7 @@ AssertionError: 0 != 1 SQLAlchemy-Utils - 0.37.9 + 0.38.1 TestOAuthLogin @@ -4947,7 +4815,7 @@ AssertionError: 0 != 1
From 222929e7413f97c4b81b093d1f21a8586d5c605d Mon Sep 17 00:00:00 2001 From: cbartondock Date: Thu, 23 Dec 2021 18:05:20 -0500 Subject: [PATCH 032/220] Fixed extra s in scholar metadata --- cps/metadata_provider/scholar.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cps/metadata_provider/scholar.py b/cps/metadata_provider/scholar.py index 6e13c768..9aa0ab87 100644 --- a/cps/metadata_provider/scholar.py +++ b/cps/metadata_provider/scholar.py @@ -19,20 +19,23 @@ from scholarly import scholarly from cps.services.Metadata import Metadata - +import pprint +pp = pprint.PrettyPrinter(indent=4) class scholar(Metadata): __name__ = "Google Scholar" __id__ = "googlescholar" - def search(self, query, generic_cover=""): + def search(self, query, generic_cover=None): val = list() if self.active: scholar_gen = scholarly.search_pubs(' '.join(query.split('+'))) i = 0 for publication in scholar_gen: + print(f"PUBLICATION {i + 1}") + pp.pprint(publication) v = dict() - v['id'] = "1234" # publication['bib'].get('title') + v['id'] = publication['url_scholarbib'].split(':')[1] v['title'] = publication['bib'].get('title') v['authors'] = publication['bib'].get('author', []) v['description'] = publication['bib'].get('abstract', "") @@ -41,8 +44,8 @@ class scholar(Metadata): v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01" else: v['publishedDate'] = "" - v['tags'] = "" - v['ratings'] = 0 + v['tags'] = [] + v['rating'] = 0 v['series'] = "" v['cover'] = generic_cover v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "", @@ -55,6 +58,7 @@ class scholar(Metadata): i += 1 if (i >= 10): break + pp.pprint(val) return val From 4edd1914b4d2b90a826b199167ca55e34258b7c4 Mon Sep 17 00:00:00 2001 From: cbartondock Date: Thu, 23 Dec 2021 23:16:41 -0500 Subject: [PATCH 033/220] Fixed google scholar issues --- cps/metadata_provider/scholar.py | 7 +------ cps/search_metadata.py | 4 ++-- cps/static/js/get_meta.js | 4 +--- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cps/metadata_provider/scholar.py b/cps/metadata_provider/scholar.py index 9aa0ab87..5ff078b9 100644 --- a/cps/metadata_provider/scholar.py +++ b/cps/metadata_provider/scholar.py @@ -19,21 +19,17 @@ from scholarly import scholarly from cps.services.Metadata import Metadata -import pprint -pp = pprint.PrettyPrinter(indent=4) class scholar(Metadata): __name__ = "Google Scholar" __id__ = "googlescholar" - def search(self, query, generic_cover=None): + def search(self, query, generic_cover=""): val = list() if self.active: scholar_gen = scholarly.search_pubs(' '.join(query.split('+'))) i = 0 for publication in scholar_gen: - print(f"PUBLICATION {i + 1}") - pp.pprint(publication) v = dict() v['id'] = publication['url_scholarbib'].split(':')[1] v['title'] = publication['bib'].get('title') @@ -58,7 +54,6 @@ class scholar(Metadata): i += 1 if (i >= 10): break - pp.pprint(val) return val diff --git a/cps/search_metadata.py b/cps/search_metadata.py index e837fe21..b88f222f 100644 --- a/cps/search_metadata.py +++ b/cps/search_metadata.py @@ -103,9 +103,9 @@ def metadata_search(): data = list() active = current_user.view_settings.get('metadata', {}) if query: - static_cover = url_for('static', filename='generic_cover.jpg') + generic_cover = "" with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: - meta = {executor.submit(c.search, query, static_cover): c for c in cl if active.get(c.__id__, True)} + meta = {executor.submit(c.search, query, generic_cover): c for c in cl if active.get(c.__id__, True)} for future in concurrent.futures.as_completed(meta): data.extend(future.result()) return Response(json.dumps(data), mimetype='application/json') diff --git a/cps/static/js/get_meta.js b/cps/static/js/get_meta.js index 51ab740d..2ad4d53b 100644 --- a/cps/static/js/get_meta.js +++ b/cps/static/js/get_meta.js @@ -40,7 +40,7 @@ $(function () { $("#book_title").val(book.title); $("#tags").val(uniqueTags.join(", ")); $("#rating").data("rating").setValue(Math.round(book.rating)); - if(book.cover !== null){ + if(book.cover){ $(".cover img").attr("src", book.cover); $("#cover_url").val(book.cover); } @@ -128,9 +128,7 @@ $(function () { e.preventDefault(); keyword = $("#keyword").val(); $('.pill').each(function(){ - // console.log($(this).data('control')); $(this).data("initial", $(this).prop('checked')); - // console.log($(this).data('initial')); }); doSearch(keyword); }); From 7eb875f388a8e7b708dff3373eb92fde5106b2c4 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Tue, 28 Dec 2021 17:13:18 +0100 Subject: [PATCH 034/220] Improvement for gdrive rename authors --- cps/helper.py | 54 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/cps/helper.py b/cps/helper.py index 21dab9a3..33a87cae 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -360,6 +360,31 @@ def clean_author_database(renamed_author, calibrepath, local_book=None): file_format.name = all_new_name +def clean_author_database_gdrive(renamed_author, calibrepath, local_book=None): + valid_filename_authors = [get_valid_filename(r) for r in renamed_author] + for r in renamed_author: + if local_book: + all_books = [local_book] + else: + all_books = calibre_db.session.query(db.Books) \ + .filter(db.Books.authors.any(db.Authors.name == r)).all() + for book in all_books: + book_author_path = book.path.split('/')[0] + if book_author_path in valid_filename_authors or local_book: + new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first() + all_new_authordir = get_valid_filename(new_author.name) + all_titledir = book.path.split('/')[1] + all_new_path = os.path.join(calibrepath, all_new_authordir, all_titledir) + all_new_name = get_valid_filename(book.title) + ' - ' + all_new_authordir + # change location in database to new author/title path + book.path = os.path.join(all_new_authordir, all_titledir).replace('\\', '/') + for file_format in book.data: + shutil.move(os.path.normcase( + os.path.join(all_new_path, file_format.name + '.' + file_format.format.lower())), + os.path.normcase(os.path.join(all_new_path, all_new_name + '.' + file_format.format.lower()))) + file_format.name = all_new_name + + # was muss gemacht werden: # Die Autorennamen müssen separiert werden und von dupletten bereinigt werden. # Es muss geprüft werden: @@ -465,6 +490,9 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author): new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first() old_author_dir = get_valid_filename(r) new_author_rename_dir = get_valid_filename(new_author.name) + gFile = gd.getFileFromEbooksFolder(None, old_author_dir) + if gFile: + gd.moveGdriveFileRemote(gFile, new_author_rename_dir) '''if os.path.isdir(os.path.join(calibrepath, old_author_dir)): try: old_author_path = os.path.join(calibrepath, old_author_dir) @@ -501,19 +529,6 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author): gd.updateDatabaseOnEdit(gFile['id'], book.path) else: error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found - # Todo: Rename all authors on gdrive - # Rename all files from old names to new names - ''' - try: - clean_author_database(renamed_author, calibrepath) - if first_author not in renamed_author: - clean_author_database([first_author], calibrepath, localbook) - if not renamed_author and not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0: - shutil.rmtree(os.path.dirname(path)) - except (OSError, FileNotFoundError) as ex: - log.error("Error in rename file in path %s", ex) - log.debug(ex, exc_info=True) - return _("Error in rename file in path: %(error)s", error=str(ex))''' if authordir != new_authordir or titledir != new_titledir: new_name = get_valid_filename(book.title) + u' - ' + get_valid_filename(new_authordir) for file_format in book.data: @@ -523,6 +538,19 @@ def update_dir_structure_gdrive(book_id, first_author, renamed_author): break gd.moveGdriveFileRemote(gFile, new_name + u'.' + file_format.format.lower()) file_format.name = new_name + # Todo: Rename all authors on gdrive + # Rename all files from old names to new names + try: + # calibrepath -> config.config_calibre_dir + clean_author_database_gdrive(renamed_author, calibrepath) + if first_author not in renamed_author: + clean_author_database_gdrive([first_author], calibrepath, localbook) + if not renamed_author and len(os.listdir(os.path.dirname(path))) == 0: + shutil.rmtree(os.path.dirname(path)) + except (OSError, FileNotFoundError) as ex: + log.error("Error in rename file in path %s", ex) + log.debug(ex, exc_info=True) + return _("Error in rename file in path: %(error)s", error=str(ex)) return error From 785726deee13b4d56f6c3503dd57c1e3eb7d6f30 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 25 Dec 2021 10:09:11 +0100 Subject: [PATCH 035/220] Migrated some routes to POST - delete shelf, import ldap users - delete_kobo token, kobo force full sync - shutdown, reconnect, shutdown --- cps/admin.py | 10 +++++----- cps/editbooks.py | 17 ++++++----------- cps/kobo_auth.py | 8 ++------ cps/shelf.py | 9 +++++---- cps/static/js/main.js | 29 ++++++++++++++++++----------- cps/templates/shelf.html | 22 ++++------------------ cps/web.py | 5 +++-- 7 files changed, 43 insertions(+), 57 deletions(-) diff --git a/cps/admin.py b/cps/admin.py index 1c228e49..29f5fb20 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -129,11 +129,11 @@ def admin_forbidden(): abort(403) -@admi.route("/shutdown") +@admi.route("/shutdown", methods=["POST"]) @login_required @admin_required def shutdown(): - task = int(request.args.get("parameter").strip()) + task = request.get_json().get('parameter', -1) showtext = {} if task in (0, 1): # valid commandos received # close all database connections @@ -906,7 +906,7 @@ def list_restriction(res_type, user_id): response.headers["Content-Type"] = "application/json; charset=utf-8" return response -@admi.route("/ajax/fullsync") +@admi.route("/ajax/fullsync", methods=["POST"]) @login_required def ajax_fullsync(): count = ub.session.query(ub.KoboSyncedBooks).filter(current_user.id == ub.KoboSyncedBooks.user_id).delete() @@ -1626,7 +1626,7 @@ def edit_user(user_id): page="edituser") -@admi.route("/admin/resetpassword/") +@admi.route("/admin/resetpassword/", methods=["POST"]) @login_required @admin_required def reset_user_password(user_id): @@ -1802,7 +1802,7 @@ def ldap_import_create_user(user, user_data): return 0, message -@admi.route('/import_ldap_users') +@admi.route('/import_ldap_users', methods=["POST"]) @login_required @admin_required def import_ldap_users(): diff --git a/cps/editbooks.py b/cps/editbooks.py index 21dc3ba0..b6fdc164 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -26,6 +26,8 @@ import json from shutil import copyfile from uuid import uuid4 from markupsafe import escape +from functools import wraps + try: from lxml.html.clean import clean_html except ImportError: @@ -51,13 +53,6 @@ from .tasks.upload import TaskUpload from .render_template import render_title_template from .usermanagement import login_required_if_no_ano -try: - from functools import wraps -except ImportError: - pass # We're not using Python 3 - - - editbook = Blueprint('editbook', __name__) log = logger.create() @@ -237,14 +232,14 @@ def modify_identifiers(input_identifiers, db_identifiers, db_session): changed = True return changed, error -@editbook.route("/ajax/delete/") +@editbook.route("/ajax/delete/", methods=["POST"]) @login_required def delete_book_from_details(book_id): return Response(delete_book_from_table(book_id, "", True), mimetype='application/json') -@editbook.route("/delete/", defaults={'book_format': ""}) -@editbook.route("/delete//") +@editbook.route("/delete/", defaults={'book_format': ""}, methods=["POST"]) +@editbook.route("/delete//", methods=["POST"]) @login_required def delete_book_ajax(book_id, book_format): return delete_book_from_table(book_id, book_format, False) @@ -1014,7 +1009,7 @@ def move_coverfile(meta, db_book): category="error") -@editbook.route("/upload", methods=["GET", "POST"]) +@editbook.route("/upload", methods=["POST"]) @login_required_if_no_ano @upload_required def upload(): diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index a51095c8..8d46b26a 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -62,6 +62,7 @@ particular calls to non-Kobo specific endpoints such as the CalibreWeb book down from binascii import hexlify from datetime import datetime from os import urandom +from functools import wraps from flask import g, Blueprint, url_for, abort, request from flask_login import login_user, current_user, login_required @@ -70,11 +71,6 @@ from flask_babel import gettext as _ from . import logger, config, calibre_db, db, helper, ub, lm from .render_template import render_title_template -try: - from functools import wraps -except ImportError: - pass # We're not using Python 3 - log = logger.create() @@ -167,7 +163,7 @@ def generate_auth_token(user_id): ) -@kobo_auth.route("/deleteauthtoken/") +@kobo_auth.route("/deleteauthtoken/", methods=["POST"]) @login_required def delete_auth_token(user_id): # Invalidate any prevously generated Kobo Auth token for this user. diff --git a/cps/shelf.py b/cps/shelf.py index 5f6a66bf..19c2d68e 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -56,7 +56,7 @@ def check_shelf_view_permissions(cur_shelf): return True -@shelf.route("/shelf/add//") +@shelf.route("/shelf/add//", methods=["POST"]) @login_required def add_to_shelf(shelf_id, book_id): xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' @@ -112,7 +112,7 @@ def add_to_shelf(shelf_id, book_id): return "", 204 -@shelf.route("/shelf/massadd/") +@shelf.route("/shelf/massadd/", methods=["POST"]) @login_required def search_to_shelf(shelf_id): shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() @@ -164,7 +164,7 @@ def search_to_shelf(shelf_id): return redirect(url_for('web.index')) -@shelf.route("/shelf/remove//") +@shelf.route("/shelf/remove//", methods=["POST"]) @login_required def remove_from_shelf(shelf_id, book_id): xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' @@ -323,12 +323,13 @@ def delete_shelf_helper(cur_shelf): ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name)) -@shelf.route("/shelf/delete/") +@shelf.route("/shelf/delete/", methods=["POST"]) @login_required def delete_shelf(shelf_id): cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() try: delete_shelf_helper(cur_shelf) + flash(_("Shelf successfully deleted"), category="success") except InvalidRequestError: ub.session.rollback() log.error("Settings DB is not Writeable") diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 585d2296..6cef22f8 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -179,7 +179,7 @@ $("#delete_confirm").click(function() { if (ajaxResponse) { path = getPath() + "/ajax/delete/" + deleteId; $.ajax({ - method:"get", + method:"post", url: path, timeout: 900, success:function(data) { @@ -376,9 +376,11 @@ $(function() { $("#restart").click(function() { $.ajax({ + method:"post", + contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../shutdown", - data: {"parameter":0}, + url: getPath() + "/shutdown", + data: JSON.stringify({"parameter":0}), success: function success() { $("#spinner").show(); setTimeout(restartTimer, 3000); @@ -387,9 +389,11 @@ $(function() { }); $("#shutdown").click(function() { $.ajax({ + method:"post", + contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../shutdown", - data: {"parameter":1}, + url: getPath() + "/shutdown", + data: JSON.stringify({"parameter":1}), success: function success(data) { return alert(data.text); } @@ -447,9 +451,11 @@ $(function() { $("#DialogContent").html(""); $("#spinner2").show(); $.ajax({ + method:"post", + contentType: "application/json; charset=utf-8", dataType: "json", url: getPath() + "/shutdown", - data: {"parameter":2}, + data: JSON.stringify({"parameter":2}), success: function success(data) { $("#spinner2").hide(); $("#DialogContent").html(data.text); @@ -527,7 +533,7 @@ $(function() { $(this).data('value'), function (value) { $.ajax({ - method: "get", + method: "post", url: getPath() + "/kobo_auth/deleteauthtoken/" + value, }); $("#config_delete_kobo_token").hide(); @@ -574,7 +580,7 @@ $(function() { function(value){ path = getPath() + "/ajax/fullsync" $.ajax({ - method:"get", + method:"post", url: path, timeout: 900, success:function(data) { @@ -638,7 +644,7 @@ $(function() { else { $("#InvalidDialog").modal('show'); } - } else { + } else { changeDbSettings(); } } @@ -685,7 +691,7 @@ $(function() { "GeneralDeleteModal", $(this).data('value'), function(value){ - window.location.href = window.location.pathname + "/../../shelf/delete/" + value + $("#delete_shelf").closest("form").submit() } ); @@ -734,7 +740,8 @@ $(function() { $("#DialogContent").html(""); $("#spinner2").show(); $.ajax({ - method:"get", + method:"post", + contentType: "application/json; charset=utf-8", dataType: "json", url: getPath() + "/import_ldap_users", success: function success(data) { diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 4f74a4da..5bb9317e 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -2,14 +2,16 @@ {% block body %}

{{title}}

+
{% if g.user.role_download() %} {{ _('Download') }} {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %} - -
{{ _('Delete this Shelf') }}
+ +
{{ _('Delete this Shelf') }}
{{ _('Edit Shelf Properties') }} +
{% if entries.__len__() %} {{ _('Arrange books manually') }} @@ -84,22 +86,6 @@ {% endfor %}
- - {% endblock %} {% block modal %} {{ delete_confirm_modal() }} diff --git a/cps/web.py b/cps/web.py index f203783b..81c37711 100644 --- a/cps/web.py +++ b/cps/web.py @@ -1055,7 +1055,8 @@ def get_tasks_status(): return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks") -@app.route("/reconnect") +# method is available without login and not protected by CSRF to make it easy reachable +@app.route("/reconnect", methods=['GET']) def reconnect(): calibre_db.reconnect_db(config, ub.app_DB_path) return json.dumps({}) @@ -1435,7 +1436,7 @@ def download_link(book_id, book_format, anyname): return get_download_link(book_id, book_format, client) -@web.route('/send///') +@web.route('/send///', methods=["POST"]) @login_required @download_required def send_to_kindle(book_id, book_format, convert): From 573c9f9fb495f4408d31c8a272702a099fb463d4 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 25 Dec 2021 10:35:08 +0100 Subject: [PATCH 036/220] Improved logging (right stacklevel on mail exceptions) Updated jsonschema requirements --- cps/logger.py | 13 ++++--------- cps/tasks/mail.py | 12 ++++++------ optional-requirements.txt | 2 +- setup.cfg | 2 +- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/cps/logger.py b/cps/logger.py index 5847188b..053d0bd3 100644 --- a/cps/logger.py +++ b/cps/logger.py @@ -42,20 +42,15 @@ logging.addLevelName(logging.CRITICAL, "CRIT") class _Logger(logging.Logger): - def debug_or_exception(self, message, *args, **kwargs): + def debug_or_exception(self, message, stacklevel=2, *args, **kwargs): if sys.version_info > (3, 7): if is_debug_enabled(): - self.exception(message, stacklevel=2, *args, **kwargs) + self.exception(message, stacklevel=stacklevel, *args, **kwargs) else: - self.error(message, stacklevel=2, *args, **kwargs) - elif sys.version_info > (3, 0): - if is_debug_enabled(): - self.exception(message, stack_info=True, *args, **kwargs) - else: - self.error(message, *args, **kwargs) + self.error(message, stacklevel=stacklevel, *args, **kwargs) else: if is_debug_enabled(): - self.exception(message, *args, **kwargs) + self.exception(message, stack_info=True, *args, **kwargs) else: self.error(message, *args, **kwargs) diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 05b2175f..03526c8b 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -158,10 +158,10 @@ class TaskEmail(CalibreTask): else: self.send_gmail_email(msg) except MemoryError as e: - log.debug_or_exception(e) + log.debug_or_exception(e, stacklevel=3) self._handleError(u'MemoryError sending e-mail: {}'.format(str(e))) except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e: - log.debug_or_exception(e) + log.debug_or_exception(e, stacklevel=3) if hasattr(e, "smtp_error"): text = e.smtp_error.decode('utf-8').replace("\n", '. ') elif hasattr(e, "message"): @@ -171,11 +171,11 @@ class TaskEmail(CalibreTask): else: text = '' self._handleError(u'Smtplib Error sending e-mail: {}'.format(text)) - except socket.error as e: - log.debug_or_exception(e) + except (socket.error) as e: + log.debug_or_exception(e, stacklevel=3) self._handleError(u'Socket Error sending e-mail: {}'.format(e.strerror)) except Exception as ex: - log.debug_or_exception(ex) + log.debug_or_exception(ex, stacklevel=3) self._handleError(u'Error sending e-mail: {}'.format(ex)) def send_standard_email(self, msg): @@ -248,7 +248,7 @@ class TaskEmail(CalibreTask): data = file_.read() file_.close() except IOError as e: - log.debug_or_exception(e) + log.debug_or_exception(e, stacklevel=3) log.error(u'The requested file could not be read. Maybe wrong permissions?') return None # Set mimetype diff --git a/optional-requirements.txt b/optional-requirements.txt index 3fac14ca..c0009c94 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -38,4 +38,4 @@ natsort>=2.2.0,<8.1.0 comicapi>=2.2.0,<2.3.0 #Kobo integration -jsonschema>=3.2.0,<4.3.0 +jsonschema>=3.2.0,<4.4.0 diff --git a/setup.cfg b/setup.cfg index 87f6055f..95f3502d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -87,7 +87,7 @@ comics = natsort>=2.2.0,<8.1.0 comicapi>= 2.2.0,<2.3.0 kobo = - jsonschema>=3.2.0,<4.3.0 + jsonschema>=3.2.0,<4.4.0 From f39dc100b458e86d195a007619da7292af7033e1 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sat, 25 Dec 2021 21:39:58 +0100 Subject: [PATCH 037/220] Migrated some routes to POST - shelf massadd - resetpassword - delete shelf - send to kindle --- cps/static/js/details.js | 2 +- cps/static/js/main.js | 36 +++++++++++++++++++++++++++++++++--- cps/templates/detail.html | 6 +++--- cps/templates/search.html | 2 +- cps/templates/shelf.html | 4 ++-- cps/templates/user_edit.html | 2 +- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/cps/static/js/details.js b/cps/static/js/details.js index 9caf9470..3a7def32 100644 --- a/cps/static/js/details.js +++ b/cps/static/js/details.js @@ -62,7 +62,7 @@ $("#archived_cb").on("change", function() { $("#shelf-actions").on("click", "[data-shelf-action]", function (e) { e.preventDefault(); - $.get(this.href) + $.post(this.href) .done(function() { var $this = $(this); switch ($this.data("shelf-action")) { diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 6cef22f8..e8da76ef 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -20,6 +20,20 @@ function getPath() { return jsFileLocation.substr(0, jsFileLocation.search("/static/js/libs/jquery.min.js")); // the js folder path } +function postButton(event, action){ + event.preventDefault(); + var newForm = jQuery('
', { + "action": action, + 'target': "_top", + 'method': "post" + }).append(jQuery('', { + 'name': 'csrf_token', + 'value': $("input[name=\'csrf_token\']").val(), + 'type': 'hidden' + })).appendTo('body'); + newForm.submit(); +} + function elementSorter(a, b) { a = +a.slice(0, -2); b = +b.slice(0, -2); @@ -71,6 +85,22 @@ $(document).on("change", "select[data-controlall]", function() { } }); +/*$(document).on("click", "#sendbtn", function (event) { + postButton(event, $(this).data('action')); +}); + +$(document).on("click", ".sendbutton", function (event) { + // $(".sendbutton").on("click", "body", function(event) { + postButton(event, $(this).data('action')); +});*/ + +$(document).on("click", ".postAction", function (event) { + // $(".sendbutton").on("click", "body", function(event) { + postButton(event, $(this).data('action')); +}); + + + // Syntax has to be bind not on, otherwise problems with firefox $(".container-fluid").bind("dragenter dragover", function () { if($("#btn-upload").length && !$('body').hasClass('shelforder')) { @@ -685,13 +715,14 @@ $(function() { }); }); - $("#delete_shelf").click(function() { + $("#delete_shelf").click(function(event) { confirmDialog( $(this).attr('id'), "GeneralDeleteModal", $(this).data('value'), function(value){ - $("#delete_shelf").closest("form").submit() + postButton(event, $("#delete_shelf").data("action")); + // $("#delete_shelf").closest("form").submit() } ); @@ -775,4 +806,3 @@ $(function() { }); }); }); - diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 0ecaf903..1fd5f09a 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -38,16 +38,16 @@ {% endif %} {% if g.user.kindle_mail and kindle_list %} {% if kindle_list.__len__() == 1 %} - {{kindle_list[0]['text']}} +
{{kindle_list[0]['text']}}
{% else %}
-
diff --git a/cps/templates/search.html b/cps/templates/search.html index 627beaf8..32339803 100644 --- a/cps/templates/search.html +++ b/cps/templates/search.html @@ -17,7 +17,7 @@ diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 5bb9317e..adfead60 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -2,14 +2,14 @@ {% block body %}

{{title}}

- + {% if g.user.role_download() %} {{ _('Download') }} {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %} -
{{ _('Delete this Shelf') }}
+
{{ _('Delete this Shelf') }}
{{ _('Edit Shelf Properties') }} {% if entries.__len__() %} diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index e6fbdb74..de7a4fb3 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -17,7 +17,7 @@
{% if ( g.user and g.user.role_passwd() or g.user.role_admin() ) and not content.role_anonymous() %} {% if g.user and g.user.role_admin() and not new_user and not profile and ( mail_configured and content.email if content.email != None ) %} - {{_('Reset user Password')}} + {{_('Reset user Password')}} {% endif %}
From 47f5e2ffb4253b57271e76444e465da425752239 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sun, 26 Dec 2021 10:31:04 +0100 Subject: [PATCH 038/220] Remove python2 urllib imports Fix for "javascript:" script links in identifier --- cps/db.py | 3 +++ cps/helper.py | 5 +---- cps/kobo.py | 6 +----- cps/redirect.py | 6 ++---- cps/services/SyncToken.py | 7 ++----- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/cps/db.py b/cps/db.py index 9c5b5657..470ecc1c 100644 --- a/cps/db.py +++ b/cps/db.py @@ -23,6 +23,7 @@ import re import ast import json from datetime import datetime +from urllib.parse import quote from sqlalchemy import create_engine from sqlalchemy import Table, Column, ForeignKey, CheckConstraint @@ -166,6 +167,8 @@ class Identifiers(Base): return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val) elif format_type == "isfdb": return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val) + elif self.val.lower().startswith("javascript:"): + return quote(self.val) else: return u"{0}".format(self.val) diff --git a/cps/helper.py b/cps/helper.py index b8499a12..e8a0b738 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -38,11 +38,8 @@ from sqlalchemy.sql.expression import true, false, and_, text, func from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash from markupsafe import escape +from urllib.parse import quote -try: - from urllib.parse import quote -except ImportError: - from urllib import quote try: import unidecode diff --git a/cps/kobo.py b/cps/kobo.py index e0395855..b5ff000e 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -23,11 +23,7 @@ import os import uuid from time import gmtime, strftime import json - -try: - from urllib import unquote -except ImportError: - from urllib.parse import unquote +from urllib.parse import unquote from flask import ( Blueprint, diff --git a/cps/redirect.py b/cps/redirect.py index d491b353..8bd68109 100644 --- a/cps/redirect.py +++ b/cps/redirect.py @@ -27,10 +27,8 @@ # http://flask.pocoo.org/snippets/62/ -try: - from urllib.parse import urlparse, urljoin -except ImportError: - from urlparse import urlparse, urljoin +from urllib.parse import urlparse, urljoin + from flask import request, url_for, redirect diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py index 85ed5032..2e23efe2 100644 --- a/cps/services/SyncToken.py +++ b/cps/services/SyncToken.py @@ -21,11 +21,8 @@ import sys from base64 import b64decode, b64encode from jsonschema import validate, exceptions, __version__ from datetime import datetime -try: - # pylint: disable=unused-import - from urllib import unquote -except ImportError: - from urllib.parse import unquote + +from urllib.parse import unquote from flask import json from .. import logger From 3e0d8763c377d2146462811e3e4ccf13f0d312ce Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Sun, 26 Dec 2021 10:46:43 +0100 Subject: [PATCH 039/220] Prevent 2 public shelfs with same names due to changing public property --- cps/shelf.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cps/shelf.py b/cps/shelf.py index 19c2d68e..b1fbdd04 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -248,12 +248,13 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False): if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on": flash(_(u"Sorry you are not allowed to create a public shelf"), category="error") return redirect(url_for('web.index')) - shelf.is_public = 1 if to_save.get("is_public") else 0 + is_public = 1 if to_save.get("is_public") else 0 if config.config_kobo_sync: shelf.kobo_sync = True if to_save.get("kobo_sync") else False shelf_title = to_save.get("title", "") - if check_shelf_is_unique(shelf, shelf_title, shelf_id): + if check_shelf_is_unique(shelf, shelf_title, is_public, shelf_id): shelf.name = shelf_title + shelf.is_public = is_public if not shelf_id: shelf.user_id = int(current_user.id) ub.session.add(shelf) @@ -284,12 +285,12 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False): sync_only_selected_shelves=sync_only_selected_shelves) -def check_shelf_is_unique(shelf, title, shelf_id=False): +def check_shelf_is_unique(shelf, title, is_public, shelf_id=False): if shelf_id: ident = ub.Shelf.id != shelf_id else: ident = true() - if shelf.is_public == 1: + if is_public == 1: is_shelf_name_unique = ub.session.query(ub.Shelf) \ .filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \ .filter(ident) \ From f22e4d996c0f647b506cd42c60407a9ee8c78818 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Mon, 27 Dec 2021 09:31:32 +0100 Subject: [PATCH 040/220] Proxy kobo library sync at the end of local sync --- cps/kobo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cps/kobo.py b/cps/kobo.py index b5ff000e..c74a3230 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -321,7 +321,7 @@ def HandleSyncRequest(): def generate_sync_response(sync_token, sync_results, set_cont=False): extra_headers = {} - if config.config_kobo_proxy: + if config.config_kobo_proxy and not set_cont: # Merge in sync results from the official Kobo store. try: store_response = make_request_to_kobo_store(sync_token) From 0548fbb685569851ec5a37b3650c5b6c54ee4447 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Mon, 27 Dec 2021 09:37:52 +0100 Subject: [PATCH 041/220] Bugfix post commands without updater --- cps/templates/admin.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 9728cb6d..6cd7815e 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -188,9 +188,8 @@ - - {% if feature_support['updater'] %} + {% if feature_support['updater'] %}
{{_('Check for Update')}}
From ae1f515446e7ee1f31700df42b810a0ce1061cf5 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Mon, 27 Dec 2021 20:02:42 +0100 Subject: [PATCH 042/220] Bugfix uncheck all ekements in books list and user list Improved testability for books list --- cps/static/js/table.js | 30 +++++++++++++++--------------- cps/templates/book_table.html | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cps/static/js/table.js b/cps/static/js/table.js index e98f6a8b..112ca957 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -47,15 +47,15 @@ $(function() { var rows = rowsAfter; if (e.type === "uncheck-all") { - rows = rowsBefore; + selections = []; + } else { + var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) { + return row.id; + }); + + var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference"; + selections = window._[func](selections, ids); } - - var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) { - return row.id; - }); - - var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference"; - selections = window._[func](selections, ids); if (selections.length >= 2) { $("#merge_books").removeClass("disabled"); $("#merge_books").attr("aria-disabled", false); @@ -540,14 +540,14 @@ $(function() { var rows = rowsAfter; if (e.type === "uncheck-all") { - rows = rowsBefore; + selections = []; + } else { + var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) { + return row.id; + }); + var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference"; + selections = window._[func](selections, ids); } - - var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) { - return row.id; - }); - var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference"; - selections = window._[func](selections, ids); handle_header_buttons(); }); }); diff --git a/cps/templates/book_table.html b/cps/templates/book_table.html index fb0cfed8..eb5c1bff 100644 --- a/cps/templates/book_table.html +++ b/cps/templates/book_table.html @@ -120,8 +120,8 @@
From c095ee3c144765ed559861caef2d0b045f704329 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Tue, 28 Dec 2021 11:31:32 +0100 Subject: [PATCH 043/220] Fix #2243 (whitespaces are trimmed also for normal search) --- cps/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cps/web.py b/cps/web.py index 81c37711..e139848e 100644 --- a/cps/web.py +++ b/cps/web.py @@ -1069,7 +1069,7 @@ def reconnect(): def search(): term = request.args.get("query") if term: - return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term)) + return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip())) else: return render_title_template('search.html', searchterm="", From a90177afa0bd1846cf7cbb92b93b770aff065082 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Tue, 28 Dec 2021 20:17:51 +0100 Subject: [PATCH 044/220] Better version output in about page (exe file, pyPi, git commit string removed if empty) --- cps/about.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cps/about.py b/cps/about.py index ba5a99af..7e839520 100644 --- a/cps/about.py +++ b/cps/about.py @@ -74,13 +74,22 @@ opt = dep_check.load_dependencys(True) for i in (req + opt): ret[i[1]] = i[0] +if constants.NIGHTLY_VERSION[0] == "$Format:%H$": + calibre_web_version = constants.STABLE_VERSION['version'] +else: + calibre_web_version = (constants.STABLE_VERSION['version'] + ' - ' + + constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - ' + + constants.NIGHTLY_VERSION[1].replace('%','%%')) +if getattr(sys, 'frozen', False): + calibre_web_version += " - Exe-Version" +elif constants.HOME_CONFIG: + calibre_web_version += " - pyPi" + if not ret: _VERSIONS = OrderedDict( Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()), Python=sys.version, - Calibre_Web=constants.STABLE_VERSION['version'] + ' - ' - + constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - ' - + constants.NIGHTLY_VERSION[1].replace('%','%%'), + Calibre_Web=calibre_web_version, WebServer=server.VERSION, Flask=flask.__version__, Flask_Login=flask_loginVersion, @@ -110,9 +119,7 @@ else: _VERSIONS = OrderedDict( Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()), Python = sys.version, - Calibre_Web = constants.STABLE_VERSION['version'] + ' - ' - + constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - ' - + constants.NIGHTLY_VERSION[1].replace('%', '%%'), + Calibre_Web=calibre_web_version, Werkzeug = werkzeug.__version__, Jinja2=jinja2.__version__, pySqlite = sqlite3.version, From c1030dfd1340ff65f9b4ea2d20a8c048b7db2e14 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Thu, 30 Dec 2021 14:44:35 +0100 Subject: [PATCH 045/220] Update dependency scholary --- optional-requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/optional-requirements.txt b/optional-requirements.txt index c0009c94..b90951aa 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -31,7 +31,7 @@ SQLAlchemy-Utils>=0.33.5,<0.38.0 # extracting metadata rarfile>=2.7 -scholarly>=1.2.0, <1.5 +scholarly>=1.2.0, <1.6 # other natsort>=2.2.0,<8.1.0 diff --git a/setup.cfg b/setup.cfg index 95f3502d..a81102b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,7 +82,7 @@ oauth = SQLAlchemy-Utils>=0.33.5,<0.38.0 metadata = rarfile>=2.7 - scholarly>=1.2.0,<1.5 + scholarly>=1.2.0,<1.6 comics = natsort>=2.2.0,<8.1.0 comicapi>= 2.2.0,<2.3.0 From bbadfa22511c2ae8fb4cdcaa326ccc1be66a07f4 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Tue, 4 Jan 2022 21:11:52 +0100 Subject: [PATCH 046/220] bugfixes load metadata --- cps/metadata_provider/scholar.py | 2 +- cps/templates/book_edit.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cps/metadata_provider/scholar.py b/cps/metadata_provider/scholar.py index 5ff078b9..df387831 100644 --- a/cps/metadata_provider/scholar.py +++ b/cps/metadata_provider/scholar.py @@ -43,7 +43,7 @@ class scholar(Metadata): v['tags'] = [] v['rating'] = 0 v['series'] = "" - v['cover'] = generic_cover + v['cover'] = "" v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "", v['source'] = { "id": self.__id__, diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index cac3219c..9d2ac14e 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -226,7 +226,7 @@