diff --git a/README.md b/README.md index e32fe7f842..e467aa833b 100755 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin - In-browser eBook reading support for multiple formats - Upload new books in various formats, including audio formats - Calibre Custom Columns support +- Create and manage custom properties from Calibre-Web using Calibre-compatible custom columns - Content hiding based on categories and Custom Column content per user - Self-update capability - "Magic Link" login for easy access on eReaders @@ -110,6 +111,7 @@ Calibre-Web is a web app that offers a clean and intuitive interface for browsin 4. **Configure Calibre Database**: In the admin interface, set the `Location of Calibre database` to the path of the folder containing your Calibre library (where `metadata.db` is located) and click "Save". 5. **Google Drive Integration**: For hosting your Calibre library on Google Drive, refer to the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration). 6. **Admin Configuration**: Configure your instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides. +7. **Custom Properties**: Use **Admin → Manage Custom Properties** to create library-specific fields such as participants, location, or production notes. Those properties appear on the edit metadata page, content details, advanced search, the browse sidebar, and OPDS navigation. ## Requirements diff --git a/cps/admin.py b/cps/admin.py index 1a1b1389cc..48a03b0a2a 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -24,6 +24,7 @@ import re import json import operator +import shutil import time import sys import string @@ -52,6 +53,7 @@ from .gdriveutils import is_gdrive_ready, gdrive_support from .render_template import render_title_template, get_sidebar_config from .services.worker import WorkerThread +from .subproc_wrapper import process_open from .usermanagement import user_login_required from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language from . import debug_info @@ -87,6 +89,7 @@ oauth_check = {} admi = Blueprint('admin', __name__) +SUPPORTED_CUSTOM_PROPERTY_TYPES = ('text', 'int', 'float', 'bool', 'datetime', 'enumeration', 'rating') def admin_required(f): @@ -103,6 +106,115 @@ def inner(*args, **kwargs): return inner +def _custom_property_type_choices(): + return [ + ('text', _("Text")), + ('int', _("Integer")), + ('float', _("Decimal")), + ('bool', _("Yes/No")), + ('datetime', _("Date")), + ('enumeration', _("Choice list")), + ('rating', _("Rating")), + ] + + +def _default_custom_property_form(): + return { + 'label': '', + 'name': '', + 'datatype': 'text', + 'is_multiple': False, + 'enum_values': '', + } + + +def _get_calibredb_binary(): + configured_path = get_calibre_binarypath("calibredb") + if configured_path and os.path.isfile(configured_path): + return configured_path + return shutil.which("calibredb") + + +def _render_custom_properties(form_data=None): + custom_columns = calibre_db.session.query(db.CustomColumns).order_by(db.CustomColumns.id).all() + for column in custom_columns: + column.supported_in_cw = column.datatype not in db.cc_exceptions + return render_title_template( + "custom_properties.html", + custom_columns=custom_columns, + property_types=_custom_property_type_choices(), + form_data=form_data or _default_custom_property_form(), + title=_("Custom Properties"), + page="customproperties" + ) + + +def _create_custom_property(): + form_data = _default_custom_property_form() + form_data.update({ + 'label': strip_whitespaces(request.form.get("label", "")), + 'name': strip_whitespaces(request.form.get("name", "")), + 'datatype': request.form.get("datatype", "text"), + 'is_multiple': bool(request.form.get("is_multiple")), + 'enum_values': request.form.get("enum_values", ""), + }) + + if not form_data["label"] or not form_data["name"]: + flash(_("Please provide both a property label and display name."), category="error") + return _render_custom_properties(form_data) + if re.match(r'^\w*$', form_data["label"]) is None or not form_data["label"][0].isalpha() \ + or form_data["label"].lower() != form_data["label"]: + flash(_("The property label must start with a letter and use only lowercase letters, numbers, and underscores."), + category="error") + return _render_custom_properties(form_data) + if form_data["datatype"] not in SUPPORTED_CUSTOM_PROPERTY_TYPES: + flash(_("The selected property type is not supported."), category="error") + return _render_custom_properties(form_data) + if form_data["datatype"] != 'text': + form_data["is_multiple"] = False + + display = {} + if form_data["datatype"] == 'enumeration': + enum_values = [strip_whitespaces(value) for value in form_data["enum_values"].split(',')] + enum_values = [value for value in helper.uniq(enum_values) if value] + if not enum_values: + flash(_("Choice list properties need at least one option."), category="error") + return _render_custom_properties(form_data) + display["enum_values"] = enum_values + + calibredb_binary = _get_calibredb_binary() + if not calibredb_binary: + flash(_("Calibre's calibredb binary could not be found. Configure the Calibre binaries path or install calibre."), + category="error") + return _render_custom_properties(form_data) + + my_env = os.environ.copy() + library_path = config.get_book_path() or config.config_calibre_dir + if not library_path: + flash(_("Calibre library path is not configured."), category="error") + return _render_custom_properties(form_data) + if config.config_calibre_split: + my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") + command = [calibredb_binary, 'add_custom_column', '--display', json.dumps(display), '--with-library', library_path] + if form_data["is_multiple"]: + command.append('--is-multiple') + command.extend([form_data["label"], form_data["name"], form_data["datatype"]]) + + quotes = [0, 3, 5, len(command) - 2] + process = process_open(command, quotes=quotes, env=my_env) + output, error = process.communicate() + if process.returncode != 0: + flash((error or output or _("Failed to create the custom property.")).strip(), category="error") + return _render_custom_properties(form_data) + + old_session = g.pop("lib_sql", None) + if old_session: + old_session.remove() + calibre_db.reconnect_db(config, ub.app_DB_path) + flash(_("Custom property %(name)s created successfully.", name=form_data["name"]), category="success") + return redirect(url_for('admin.custom_properties')) + + @admi.before_app_request def before_request(): #try: @@ -242,6 +354,15 @@ def db_configuration(): return _db_configuration_result() +@admi.route("/admin/customproperties", methods=["GET", "POST"]) +@user_login_required +@admin_required +def custom_properties(): + if request.method == "POST": + return _create_custom_property() + return _render_custom_properties() + + @admi.route("/admin/config", methods=["GET"]) @user_login_required @admin_required diff --git a/cps/db.py b/cps/db.py index 989731e53d..e795fec265 100644 --- a/cps/db.py +++ b/cps/db.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import relationship, sessionmaker, scoped_session, selectinload from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.ext.declarative import DeclarativeMeta -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import OperationalError, InvalidRequestError try: # Compatibility with sqlalchemy 2.0 from sqlalchemy.orm import declarative_base @@ -601,7 +601,7 @@ def setup_db_cc_classes(cls, cc): 'id': Column(Integer, primary_key=True)} if row.datatype == 'float': ccdict['value'] = Column(Float) - elif row.datatype == 'int': + elif row.datatype in ('int', 'rating'): ccdict['value'] = Column(Integer) elif row.datatype == 'datetime': ccdict['value'] = Column(TIMESTAMP) @@ -712,13 +712,14 @@ def setup_db(cls, config_calibre_dir, app_db_path): cls.config.db_configured = True - if not cc_classes: - try: - cc = conn.execute(text("SELECT id, datatype FROM custom_columns")) - cls.setup_db_cc_classes(cc) - except OperationalError as e: - log.error_or_exception(e) - return None + try: + cc = list(conn.execute(text("SELECT id, datatype FROM custom_columns"))) + missing_cc = cc if not cc_classes else [row for row in cc if row[0] not in cc_classes] + if missing_cc: + cls.setup_db_cc_classes(missing_cc) + except OperationalError as e: + log.error_or_exception(e) + return None return scoped_session(sessionmaker(autocommit=False, autoflush=False, @@ -1056,7 +1057,11 @@ def search_query(self, term, config, *join): return base_query.filter(or_(*filter_expression)) def get_cc_columns(self, config, filter_config_custom_read=False): - tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all() + try: + tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all() + except (OperationalError, InvalidRequestError) as ex: + log.warning("Unable to load custom columns: %s", ex) + return [] cc = [] r = None if config.config_columns_to_ignore: @@ -1071,6 +1076,10 @@ def get_cc_columns(self, config, filter_config_custom_read=False): return cc + def get_browseable_cc_columns(self, config): + return [col for col in self.get_cc_columns(config, filter_config_custom_read=True) + if col.normalized and col.datatype in ('text', 'enumeration', 'rating')] + # read search results from calibre-database and return it (function is used for feed and simple search def get_search_results(self, term, config, offset=None, order=None, limit=None, *join): order = order[0] if order else [Books.sort] diff --git a/cps/editbooks.py b/cps/editbooks.py index 5e33d4ba93..54c6f8f2ca 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -1493,8 +1493,11 @@ def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string): def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string): changed = False if c.datatype == 'rating': - to_save[cc_string] = str(int(float(to_save[cc_string]) * 2)) - if strip_whitespaces(to_save[cc_string]) != cc_db_value: + to_save[cc_string] = int(float(to_save[cc_string]) * 2) + compare_value = to_save[cc_string] + else: + compare_value = strip_whitespaces(to_save[cc_string]) + if compare_value != cc_db_value: if cc_db_value is not None: # remove old cc_val del_cc = getattr(book, cc_string)[0] @@ -1504,15 +1507,15 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string): changed = True cc_class = db.cc_classes[c.id] new_cc = calibre_db.session.query(cc_class).filter( - cc_class.value == strip_whitespaces(to_save[cc_string])).first() + cc_class.value == compare_value).first() # if no cc val is found add it if new_cc is None: - new_cc = cc_class(value=strip_whitespaces(to_save[cc_string])) + new_cc = cc_class(value=compare_value) calibre_db.session.add(new_cc) changed = True calibre_db.session.flush() new_cc = calibre_db.session.query(cc_class).filter( - cc_class.value == strip_whitespaces(to_save[cc_string])).first() + cc_class.value == compare_value).first() # add cc value to book getattr(book, cc_string).append(new_cc) return changed, to_save diff --git a/cps/opds.py b/cps/opds.py index 329a6a0e5b..beb69c6aad 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -43,11 +43,24 @@ log = logger.create() +def get_browseable_custom_column(column_id): + for column in calibre_db.get_browseable_cc_columns(config): + if column.id == column_id: + return column + return None + + +def format_custom_column_value(column, value): + if column.datatype == 'rating': + return '%.1f' % (value / 2) + return str(value) + + @opds.route("/opds/") @opds.route("/opds") @requires_basic_auth_if_no_ano def feed_index(): - return render_xml_template('index.xml') + return render_xml_template('index.xml', custom_columns=calibre_db.get_browseable_cc_columns(config)) @opds.route("/opds/osd") @@ -245,6 +258,83 @@ def feed_category(book_id): return render_xml_dataset(db.Tags, book_id) +@opds.route("/opds/custom/") +@requires_basic_auth_if_no_ano +def feed_custom_property_index(column_id): + column = get_browseable_custom_column(column_id) + if not column or not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY): + abort(404) + off = int(request.args.get("offset") or 0) + cc_class = db.cc_classes[column.id] + entries = (calibre_db.session.query(func.upper(func.substr(cc_class.value, 1, 1)).label('id')) + .join(cc_class.books) + .filter(calibre_db.common_filters()) + .group_by(func.upper(func.substr(cc_class.value, 1, 1))) + .all()) + elements = [] + shift = 0 + if off == 0 and entries: + elements.append({'id': "00", 'name': _("All")}) + shift = 1 + for entry in entries[off + shift - 1:int(off + int(config.config_books_per_page) - shift)]: + elements.append({'id': entry.id, 'name': entry.id}) + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + len(entries) + 1) + cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) + return render_xml_template('feed.xml', letterelements=elements, folder='opds.feed_custom_property_letter', + pagination=pagination, cc=cc, custom_column=column) + + +@opds.route("/opds/custom//letter/") +@requires_basic_auth_if_no_ano +def feed_custom_property_letter(column_id, book_id): + column = get_browseable_custom_column(column_id) + if not column or not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY): + abort(404) + off = int(request.args.get("offset") or 0) + cc_class = db.cc_classes[column.id] + query = (calibre_db.session.query(cc_class) + .join(cc_class.books) + .filter(calibre_db.common_filters())) + if book_id != "00": + query = query.filter(func.upper(func.substr(cc_class.value, 1, 1)) == book_id) + entries = query.group_by(cc_class.id).order_by(cc_class.value) + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + entries.count()) + items = [db.Category(format_custom_column_value(column, entry.value), entry.id) + for entry in entries.offset(off).limit(config.config_books_per_page).all()] + none_count = (calibre_db.session.query(db.Books) + .filter(~getattr(db.Books, 'custom_column_' + str(column.id)).any()) + .filter(calibre_db.common_filters()) + .count()) + if none_count and book_id == "00": + items.append(db.Category(_("None"), "none")) + cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) + return render_xml_template('feed.xml', listelements=items, folder='opds.feed_custom_property', + pagination=pagination, cc=cc, custom_column=column) + + +@opds.route("/opds/custom//") +@requires_basic_auth_if_no_ano +def feed_custom_property(column_id, book_id): + column = get_browseable_custom_column(column_id) + if not column or not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY): + abort(404) + off = request.args.get("offset") or 0 + relation = getattr(db.Books, 'custom_column_' + str(column.id)) + if book_id == 'none': + db_filter = ~relation.any() + else: + db_filter = relation.any(db.cc_classes[column.id].id == book_id) + entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0, + db.Books, + db_filter, + [db.Books.timestamp.desc()], + True, config.config_read_column) + cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True) + return render_xml_template('feed.xml', entries=entries, pagination=pagination, cc=cc, custom_column=column) + + @opds.route("/opds/series") @requires_basic_auth_if_no_ano def feed_seriesindex(): diff --git a/cps/render_template.py b/cps/render_template.py index 03230ecd81..75040de4bc 100644 --- a/cps/render_template.py +++ b/cps/render_template.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from flask import render_template, g, abort, request +from flask import render_template, g, abort, request, url_for from flask_babel import gettext as _ from werkzeug.local import LocalProxy from .cw_login import current_user @@ -29,6 +29,7 @@ log = logger.create() def get_sidebar_config(kwargs=None): + from . import calibre_db kwargs = kwargs or [] simple = bool([e for e in ['kindle', 'tolino', "kobo", "bookeen"] if (e in request.headers.get('User-Agent', "").lower())]) @@ -91,6 +92,19 @@ def get_sidebar_config(kwargs=None): sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format", "visibility": constants.SIDEBAR_FORMAT, 'public': True, "no_param":True, "page": "format", "show_text": _('Show File Formats Section'), "config_show": True}) + for col in calibre_db.get_browseable_cc_columns(config): + sidebar.append({ + "glyph": "glyphicon-tag", + "text": col.name, + "link": 'web.custom_property_list', + "id": 'custom_column_' + str(col.id), + "visibility": constants.SIDEBAR_CATEGORY, + 'public': True, + "page": 'custom_column_' + str(col.id), + "show_text": _('Show %(column)s Section', column=col.name), + "config_show": False, + "url": url_for('web.custom_property_list', column_id=col.id), + }) sidebar.append( {"glyph": "glyphicon-folder-open", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived", "visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived", diff --git a/cps/static/js/edit_books.js b/cps/static/js/edit_books.js index 54f615fb0a..df23acf7bb 100644 --- a/cps/static/js/edit_books.js +++ b/cps/static/js/edit_books.js @@ -23,6 +23,12 @@ if ($(".tiny_editor").length) { }); } +$("#book_edit_frm").on("submit", function () { + if (typeof tinymce !== "undefined") { + tinymce.triggerSave(); + } +}); + $(".datepicker").datepicker({ format: "yyyy-mm-dd", language: language @@ -265,4 +271,3 @@ $("#xchange").click(function () { $("#title").val($("#authors").val()); $("#authors").val(title); }); - diff --git a/cps/templates/admin.html b/cps/templates/admin.html index ac124fe84c..1c7baff738 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -159,6 +159,7 @@

