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 @@
+ {{_('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 %} +
+{{_('No custom properties are defined for this library yet.')}}
{% endif %}| {{_('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 %} | +
{{_('No custom properties are defined yet.')}}
+ {% endif %} + +{{_('New properties become available in edit metadata, content details, and advanced search after they are created.')}}
+ ++ {{_('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 %} +
+{{_('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/