From 93b19165cf422df8fbcd2a943e61ece133a08ffe Mon Sep 17 00:00:00 2001 From: OzzieIsaacs Date: Wed, 15 Feb 2017 18:09:17 +0100 Subject: [PATCH] Added polish in readme to supported UI languages Handling of missing tags in fb import naming of path is more imitating calibre (replacement of special characters, "pinyining" of author names if unidecode is available ) Sorting of authors (similar to calibre for jr./sr./I..IV endings) bugfix pathseparator on windows and linux during upload bugfix os.rename for authordir publishing date on detailview is formated according to slected locale filename on downloading from web ui is now correct displayed added ids to html for testing --- cps/book_formats.py | 2 +- cps/db.py | 32 +++++------ cps/fb2.py | 32 +++++------ cps/helper.py | 62 ++++++++++++--------- cps/templates/admin.html | 11 ++-- cps/templates/detail.html | 4 +- cps/templates/index.html | 4 +- cps/templates/languages.html | 2 +- cps/templates/layout.html | 36 ++++++------ cps/templates/list.html | 2 +- cps/templates/login.html | 2 +- cps/templates/osd.xml | 2 +- cps/templates/stats.html | 4 +- cps/templates/user_edit.html | 4 +- cps/translations/de/LC_MESSAGES/messages.po | 28 +++++----- cps/ub.py | 9 --- cps/web.py | 41 +++++++------- readme.md | 2 +- 18 files changed, 141 insertions(+), 138 deletions(-) diff --git a/cps/book_formats.py b/cps/book_formats.py index d295452a..64ec86e8 100644 --- a/cps/book_formats.py +++ b/cps/book_formats.py @@ -99,7 +99,7 @@ def pdf_preview(tmp_file_path, tmp_dir): return None else: cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg" - with Image(filename=tmp_file_path +"[0]", resolution=150) as img: + with Image(filename=tmp_file_path + "[0]", resolution=150) as img: img.compression_quality = 88 img.save(filename=os.path.join(tmp_dir, cover_file_name)) return cover_file_name diff --git a/cps/db.py b/cps/db.py index 67526e2c..f6ee790e 100755 --- a/cps/db.py +++ b/cps/db.py @@ -32,29 +32,29 @@ def title_sort(title): Base = declarative_base() books_authors_link = Table('books_authors_link', Base.metadata, - Column('book', Integer, ForeignKey('books.id'), primary_key=True), - Column('author', Integer, ForeignKey('authors.id'), primary_key=True) - ) + Column('book', Integer, ForeignKey('books.id'), primary_key=True), + Column('author', Integer, ForeignKey('authors.id'), primary_key=True) + ) books_tags_link = Table('books_tags_link', Base.metadata, - Column('book', Integer, ForeignKey('books.id'), primary_key=True), - Column('tag', Integer, ForeignKey('tags.id'), primary_key=True) - ) + Column('book', Integer, ForeignKey('books.id'), primary_key=True), + Column('tag', Integer, ForeignKey('tags.id'), primary_key=True) + ) books_series_link = Table('books_series_link', Base.metadata, - Column('book', Integer, ForeignKey('books.id'), primary_key=True), - Column('series', Integer, ForeignKey('series.id'), primary_key=True) - ) + Column('book', Integer, ForeignKey('books.id'), primary_key=True), + Column('series', Integer, ForeignKey('series.id'), primary_key=True) + ) books_ratings_link = Table('books_ratings_link', Base.metadata, - Column('book', Integer, ForeignKey('books.id'), primary_key=True), - Column('rating', Integer, ForeignKey('ratings.id'), primary_key=True) - ) + Column('book', Integer, ForeignKey('books.id'), primary_key=True), + Column('rating', Integer, ForeignKey('ratings.id'), primary_key=True) + ) books_languages_link = Table('books_languages_link', Base.metadata, - Column('book', Integer, ForeignKey('books.id'), primary_key=True), - Column('lang_code', Integer, ForeignKey('languages.id'), primary_key=True) - ) + Column('book', Integer, ForeignKey('books.id'), primary_key=True), + Column('lang_code', Integer, ForeignKey('languages.id'), primary_key=True) + ) class Identifiers(Base): @@ -227,7 +227,7 @@ class Books(Base): identifiers = relationship('Identifiers', backref='books') def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover, - authors, tags): # ToDO check Authors and tags necessary + authors, tags): self.title = title self.sort = sort self.author_sort = author_sort diff --git a/cps/fb2.py b/cps/fb2.py index aa887068..205f69ce 100644 --- a/cps/fb2.py +++ b/cps/fb2.py @@ -6,7 +6,7 @@ import os import uploader import StringIO -# ToDo: Check usage of original_file_name + def get_fb2_info(tmp_file_path, original_file_extension): ns = { @@ -20,37 +20,35 @@ def get_fb2_info(tmp_file_path, original_file_extension): authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns) def get_author(element): - last_name=element.xpath('fb:last-name/text()', namespaces=ns) + last_name = element.xpath('fb:last-name/text()', namespaces=ns) if len(last_name): - last_name=last_name[0] + last_name = last_name[0] else: - last_name=u'' - middle_name=element.xpath('fb:middle-name/text()', namespaces=ns) + last_name = u'' + middle_name = element.xpath('fb:middle-name/text()', namespaces=ns) if len(middle_name): - middle_name=middle_name[0] + middle_name = middle_name[0] else: - middle_name=u'' - first_name=element.xpath('fb:first-name/text()', namespaces=ns) + middle_name = u'' + first_name = element.xpath('fb:first-name/text()', namespaces=ns) if len(first_name): - first_name=first_name[0] + first_name = first_name[0] else: - first_name=u'' - return first_name + ' ' + middle_name + ' ' + last_name + first_name = u'' + return first_name + ' ' + middle_name + ' ' + last_name author = unicode(", ".join(map(get_author, authors))) title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns) if len(title): - title=unicode(title[0]) + title = unicode(title[0]) else: - title=u'' + title = u'' description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns) if len(description): - description=unicode(description[0]) + description = unicode(description[0]) else: - description=u'' - - + description = u'' return uploader.BookMeta( file_path=tmp_file_path, diff --git a/cps/helper.py b/cps/helper.py index a0867660..f9251147 100755 --- a/cps/helper.py +++ b/cps/helper.py @@ -22,6 +22,11 @@ from email.generator import Generator from flask_babel import gettext as _ import subprocess import shutil +try: + import unidecode + use_unidecode=True +except: + use_unidecode=False def update_download(book_id, user_id): check = ub.session.query(ub.Downloads).filter(ub.Downloads.user_id == user_id).filter(ub.Downloads.book_id == @@ -203,7 +208,7 @@ def get_attachment(file_path): return attachment except IOError: traceback.print_exc() - message = (_('The requested file could not be read. Maybe wrong permissions?')) # ToDo: What is this? + app.logger.error = (u'The requested file could not be read. Maybe wrong permissions?') return None @@ -212,47 +217,54 @@ def get_valid_filename(value, replace_whitespace=True): Returns the given string converted to a string that can be used for a clean filename. Limits num characters to 128 max. """ - value = value[:128] - # re_slugify = re.compile('[^\w\s-]', re.UNICODE) - value = unicodedata.normalize('NFKD', value) - re_slugify = re.compile('[^\w\s-]', re.UNICODE) - value = unicode(re_slugify.sub('', value).strip()) + if value[-1:] ==u'.': + value = value[:-1]+u'_' + if use_unidecode: + value=(unidecode.unidecode(value)).strip() + else: + value=value.replace('§','SS') + value=value.replace('ß','ss') + value = unicodedata.normalize('NFKD', value) + re_slugify = re.compile('[\W\s-]', re.UNICODE) + value = unicode(re_slugify.sub('', value).strip()) if replace_whitespace: - value = re.sub('[\s]+', '_', value, flags=re.U) - value = value.replace(u"\u00DF", "ss") + #*+:\"/<>? werden durch _ ersetzt + value = re.sub('[\*\+:\\\"/<>\?]+', '_', value, flags=re.U) + + value = value[:128] return value +def get_sorted_author(value): + regexes = ["^(JR|SR)\.?$","^I{1,3}\.?$","^IV\.?$"] + combined = "(" + ")|(".join(regexes) + ")" + value = value.split(" ") + if re.match(combined,value[-1].upper()): + value2 = value[-2] + ", " + " ".join(value[:-2]) + " " + value[-1] + else: + value2 = value[-1] + ", " + " ".join(value[:-1]) + return value2 -def get_normalized_author(value): - """ - Normalizes sorted author name - """ - value = unicodedata.normalize('NFKD', value) - value = re.sub('[^\w,\s]', '', value, flags=re.U) - value = " ".join(value.split(", ")[::-1]) - return value - def update_dir_stucture(book_id, calibrepath): db.session.connection().connection.connection.create_function("title_sort", 1, db.title_sort) book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - path = os.path.join(calibrepath, book.path) + path = os.path.join(calibrepath, book.path)#.replace('/',os.path.sep)).replace('\\',os.path.sep) - authordir = book.path.split(os.sep)[0] - new_authordir = get_valid_filename(book.authors[0].name, False) - titledir = book.path.split(os.sep)[1] - new_titledir = get_valid_filename(book.title, False) + " (" + str(book_id) + ")" + authordir = book.path.split('/')[0] + new_authordir = get_valid_filename(book.authors[0].name) + titledir = book.path.split('/')[1] + new_titledir = get_valid_filename(book.title) + " (" + str(book_id) + ")" if titledir != new_titledir: new_title_path = os.path.join(os.path.dirname(path), new_titledir) os.rename(path, new_title_path) path = new_title_path - book.path = book.path.split(os.sep)[0] + os.sep + new_titledir + book.path = book.path.split('/')[0] + '/' + new_titledir if authordir != new_authordir: new_author_path = os.path.join(os.path.join(calibrepath, new_authordir), os.path.basename(path)) - os.renames(path, new_author_path) - book.path = new_authordir + os.sep + book.path.split(os.sep)[1] + os.rename(path, new_author_path) + book.path = new_authordir + '/' + book.path.split('/')[1] db.session.commit() diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 2b70e17b..31c167ee 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -2,7 +2,7 @@ {% block body %}