{{_('Configuration')}}

{{_('Edit Calibre Database Configuration')}} {{_('Edit Basic Configuration')}} {{_('Edit UI Configuration')}} + {{_('Manage Custom Properties')}} {% if feature_support['scheduler'] %} diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html index 615b40c2e8..d44347fa69 100644 --- a/cps/templates/book_edit.html +++ b/cps/templates/book_edit.html @@ -139,6 +139,16 @@ {% endif %} +
+
+

{{_('Custom Properties')}}

+

+ {{_('Custom properties are library-specific metadata fields backed by Calibre custom columns. Use them for metadata such as participants, location, or production notes.')}} + {% if current_user.role_admin() %} + {{_('Manage property definitions')}}. + {% endif %} +

+
{% if cc|length > 0 %} {% for c in cc %}
@@ -208,6 +218,8 @@ {% endif %}
{% endfor %} + {% else %} +

{{_('No custom properties are defined for this library yet.')}}

{% endif %}
diff --git a/cps/templates/config_db.html b/cps/templates/config_db.html index c74bfee13c..ae34d9354b 100644 --- a/cps/templates/config_db.html +++ b/cps/templates/config_db.html @@ -22,11 +22,16 @@

{{title}}

- - - - -
+ + + + +
+ + {% if feature_support['gdrive'] %}
diff --git a/cps/templates/custom_properties.html b/cps/templates/custom_properties.html new file mode 100644 index 0000000000..100739d67e --- /dev/null +++ b/cps/templates/custom_properties.html @@ -0,0 +1,79 @@ +{% extends "layout.html" %} +{% block body %} +
+

