From f3e8ce5fa830eafb89bfd560254f9fcf001a6a32 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:35:36 +0000 Subject: [PATCH 1/5] add custom properties UI --- README.md | 2 + cps/admin.py | 122 +++++++++++++++++++++++++++ cps/db.py | 15 ++-- cps/templates/admin.html | 1 + cps/templates/book_edit.html | 12 +++ cps/templates/config_db.html | 15 ++-- cps/templates/custom_properties.html | 79 +++++++++++++++++ cps/templates/detail.html | 74 ++++++++-------- cps/templates/search_form.html | 12 +++ 9 files changed, 287 insertions(+), 45 deletions(-) create mode 100644 cps/templates/custom_properties.html diff --git a/README.md b/README.md index e32fe7f842..d2e93fdbb5 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, and advanced search. ## Requirements diff --git a/cps/admin.py b/cps/admin.py index 1a1b1389cc..e4b44f63bb 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', 'comments', 'enumeration', 'rating') def admin_required(f): @@ -103,6 +106,116 @@ def inner(*args, **kwargs): return inner +def _custom_property_type_choices(): + return [ + ('text', _("Text")), + ('int', _("Integer")), + ('float', _("Decimal")), + ('bool', _("Yes/No")), + ('datetime', _("Date")), + ('comments', _("Long text")), + ('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 +355,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..ed000ddc91 100644 --- a/cps/db.py +++ b/cps/db.py @@ -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, 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..687150f995 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -264,43 +264,51 @@