{{_('User list')}}

- +
@@ -30,9 +30,9 @@ {% endif %} {% endfor %}
{{_('Nickname')}} {{_('Email')}}
- +

{{_('SMTP mail settings')}}

- +
@@ -51,10 +51,10 @@
{{_('SMTP hostname')}} {{_('SMTP port')}}
- +

{{_('Configuration')}}

- +
@@ -76,6 +76,7 @@

{{_('Administration')}}

{% if not development %} +

{{_('Current commit timestamp')}}: {{commit}}

{{_('Restart Calibre-web')}}
{{_('Stop Calibre-web')}}
{{_('Check for update')}}
diff --git a/cps/templates/detail.html b/cps/templates/detail.html index 4fa37a1d..8775f079 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -70,8 +70,8 @@

{% endif %} - {% if entry.pubdate != '0101-01-01 00:00:00' %} -

{{_('Publishing date')}}: {{entry.pubdate[:10]}}

+ {% if entry.pubdate[:10] != '0101-01-01' %} +

{{_('Publishing date')}}: {{entry.pubdate|formatdate}}

{% endif %} {% if cc|length > 0 %}

diff --git a/cps/templates/index.html b/cps/templates/index.html index a59ae6b3..7ab46a7b 100755 --- a/cps/templates/index.html +++ b/cps/templates/index.html @@ -6,7 +6,7 @@