{{title}}

+
+ + +

{{_('Existing Properties')}}

+ {% if custom_columns %} +
+ + + + + + + + + + + + {% for column in custom_columns %} + + + + + + + + {% endfor %} + +
{{_('Name')}}{{_('Label')}}{{_('Type')}}{{_('Multiple')}}{{_('Visible in Calibre-Web')}}
{{ column.name }}#{{ column.label }}{{ column.datatype }}{% if column.is_multiple %}{{_('Yes')}}{% else %}{{_('No')}}{% endif %}{% if column.supported_in_cw %}{{_('Yes')}}{% else %}{{_('No')}}{% endif %}
+
+ {% else %} +

{{_('No custom properties are defined yet.')}}

+ {% endif %} + +
+

{{_('Create Property')}}

+

{{_('New properties become available in edit metadata, content details, and advanced search after they are created.')}}

+
+ +
+ + +
+
+ + +

{{_('Used internally by Calibre. Start with a lowercase letter and use only lowercase letters, numbers, and underscores.')}}

+
+
+ + +
+
+ +
+
+ + +

{{_('Required only for choice list properties. Enter comma-separated values.')}}

+
+ + {{_('Back')}} +
+
+
+{% endblock %} diff --git a/cps/templates/detail.html b/cps/templates/detail.html index dce148229a..b442ef7bce 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -264,43 +264,60 @@