{{ 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' %} + {{ (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 %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} {% endif %} {% if not current_user.is_anonymous %} 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}}

+
+
+

{{_('Custom Properties')}}

+

+ {{_('Search library-specific metadata such as participants, location, or other custom properties defined for this library.')}} + {% if current_user.role_admin() %} + {{_('Manage property definitions')}}. + {% endif %} +

+
{% 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 %} From 6e798395d195e58e0b9eca8dc23bc490c8a46d51 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:39:22 +0000 Subject: [PATCH 2/5] add custom property browsing to sidebar and OPDS navigation --- README.md | 2 +- cps/db.py | 4 ++ cps/opds.py | 92 ++++++++++++++++++++++++++++++++++++- cps/render_template.py | 16 ++++++- cps/templates/feed.xml | 8 ++-- cps/templates/index.xml | 9 ++++ cps/templates/layout.html | 2 +- cps/templates/list.html | 2 +- cps/web.py | 95 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d2e93fdbb5..e467aa833b 100755 --- a/README.md +++ b/README.md @@ -111,7 +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, and advanced search. +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/db.py b/cps/db.py index ed000ddc91..c7fbd6c027 100644 --- a/cps/db.py +++ b/cps/db.py @@ -1072,6 +1072,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/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/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..bf50cb4382 100644 --- a/cps/templates/list.html +++ b/cps/templates/list.html @@ -31,7 +31,7 @@

{{_(title)}}

{% endif %}
{{entry[1]}}
-
+
{% if entry.name %}
{% for number in range(entry.name|int) %} 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(): From ec3e8b8d5d3cbbfc8990d3301a063d69d4eaff49 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:51:12 +0000 Subject: [PATCH 3/5] fix rating save --- cps/db.py | 2 +- cps/editbooks.py | 13 ++++++++----- cps/static/js/edit_books.js | 7 ++++++- cps/templates/detail.html | 11 ++++++++++- cps/templates/list.html | 14 +++++++------- cps/templates/listenmp3.html | 11 ++++++++++- library/metadata.db | Bin 413696 -> 425984 bytes 7 files changed, 42 insertions(+), 16 deletions(-) diff --git a/cps/db.py b/cps/db.py index c7fbd6c027..5bb06a47d1 100644 --- a/cps/db.py +++ b/cps/db.py @@ -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) 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/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/detail.html b/cps/templates/detail.html index 687150f995..b442ef7bce 100644 --- a/cps/templates/detail.html +++ b/cps/templates/detail.html @@ -279,7 +279,16 @@

{{_('Custom Properties')}}

{{ 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/list.html b/cps/templates/list.html index bf50cb4382..3e0866b6ff 100644 --- a/cps/templates/list.html +++ b/cps/templates/list.html @@ -32,13 +32,13 @@

{{_(title)}}

{{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/library/metadata.db b/library/metadata.db index fc0d5d3156ccaf85108d9bb797433e688e1f2136..535e4fda4793ee38333355619870a76e212227b9 100644 GIT binary patch delta 1662 zcmZuxdu$X%7@wJ&ea&@uueQhbdTnnp2zSs@u&Hti=8nB>DXmwy9<(GR?a?0X(b8gD z6;Wun1xgSS%n~Lss5Jy*c$k_CY@(7zB^pcsgC_EaCPotwLrmchd=SySK0HdDWU}-7 zzTfwpZ+39hGdNgy#9LMZ0O+vy%-U@q@C>lO(B9q*ZcTKh-UutU!P3KjGPZ!omoxHq z`M!Klz9Zk3Z^}Q(KgieRZ{*7eIX1&_IG)aRRlxK(lq9E3O&A%wmOaDQWqMtP0g9ja5 z9>~#hB+x?cpiSf=+%8<<4gMMSIGbeNVtn`z<{TqtuB(&`ktLks^}^v!+IR*X%SN1^ ziOABtv&c`3R5_=(-Eg>)`c9!;SyIUq>624vAsJdy#wi{T+`F85PNR>qBsEJC_5?X4 z7=(sG*Nq}KeQ*M?)}<4uj>S$DT&A8==pHROgUU1I{bsE{gH$N24sgo+`EYMN-E|gS z$@*O|*%R}myudp40on$wv3HRK_m83TV-C2^!{!44Yy;>Vvm0N-m5$@md2t!J0k)kh z#D6h%0DNtvfW=k;%iO-@;84WkCYZnFA`UbGX*YMD?Zej`8{yZWNpv|+lJ7|#A$H4F zoNHo30*`Nc>nr(v9DQMeDWD}hE7LCpRufsn$=bOCVCV*+KN8k$l@fM6wmzL;hT(fI zHUz~q9Cf(n2`~tx!5QD&RrY7L69(qtCOr z&>Tbp9dl!z_OSR+7OfR9p(2L|vZA9Ler*!D_=oMD?1N|zW1;zGj*~tRaBh~T5L|n#2ok!J;C%bwqVJPw{Z)d+$pT!(m>Jm8)6?1a zq9Jn1(`C@eOLey;cTUQhcO=@Aea&5|?#|{NskS-74lSmKgkCb|PgN4b&1d21DZ=#~E7RC{}}caEdh-ejUb**vYL-g2gJR;%Hv zxE`}}YS3fx|H7+P^)XcsYirb4eIrejGrj@UUem0aXuN(-n*K~40d-@zZiC+7kLry} zQ*D7~Nycnu>CwpEkm2N%vNCv}qCc^vEBR>hkK(P_%SzSdfk5f3!ZQu9JF3@IN7Y(= zlRDk2m|msF^r)scOcmz0JC?bEh+e1LbE1VCv~Wb9}j9z643OplLD~^CtsE=%oiMw1+`O!-pi;gZdzmMEw#T_&Dd|`_4IEHXF)L2Ol?g zv=Bl@9URn{GtdL#bZ`*N=fhns=o`A5aMKy*A^gt3AiMk2lJx(aY-knDQcCr_CfLfzrJ{j)^JKm4)hR@*=ISeI& z87Rp&#;WneH|C3ZhrCBTQf!ETyY71CO0h+j)8FcGTBIFX0hb_1&N_dsP@J2CgJ2PY zx74&oKT_wMQkG7q?45ZuKsc^VGZu4AjHree7*JQdl&ddDSKN1_+>A!SGXD#;8D#eC z44!Ay8tCR~?-lNf32zrSgF(oRt8343QGK7ou&T|XOHCIsqPC0JO|2V8xgD9sR&cj> zalk|KPMjng&l5_}>lBkS{f3gW9cl+T~8 eq*%g@B%>^Y-KN$VOQ@P)0sB~*eb!(4S>his@|$D; From 9430b110a3397dba4e0a679658df22e424800ea7 Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:58:35 +0000 Subject: [PATCH 4/5] remove long text option --- cps/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cps/admin.py b/cps/admin.py index e4b44f63bb..48a03b0a2a 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -89,7 +89,7 @@ oauth_check = {} admi = Blueprint('admin', __name__) -SUPPORTED_CUSTOM_PROPERTY_TYPES = ('text', 'int', 'float', 'bool', 'datetime', 'comments', 'enumeration', 'rating') +SUPPORTED_CUSTOM_PROPERTY_TYPES = ('text', 'int', 'float', 'bool', 'datetime', 'enumeration', 'rating') def admin_required(f): @@ -113,7 +113,6 @@ def _custom_property_type_choices(): ('float', _("Decimal")), ('bool', _("Yes/No")), ('datetime', _("Date")), - ('comments', _("Long text")), ('enumeration', _("Choice list")), ('rating', _("Rating")), ] From dec75f3b48dde25edcf17e8d57b5145b01e6855d Mon Sep 17 00:00:00 2001 From: Jacob Chapman <7908073+chapmanjacobd@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:05:44 +0000 Subject: [PATCH 5/5] fix edit metadata crash --- cps/db.py | 8 ++++++-- tests/functional/test_custom_columns.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/functional/test_custom_columns.py diff --git a/cps/db.py b/cps/db.py index 5bb06a47d1..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 @@ -1057,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: 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) == []