{% for entry in random %} -
+
{% if entry.has_cover %} @@ -41,7 +41,7 @@

{{title}}

{% for entry in entries %} -
+
diff --git a/cps/templates/layout.html b/cps/templates/layout.html index e9157b10..7e63b93b 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -80,16 +80,16 @@ {% endif %} {% endif %} {% if g.user.role_admin() %} -
  • {{_('Admin')}}
  • +
  • {{_('Admin')}}
  • {% endif %} -
  • {{g.user.nickname}}
  • +
  • {{g.user.nickname}}
  • {% if not g.user.is_anonymous() %} -
  • {{_('Logout')}}
  • +
  • {{_('Logout')}}
  • {% endif %} {% endif %} {% if g.allow_registration and not g.user.is_authenticated %} -
  • {{_('Login')}}
  • -
  • {{_('Register')}}
  • +
  • {{_('Login')}}
  • +
  • {{_('Register')}}
  • {% endif %}
    @@ -98,17 +98,17 @@ {% for message in get_flashed_messages(with_categories=True) %} {%if message[0] == "error" %}
    -
    {{ message[1] }}
    +
    {{ message[1] }}
    {%endif%} {%if message[0] == "info" %}
    -
    {{ message[1] }}
    +
    {{ message[1] }}
    {%endif%} {%if message[0] == "success" %}
    -
    {{ message[1] }}
    +
    {{ message[1] }}
    {%endif%} {% endfor %} @@ -119,25 +119,25 @@
    {% if error %} diff --git a/cps/templates/osd.xml b/cps/templates/osd.xml index 35c1147c..77709478 100644 --- a/cps/templates/osd.xml +++ b/cps/templates/osd.xml @@ -2,7 +2,7 @@ {{instance}} {{instance}} - {{_('instanceCalibre Web ebook catalog')}} + {{_('Calibre Web ebook catalog')}} Janeczku https://github.com/janeczku/calibre-web {{_('Linked libraries')}} -
    {{_('Calibre DB dir')}} {{_('Log Level')}}
    +
    @@ -30,7 +30,7 @@
    {{_('Program library')}}

    {{_('Calibre library statistics')}}

    - +
    diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index 74469124..b9cff3eb 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -27,7 +27,7 @@ @@ -108,7 +108,7 @@ {% endif %} {% if not profile %} - {{_('Back')}} + {{_('Back')}} {% endif %} diff --git a/cps/translations/de/LC_MESSAGES/messages.po b/cps/translations/de/LC_MESSAGES/messages.po index 7ba0050e..3fb5cc32 100644 --- a/cps/translations/de/LC_MESSAGES/messages.po +++ b/cps/translations/de/LC_MESSAGES/messages.po @@ -81,7 +81,7 @@ msgstr "Beliebte Bücher (die meisten Downloads)" #: cps/web.py:813 msgid "Best rated books" -msgstr "" +msgstr "Best bewertete Bücher" #: cps/templates/index.xml:36 cps/web.py:822 msgid "Random Books" @@ -94,7 +94,7 @@ msgstr "Autorenliste" #: cps/web.py:846 #, python-format msgid "Author: %(name)s" -msgstr "" +msgstr "Autor: %(name)s" #: cps/web.py:848 cps/web.py:876 cps/web.py:975 cps/web.py:1216 cps/web.py:2103 msgid "Error opening eBook. File does not exist or file is not accessible:" @@ -143,7 +143,7 @@ msgstr "Server wird runtergefahren, bitte Fenster schließen" #: cps/web.py:1055 msgid "Update done" -msgstr "" +msgstr "Update durchgeführt" #: cps/web.py:1128 cps/web.py:1141 msgid "search" @@ -470,11 +470,11 @@ msgstr "Stoppe Calibre-web" #: cps/templates/admin.html:81 msgid "Check for update" -msgstr "" +msgstr "Suche nach Update" #: cps/templates/admin.html:82 msgid "Perform Update" -msgstr "" +msgstr "Update durchführen" #: cps/templates/admin.html:93 msgid "Do you really want to restart Calibre-web?" @@ -584,7 +584,7 @@ msgstr "Öffentliche Registrierung aktivieren" #: cps/templates/config_edit.html:52 msgid "Default Settings for new users" -msgstr "" +msgstr "Default Einstellungen für neue Benutzer" #: cps/templates/config_edit.html:55 cps/templates/user_edit.html:80 msgid "Admin user" @@ -625,7 +625,7 @@ msgstr "Sprache" #: cps/templates/detail.html:74 msgid "Publishing date" -msgstr "" +msgstr "Herausgabedatum" #: cps/templates/detail.html:106 msgid "Description:" @@ -699,11 +699,11 @@ msgstr "Beliebte Bücher" #: cps/templates/index.xml:19 msgid "Popular publications from this catalog based on Downloads." -msgstr "" +msgstr "Beliebte Publikationen aus dieser Bibliothek basierend auf Downloadzahlen" #: cps/templates/index.xml:22 cps/templates/layout.html:127 msgid "Best rated Books" -msgstr "" +msgstr "Best bewertete Bücher" #: cps/templates/index.xml:26 msgid "Popular publications from this catalog based on Rating." @@ -804,8 +804,8 @@ msgid "Remember me" msgstr "Merken" #: cps/templates/osd.xml:5 -msgid "instanceCalibre Web ebook catalog" -msgstr "" +msgid "Calibre Web ebook catalog" +msgstr "Calibre Web Ebook Katalog" #: cps/templates/read.html:136 msgid "Reflow text when sidebars are open." @@ -909,11 +909,11 @@ msgstr "Autoren in dieser Bibliothek" #: cps/templates/stats.html:45 msgid "Categories in this Library" -msgstr "" +msgstr "Kategorien in dieser Bibliothek" #: cps/templates/stats.html:49 msgid "Series in this Library" -msgstr "" +msgstr "Serien in dieser Bibliothek" #: cps/templates/user_edit.html:23 msgid "Kindle E-Mail" @@ -937,7 +937,7 @@ msgstr "Zeige Auswahl Beliebte Bücher" #: cps/templates/user_edit.html:53 msgid "Show best rated books" -msgstr "" +msgstr "Zeige am besten bewertete Bücher" #: cps/templates/user_edit.html:57 msgid "Show language selection" diff --git a/cps/ub.py b/cps/ub.py index bebeedab..f5207f06 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -144,7 +144,6 @@ class UserBase: else: return False - def __repr__(self): return '' % self.nickname @@ -164,10 +163,6 @@ class User(UserBase, Base): downloads = relationship('Downloads', backref='user', lazy='dynamic') locale = Column(String(2), default="en") sidebar_view = Column(Integer, default=1) - #language_books = Column(Integer, default=1) - #series_books = Column(Integer, default=1) - #category_books = Column(Integer, default=1) - #hot_books = Column(Integer, default=1) default_language = Column(String(3), default="all") @@ -184,10 +179,6 @@ class Anonymous(AnonymousUserMixin, UserBase): self.role = data.role self.sidebar_view = data.sidebar_view self.default_language = data.default_language - #self.language_books = data.language_books - #self.series_books = data.series_books - #self.category_books = data.category_books - #self.hot_books = data.hot_books self.default_language = data.default_language self.locale = data.locale self.anon_browse = settings.config_anonbrowse diff --git a/cps/web.py b/cps/web.py index da601c10..efecfe56 100755 --- a/cps/web.py +++ b/cps/web.py @@ -25,6 +25,7 @@ import zipfile from werkzeug.security import generate_password_hash, check_password_hash from babel import Locale as LC from babel import negotiate_locale +from babel.dates import format_date from functools import wraps import base64 from sqlalchemy.sql import * @@ -279,6 +280,12 @@ def mimetype_filter(val): s = 'application/octet-stream' return s +@app.template_filter('formatdate') +def formatdate(val): + conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val) + formatdate = datetime.datetime.strptime(conformed_timestamp[:-5], "%Y%m%d %H%M%S") + return format_date(formatdate, format='medium',locale=get_locale()) + def admin_required(f): """ @@ -658,10 +665,9 @@ def get_opds_download_link(book_id, format): data = db.session.query(db.Data).filter(db.Data.book == book.id).filter(db.Data.format == format.upper()).first() if current_user.is_authenticated: helper.update_download(book_id, int(current_user.id)) - author = helper.get_normalized_author(book.author_sort) file_name = book.title - if len(author) > 0: - file_name = author + '-' + file_name + if len(book.authors) > 0: + file_name = book.authors[0].name + '-' + file_name file_name = helper.get_valid_filename(file_name) response = make_response(send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) response.headers["Content-Disposition"] = "attachment; filename=\"%s.%s\"" % (data.name, format) @@ -1228,10 +1234,9 @@ def get_download_link(book_id, format): # collect downloaded books only for registered user and not for anonymous user if current_user.is_authenticated: helper.update_download(book_id, int(current_user.id)) - author = helper.get_normalized_author(book.author_sort) file_name = book.title - if len(author) > 0: - file_name = author + '-' + file_name + if len(book.authors) > 0: + file_name = book.authors[0].name + '-' + file_name file_name = helper.get_valid_filename(file_name) response = make_response( send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + format)) @@ -1239,13 +1244,7 @@ def get_download_link(book_id, format): response.headers["Content-Type"] = mimetypes.types_map['.' + format] except: pass - response.headers["Content-Disposition"] = \ - "attachment; " \ - "filename={utf_filename}.{suffix};" \ - "filename*=UTF-8''{utf_filename}.{suffix}".format( - utf_filename=file_name.encode('utf-8'), - suffix=format - ) + response.headers["Content-Disposition"] = "attachment; filename=\"%s.%s\"" % (file_name.encode('utf-8'), format) return response else: abort(404) @@ -1599,6 +1598,7 @@ def basic_configuration(): def configuration_helper(origin): global global_task + commit='$Format:%cI$' reboot_required = False db_change = False success = False @@ -1659,16 +1659,16 @@ def configuration_helper(origin): logging.getLogger("book_formats").setLevel(config.config_log_level) except e: flash(e, category="error") - return render_title_template("config_edit.html", content=config, origin=origin, + return render_title_template("config_edit.html", content=config, origin=origin, commit=commit, title=_(u"Basic Configuration")) if db_change: reload(db) if not db.setup_db(): flash(_(u'DB location is not valid, please enter correct path'), category="error") - return render_title_template("config_edit.html", content=config, origin=origin, + return render_title_template("config_edit.html", content=config, origin=origin, commit=commit, title=_(u"Basic Configuration")) if reboot_required: - # db.engine.dispose() # ToDo verify correct + # db.engine.dispose() # ToDo verify correct ub.session.close() ub.engine.dispose() # stop tornado server @@ -1678,7 +1678,7 @@ def configuration_helper(origin): app.logger.info('Reboot required, restarting') if origin: success = True - return render_title_template("config_edit.html", origin=origin, success=success, content=config, + return render_title_template("config_edit.html", origin=origin, success=success, content=config, commit=commit, title=_(u"Basic Configuration")) @@ -1927,7 +1927,7 @@ def edit_book(book_id): modify_database_object(input_authors, book.authors, db.Authors, db.session, 'author') if author0_before_edit != book.authors[0].name: edited_books_id.add(book.id) - book.author_sort=helper.get_normalized_author(input_authors[0]) # ToDo: wrong sorting + book.author_sort=helper.get_sorted_author(input_authors[0]) if to_save["cover_url"] and os.path.splitext(to_save["cover_url"])[1].lower() == ".jpg": img = requests.get(to_save["cover_url"]) @@ -2155,9 +2155,10 @@ def upload(): if is_author: db_author = is_author else: - db_author = db.Authors(author, helper.get_normalized_author(author), "") # TODO: WRONG Sorting Author function + db_author = db.Authors(author, helper.get_sorted_author(author), "") db.session.add(db_author) - path = os.path.join(author_dir, title_dir) + # combine path and normalize path from windows systems + path = os.path.join(author_dir, title_dir).replace('\\','/') db_book = db.Books(title, "", db_author.sort, datetime.datetime.now(), datetime.datetime(101, 01, 01), 1, datetime.datetime.now(), path, has_cover, db_author, []) db_book.authors.append(db_author) diff --git a/readme.md b/readme.md index 4629a47f..102d4b2e 100755 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ Calibre Web is a web app providing a clean interface for browsing, reading and d - full graphical setup - User management - Admin interface -- User Interface in english, french, german, simplified chinese, spanish +- User Interface in english, french, german, polish, simplified chinese, spanish - OPDS feed for eBook reader apps - Filter and search by titles, authors, tags, series and language - Create custom book collection (shelves)
    {{bookcounter}}