{{ entry.title }}

{% endif %} {% if cc|length > 0 %} - - + {% set custom = namespace(has_values=false) %} {% for c in cc %} {% if entry['custom_column_' ~ c.id]|length > 0 %} -
- {{ c.name }}: - {% for column in entry['custom_column_' ~ c.id] %} - {% if c.datatype == 'rating' %} - {{ (column.value / 2)|formatfloat }} - {% else %} - {% if c.datatype == 'bool' %} - {% if column.value == true %} - - {% else %} - - {% endif %} - {% else %} - {% if c.datatype == 'float' %} - {{ column.value|formatfloat(2) }} - {% elif c.datatype == 'datetime' %} - {{ column.value|formatdate }} - {% elif c.datatype == 'comments' %} - {{ column.value|safe }} - {% elif c.datatype == 'series' %} - {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} - {% elif c.datatype == 'text' %} - {{ column.value.strip() }}{% if not loop.last %}, {% endif %} - {% else %} - {{ column.value }} - {% endif %} - {% endif %} - {% endif %} - {% endfor %} - -
+ {% set custom.has_values = true %} {% endif %} {% endfor %} + {% if custom.has_values %} +
+

{{_('Custom Properties')}}

+ {% for c in cc %} + {% if entry['custom_column_' ~ c.id]|length > 0 %} +
+ {{ c.name }}: + {% for column in entry['custom_column_' ~ c.id] %} + {% if c.datatype == 'rating' %} + + {% for number in range((column.value / 2)|int(2)) %} + + {% if loop.last and loop.index < 5 %} + {% for numer in range(5 - loop.index) %} + + {% endfor %} + {% endif %} + {% endfor %} + + {% else %} + {% if c.datatype == 'bool' %} + {% if column.value == true %} + + {% else %} + + {% endif %} + {% else %} + {% if c.datatype == 'float' %} + {{ column.value|formatfloat(2) }} + {% elif c.datatype == 'datetime' %} + {{ column.value|formatdate }} + {% elif c.datatype == 'comments' %} + {{ column.value|safe }} + {% elif c.datatype == 'series' %} + {{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }} + {% elif c.datatype == 'text' %} + {{ column.value.strip() }}{% if not loop.last %}, {% endif %} + {% else %} + {{ column.value }} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} {% endif %} {% if not current_user.is_anonymous %} diff --git a/cps/templates/feed.xml b/cps/templates/feed.xml index 6627daac40..cd21e22e8e 100644 --- a/cps/templates/feed.xml +++ b/cps/templates/feed.xml @@ -132,15 +132,15 @@ {% else %} {{entry.name}} {% endif %} - {{ url_for(folder, book_id=entry.id) }} - + {% if custom_column is defined %}{{ url_for(folder, column_id=custom_column.id, book_id=entry.id) }}{% else %}{{ url_for(folder, book_id=entry.id) }}{% endif %} + {% endfor %} {% for entry in letterelements %} {{entry['name']}} - {{ url_for(folder, book_id=entry['id']) }} - + {% if custom_column is defined %}{{ url_for(folder, column_id=custom_column.id, book_id=entry['id']) }}{% else %}{{ url_for(folder, book_id=entry['id']) }}{% endif %} + {% endfor %} diff --git a/cps/templates/index.xml b/cps/templates/index.xml index 4c088e371c..afbcf4d613 100644 --- a/cps/templates/index.xml +++ b/cps/templates/index.xml @@ -100,6 +100,15 @@ {{ current_time }} {{_('Books ordered by category')}} + {% for column in custom_columns %} + + {{column.name}} + + {{url_for('opds.feed_custom_property_index', column_id=column.id)}} + {{ current_time }} + {{_('Books ordered by %(column)s', column=column.name)}} + + {% endfor %} {% endif %} {% if current_user.check_visibility(g.constants.SIDEBAR_SERIES) %} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 0fe31066b7..552b3624ec 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -153,7 +153,7 @@

