Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
121 changes: 121 additions & 0 deletions cps/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import re
import json
import operator
import shutil
import time
import sys
import string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions cps/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand Down
13 changes: 8 additions & 5 deletions cps/editbooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
92 changes: 91 additions & 1 deletion cps/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -245,6 +258,83 @@ def feed_category(book_id):
return render_xml_dataset(db.Tags, book_id)


@opds.route("/opds/custom/<int:column_id>")
@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/<int:column_id>/letter/<book_id>")
@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/<int:column_id>/<book_id>")
@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():
Expand Down
Loading
Loading