{{_('Uploading...')}}

{% for element in sidebar %} {% if current_user.check_visibility(element['visibility']) and element['public'] %} - + {% endif %} {% endfor %} {% if current_user.is_authenticated or g.allow_anonymous %} diff --git a/cps/templates/list.html b/cps/templates/list.html index 5a1a4c0ca2..3e0866b6ff 100644 --- a/cps/templates/list.html +++ b/cps/templates/list.html @@ -31,14 +31,14 @@

{{_(title)}}

{% endif %}
{{entry[1]}}
-
- {% if entry.name %} -
- {% for number in range(entry.name|int) %} - - {% if loop.last and loop.index < 5 %} - {% for numer in range(5 - loop.index) %} - +
+ {% if data == 'rating' or (custom_column is defined and custom_column.datatype == 'rating') %} +
+ {% for number in range(entry[0].name|int) %} + + {% if loop.last and loop.index < 5 %} + {% for numer in range(5 - loop.index) %} + {% endfor %} {% endif %} {% endfor %} diff --git a/cps/templates/listenmp3.html b/cps/templates/listenmp3.html index 375a871b43..e0a9487b02 100644 --- a/cps/templates/listenmp3.html +++ b/cps/templates/listenmp3.html @@ -120,7 +120,16 @@

{{entry.title}}

{{ c.name }}: {% for column in entry['custom_column_' ~ c.id] %} {% if c.datatype == 'rating' %} - {{ (column.value / 2)|formatfloat }} + + {% for number in range((column.value / 2)|int(2)) %} + + {% if loop.last and loop.index < 5 %} + {% for numer in range(5 - loop.index) %} + + {% endfor %} + {% endif %} + {% endfor %} + {% else %} {% if c.datatype == 'bool' %} {% if column.value == true %} diff --git a/cps/templates/search_form.html b/cps/templates/search_form.html index cea1d43689..13315c3ca2 100644 --- a/cps/templates/search_form.html +++ b/cps/templates/search_form.html @@ -155,6 +155,16 @@

{{title}}

+
+
{% if cc|length > 0 %} {% for c in cc %}
@@ -242,6 +252,8 @@

{{title}}

{% endif %}
{% endfor %} + {% else %} +

{{_('No custom properties are defined for this library yet.')}}

{% endif %} diff --git a/cps/web.py b/cps/web.py index d586a84ae4..068a7159a9 100644 --- a/cps/web.py +++ b/cps/web.py @@ -372,6 +372,42 @@ def generate_char_list(entries): # data_colum, db_link): return char_list +def get_browseable_custom_column(column_id): + for column in calibre_db.get_browseable_cc_columns(config): + if column.id == column_id: + return column + return None + + +def custom_column_page(column_id): + return 'custom_column_' + str(column_id) + + +def format_custom_column_value(column, value): + if column.datatype == 'rating': + return '%.1f' % (value / 2) + return str(value) + + +def get_custom_column_entries(column, order): + cc_class = db.cc_classes[column.id] + entries = (calibre_db.session.query(cc_class, func.count(db.Books.id).label('count')) + .join(cc_class.books) + .filter(calibre_db.common_filters()) + .group_by(cc_class.id) + .order_by(order) + .all()) + formatted_entries = [[db.Category(format_custom_column_value(column, entry[0].value), entry[0].id), entry[1]] + for entry in entries] + no_value_count = (calibre_db.session.query(db.Books) + .filter(~getattr(db.Books, custom_column_page(column.id)).any()) + .filter(calibre_db.common_filters()) + .count()) + if no_value_count: + formatted_entries.append([db.Category(_("None"), "none"), no_value_count]) + return formatted_entries + + def query_char_list(data_colum, db_link): results = (calibre_db.session.query(func.upper(func.substr(data_colum, 1, 1)).label('char')) .join(db_link).join(db.Books).filter(calibre_db.common_filters()) @@ -743,6 +779,44 @@ def render_category_books(page, book_id, order): title=_("Category: %(name)s", name=tagsname), page="category", order=order[1]) +@web.route("/customproperties//", defaults={'page': 1}) +@web.route("/customproperties///page/") +@login_required_if_no_ano +def custom_property_books(column_id, book_id, page): + column = get_browseable_custom_column(column_id) + if not column or not current_user.check_visibility(constants.SIDEBAR_CATEGORY): + abort(404) + page_key = custom_column_page(column_id) + order = get_sort_function('stored', page_key) + relation = getattr(db.Books, page_key) + if book_id == 'none': + entries, random, pagination = calibre_db.fill_indexpage(page, 0, + db.Books, + ~relation.any(), + [order[0][0], db.Series.name, db.Books.series_index], + True, config.config_read_column, + db.books_series_link, + db.Books.id == db.books_series_link.c.book, + db.Series) + value_name = _("None") + else: + value = calibre_db.session.query(db.cc_classes[column_id]).filter(db.cc_classes[column_id].id == book_id).first() + if not value: + abort(404) + entries, random, pagination = calibre_db.fill_indexpage(page, 0, + db.Books, + relation.any(db.cc_classes[column_id].id == book_id), + [order[0][0], db.Series.name, db.Books.series_index], + True, config.config_read_column, + db.books_series_link, + db.Books.id == db.books_series_link.c.book, + db.Series) + value_name = format_custom_column_value(column, value.value) + return render_title_template('index.html', random=random, entries=entries, pagination=pagination, id=book_id, + title=_("%(column)s: %(name)s", column=column.name, name=value_name), + page=page_key, order=order[1]) + + def render_language_books(page, name, order): try: if name.lower() != "none": @@ -1046,6 +1120,27 @@ def publisher_list(): abort(404) +@web.route("/customproperties/") +@login_required_if_no_ano +def custom_property_list(column_id): + column = get_browseable_custom_column(column_id) + if not column or not current_user.check_visibility(constants.SIDEBAR_CATEGORY): + abort(404) + page_key = custom_column_page(column_id) + if current_user.get_view_property(page_key, 'dir') == 'desc': + order = db.cc_classes[column_id].value.desc() + order_no = 0 + else: + order = db.cc_classes[column_id].value.asc() + order_no = 1 + entries = get_custom_column_entries(column, order) + entries = sorted(entries, key=lambda x: x[0].name.lower(), reverse=not order_no) + char_list = generate_char_list(entries) + return render_title_template('list.html', entries=entries, folder='web.custom_property_books', charlist=char_list, + title=column.name, page=page_key, data=page_key, order=order_no, + custom_column=column) + + @web.route("/series") @login_required_if_no_ano def series_list(): diff --git a/library/metadata.db b/library/metadata.db index fc0d5d3156..535e4fda47 100644 Binary files a/library/metadata.db and b/library/metadata.db differ diff --git a/tests/functional/test_custom_columns.py b/tests/functional/test_custom_columns.py new file mode 100644 index 0000000000..ce1a1c7ac6 --- /dev/null +++ b/tests/functional/test_custom_columns.py @@ -0,0 +1,24 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from flask import Flask +from sqlalchemy.exc import OperationalError + +from cps.db import CalibreDB + + +def test_get_cc_columns_returns_empty_list_when_custom_columns_table_is_missing(): + calibre_db = CalibreDB() + mock_session = Mock() + mock_session.query.return_value.filter.return_value.all.side_effect = OperationalError( + "SELECT * FROM custom_columns", + {}, + Exception("no such table: custom_columns"), + ) + calibre_db.connect = Mock(return_value=mock_session) + + config = SimpleNamespace(config_columns_to_ignore=None, config_read_column=None) + + with Flask(__name__).app_context(): + assert calibre_db.get_cc_columns(config) == [] + assert calibre_db.get_browseable_cc_columns(config) == []