From d7cc86be2e0618544c6d4e888f2d0956146917e5 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Fri, 7 Feb 2025 22:12:38 +0000
Subject: [PATCH 1/3] Add a template button to Pref / L&F / book details. Add
necessary code for the tester to evaluate the template.
---
.../preferences/look_feel_tabs/__init__.py | 34 +++++++++++++++++--
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/src/calibre/gui2/preferences/look_feel_tabs/__init__.py b/src/calibre/gui2/preferences/look_feel_tabs/__init__.py
index ac16fefc9e..a3693c3bad 100644
--- a/src/calibre/gui2/preferences/look_feel_tabs/__init__.py
+++ b/src/calibre/gui2/preferences/look_feel_tabs/__init__.py
@@ -7,14 +7,17 @@ __docformat__ = 'restructuredtext en'
import json
-from qt.core import QAbstractListModel, QComboBox, QFormLayout, QIcon, QItemSelectionModel, QLineEdit, Qt, QVBoxLayout, QWidget, pyqtSignal
+from qt.core import QAbstractListModel, QComboBox, QDialog, QFormLayout, QHBoxLayout, QIcon, QItemSelectionModel, QLineEdit, Qt, QToolButton, QVBoxLayout, QWidget, pyqtSignal
from calibre.ebooks.metadata.book.render import DEFAULT_AUTHOR_LINK
+from calibre.ebooks.metadata.search_internet import qquote
from calibre.gui2 import choose_files, choose_save_file, error_dialog
from calibre.gui2.book_details import get_field_list
+from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.preferences import LazyConfigWidgetBase
from calibre.gui2.preferences.coloring import EditRules
from calibre.gui2.ui import get_gui
+from calibre.utils.formatter import EvalFormatter
class DefaultAuthorLink(QWidget):
@@ -44,6 +47,7 @@ class DefaultAuthorLink(QWidget):
]:
c.addItem(text, data)
l.addRow(_('Clicking on &author names should:'), c)
+ ul = QHBoxLayout()
self.custom_url = u = QLineEdit(self)
u.setToolTip(_(
'Enter the URL to search. It should contain the string {0}'
@@ -51,8 +55,14 @@ class DefaultAuthorLink(QWidget):
'\n{1}').format('{author}', 'https://en.wikipedia.org/w/index.php?search={author}'))
u.textChanged.connect(self.changed_signal)
u.setPlaceholderText(_('Enter the URL'))
+ ul.addWidget(u)
+ u = self.custom_url_button = QToolButton()
+ u.setIcon(QIcon.ic('edit_input.png'))
+ u.setToolTip(_('Click this button to open the template tester'))
+ u.clicked.connect(self.open_template_tester)
+ ul.addWidget(u)
c.currentIndexChanged.connect(self.current_changed)
- l.addRow(u)
+ l.addRow(ul)
self.current_changed()
c.currentIndexChanged.connect(self.changed_signal)
@@ -71,9 +81,29 @@ class DefaultAuthorLink(QWidget):
self.custom_url.setText(val)
self.choices.setCurrentIndex(i)
+ def open_template_tester(self):
+ gui = get_gui()
+ db = gui.current_db.new_api
+ lv = gui.library_view
+ rows = lv.selectionModel().selectedRows()
+ if not rows:
+ vals = [{'author': qquote(_('Author')), 'title': _('Title'), 'author_sort': _('Author sort')}]
+ else:
+ vals = []
+ for row in rows:
+ book_id = lv.model().id(row)
+ mi = db.new_api.get_proxy_metadata(book_id)
+ vals.append({'author': qquote(mi.authors[0]),
+ 'title': qquote(mi.title),
+ 'author_sort': qquote(mi.author_sort_map.get(mi.authors[0]))})
+ d = TemplateDialog(parent=self, text=self.custom_url.text(), mi=vals, formatter=EvalFormatter)
+ if d.exec() == QDialog.DialogCode.Accepted:
+ self.custom_url.setText(d.rule[1])
+
def current_changed(self):
k = self.choices.currentData()
self.custom_url.setVisible(k == 'url')
+ self.custom_url_button.setVisible(k == 'url')
def restore_defaults(self):
self.value = DEFAULT_AUTHOR_LINK
From cc93a1a8a5abdb1ba879a8bc6a8db6b6b2aacfdc Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Fri, 7 Feb 2025 22:15:24 +0000
Subject: [PATCH 2/3] Changes for the custom column web url template. These
aren't particularly risky.
* Change to permit using arbitrary fields added to mi. (base.py)
* Add new action type "cc_url" (book_details.py)
* Code to add the template when creating a custom column
---
src/calibre/ebooks/metadata/book/base.py | 3 ++
src/calibre/gui2/book_details.py | 3 ++
.../gui2/preferences/create_custom_column.py | 50 ++++++++++++++++++-
3 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py
index 08e79b76cf..a64be2654e 100644
--- a/src/calibre/ebooks/metadata/book/base.py
+++ b/src/calibre/ebooks/metadata/book/base.py
@@ -753,6 +753,9 @@ class Metadata:
res = human_readable(res)
return (name, str(res), orig_res, fmeta)
+ if self.get(key, None):
+ return (key, str(self.get(key)), self.get(key), None)
+
return (None, None, None, None)
def __unicode__representation__(self):
diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py
index 538e0c9dd3..cc9884600f 100644
--- a/src/calibre/gui2/book_details.py
+++ b/src/calibre/gui2/book_details.py
@@ -1479,6 +1479,9 @@ class BookDetails(DetailsLayout, DropMixin): # {{{
if dt == 'search':
field = data.get('field')
search_term(data['term'], data['value'])
+ elif dt == 'cc_url':
+ if data['url']:
+ browse(data['url'])
elif dt == 'author':
url = data['url']
if url == 'calibre':
diff --git a/src/calibre/gui2/preferences/create_custom_column.py b/src/calibre/gui2/preferences/create_custom_column.py
index c51a8cdd9f..2ec2ecf96f 100644
--- a/src/calibre/gui2/preferences/create_custom_column.py
+++ b/src/calibre/gui2/preferences/create_custom_column.py
@@ -26,11 +26,13 @@ from qt.core import (
QRadioButton,
QSpinBox,
Qt,
+ QToolButton,
QVBoxLayout,
QWidget,
)
from calibre.gui2 import error_dialog
+from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor
from calibre.utils.date import UNDEFINED_DATE, parse_date
from calibre.utils.localization import ngettext
@@ -231,6 +233,7 @@ class CreateCustomColumn(QDialog):
elif ct == '*text':
self.is_names.setChecked(c['display'].get('is_names', False))
self.description_box.setText(c['display'].get('description', ''))
+ self.web_search_template.setText(c['display'].get('web_search_template', ''))
self.decimals_box.setValue(min(9, max(1, int(c['display'].get('decimals', 2)))))
all_colors = [str(s) for s in list(QColor.colorNames())]
@@ -486,6 +489,7 @@ class CreateCustomColumn(QDialog):
l.addWidget(cch)
l.addStretch()
add_row(None, l)
+
l = QHBoxLayout()
self.composite_in_comments_box = cmc = QCheckBox(_('Show with comments in Book details'))
cmc.setToolTip('' + _('If you check this box then the column contents '
@@ -533,9 +537,45 @@ class CreateCustomColumn(QDialog):
'Rating columns enter a number between 0 and 5.') + '
')
self.default_value_label = add_row(_('&Default value:'), dv)
+ l = QHBoxLayout()
+ self.web_search_label = QLabel(_('Search tem&plate:'))
+ l.addWidget(self.web_search_label)
+ wst = self.web_search_template = QLineEdit()
+ wst.setToolTip('' + _(
+ "Fill in this box if you want clicking on the value in book details to do a "
+ "web search instead of searching your calibre library. The book's metadata are "
+ "available to the template. An additional field 'item_value' is available to the "
+ "template. For multiple-valued (tags-like) columns it is the value being examined, "
+ "telling you which value to use to generate the link.") + '
')
+ l.addWidget(wst)
+ self.web_search_label.setBuddy(wst)
+ wst_tb = self.web_search_toolbutton = QToolButton()
+ wst_tb.setIcon(QIcon.ic('edit_input.png'))
+ l.addWidget(wst_tb)
+ wst_tb.clicked.connect(self.cws_template_button_clicked)
+ add_row(None, l)
+
self.resize(self.sizeHint())
# }}}
+ def cws_template_button_clicked(self):
+ db = self.gui.current_db.new_api
+ lv = self.gui.library_view
+ rows = lv.selectionModel().selectedRows()
+ if not self.editing_col or not rows:
+ vals = [{'value': _('Value'), 'lookup_name': _('Lookup name'), 'author': _('Author'),
+ 'title': _('Title'), 'author_sort': _('Author sort')}]
+ else:
+ vals = []
+ for row in rows:
+ book_id = lv.model().id(row)
+ mi = db.new_api.get_metadata(book_id)
+ mi.set('item_value', 'Item Value')
+ vals.append(mi)
+ d = TemplateDialog(parent=self, text=self.web_search_template.text(), mi=vals)
+ if d.exec() == QDialog.DialogCode.Accepted:
+ self.web_search_template.setText(d.rule[1])
+
def bool_radio_button_clicked(self, button, clicked):
if clicked:
self.bool_button_group.setFocusProxy(button)
@@ -617,7 +657,7 @@ class CreateCustomColumn(QDialog):
'after the decimal point and thousands separated by commas.') + '
'
)
self.format_label.setText(l), self.format_default_label.setText(dl)
- for x in ('in_comments_box', 'heading_position', 'heading_position_label'):
+ for x in ('in_comments_box', 'heading_position', 'heading_position_label',):
getattr(self, 'composite_'+x).setVisible(col_type == 'composite')
for x in ('box', 'default_label', 'label', 'sort_by', 'sort_by_label',
'make_category', 'contains_html'):
@@ -638,6 +678,13 @@ class CreateCustomColumn(QDialog):
self.comments_heading_position_label.setVisible(is_comments)
self.comments_type.setVisible(is_comments)
self.comments_type_label.setVisible(is_comments)
+
+ has_url_template = not is_comments and col_type in ('text', '*text', 'composite', '*composite',
+ 'series', 'enumeration')
+ self.web_search_label.setVisible(has_url_template)
+ self.web_search_template.setVisible(has_url_template)
+ self.web_search_toolbutton.setVisible(has_url_template)
+
self.allow_half_stars.setVisible(col_type == 'rating')
is_bool = col_type == 'bool'
@@ -811,6 +858,7 @@ class CreateCustomColumn(QDialog):
display_dict['default_value'] = default_val
display_dict['description'] = self.description_box.text().strip()
+ display_dict['web_search_template'] = self.web_search_template.text().strip()
if not self.editing_col:
self.caller.custcols[key] = {
From b48b5d34c897483c572103a5d5690740d3ac8495 Mon Sep 17 00:00:00 2001
From: Charles Haley
Date: Fri, 7 Feb 2025 22:16:31 +0000
Subject: [PATCH 3/3] The real work: interpreting the template and putting into
links in book details. This code could use some refactoring and more testing.
---
src/calibre/ebooks/metadata/book/render.py | 116 ++++++++++++++-------
1 file changed, 78 insertions(+), 38 deletions(-)
diff --git a/src/calibre/ebooks/metadata/book/render.py b/src/calibre/ebooks/metadata/book/render.py
index 0ccf37240d..11c5ba445d 100644
--- a/src/calibre/ebooks/metadata/book/render.py
+++ b/src/calibre/ebooks/metadata/book/render.py
@@ -12,6 +12,7 @@ from calibre import force_unicode, prepare_string_for_xml
from calibre.constants import filesystem_encoding
from calibre.db.constants import DATA_DIR_NAME
from calibre.ebooks.metadata import fmt_sidx, rating_to_stars
+from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.ebooks.metadata.search_internet import DEFAULT_AUTHOR_SOURCE, name_for, qquote, url_for_author_search, url_for_book_search
from calibre.ebooks.metadata.sources.identify import urls_from_identifiers
from calibre.library.comments import comments_to_html, markdown
@@ -66,6 +67,19 @@ def search_action_with_data(search_term, value, book_id, field=None, **k):
return search_action(search_term, value, field=field, book_id=book_id, **k)
+def cc_search_action_with_data(search_term, value, book_id, fm, mi, field=None, **k):
+ if mi is not None and fm is not None:
+ template = fm.get('display', {}).get('web_search_template')
+ if template:
+ formatter = SafeFormat()
+ mi.set('item_value', value)
+ u = formatter.safe_format(template, mi, "BOOK DETAILS WEB LINK", mi)
+ if u:
+ v = prepare_string_for_xml(_('Click to browse to {0}').format(u), attribute=True)
+ return action('cc_url', url=u),v
+ t = _('Click to see books with {0}: {1}').format(mi.get('name', search_term), prepare_string_for_xml(value))
+ return search_action_with_data(search_term, value, book_id, **k), t
+
def notes_action(**keys):
return 'notes:' + as_hex_unicode(json_dumps(keys))
@@ -211,16 +225,16 @@ def mi_to_html(
ans.append((field, row % (name, comments_to_html(val))))
else:
if not metadata['is_multiple']:
- val = '{}'.format(
- search_action(field, val, book_id=book_id),
- _('Click to see books with {0}: {1}').format(metadata['name'], a(val)), p(val))
+ u,v = cc_search_action_with_data(field, val, book_id, metadata, mi, field)
+ val = '{}'.format(u, v, p(val))
else:
all_vals = [v.strip()
for v in val.split(metadata['is_multiple']['cache_to_list']) if v.strip()]
if show_links:
- links = ['{}'.format(
- search_action(field, x, book_id=book_id), _('Click to see books with {0}: {1}').format(
- metadata['name'], a(x)), p(x)) for x in all_vals]
+ links = []
+ for x in all_vals:
+ u,v = cc_search_action_with_data(field, x, book_id, metadata, mi, field)
+ links.append('{}'.format(u, v, p(x)))
else:
links = all_vals
val = value_list(metadata['is_multiple']['list_to_ui'], links)
@@ -349,12 +363,19 @@ def mi_to_html(
except Exception:
st = field
series = getattr(mi, field)
- val = _(
- '%(sidx)s of '
- '%(series)s') % dict(
- sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls='series_name',
- series=p(series), href=search_action_with_data(st, series, book_id, field),
- tt=p(_('Click to see books in this series')))
+ if metadata.get('display', {}).get('web_search_template'):
+ u,v = cc_search_action_with_data(st, series, book_id, metadata, mi, field)
+ val = _('%(sidx)s of '
+ '%(series)s') % dict(
+ sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls='series_name',
+ series=p(series), href=u, tt=v)
+ else:
+ val = _(
+ '%(sidx)s of '
+ '%(series)s') % dict(
+ sidx=fmt_sidx(sidx, use_roman=use_roman_numbers), cls='series_name',
+ series=p(series), href=search_action_with_data(st, series, book_id, field),
+ tt=p(_('Click to see books in this series')))
val += add_other_links(field, series)
elif metadata['datatype'] == 'datetime':
aval = getattr(mi, field)
@@ -371,32 +392,51 @@ def mi_to_html(
search_action_with_data(key, str(aval), book_id, None, original_value=val), a(
_('Click to see books with {0}: {1} (derived from {2})').format(
metadata['name'] or field, aval, val)), val)
- elif metadata['datatype'] == 'text' and metadata['is_multiple']:
- try:
- st = metadata['search_terms'][0]
- except Exception:
- st = field
- all_vals = mi.get(field)
- if not metadata.get('display', {}).get('is_names', False):
- all_vals = sorted(all_vals, key=sort_key)
- links = []
- for x in all_vals:
- v = '{}'.format(
- search_action_with_data(st, x, book_id, field), _('Click to see books with {0}: {1}').format(
- metadata['name'] or field, a(x)), p(x))
- v += add_other_links(field, x)
- links.append(v)
- val = value_list(metadata['is_multiple']['list_to_ui'], links)
- elif metadata['datatype'] == 'text' or metadata['datatype'] == 'enumeration':
- # text/is_multiple handled above so no need to add the test to the if
- try:
- st = metadata['search_terms'][0]
- except Exception:
- st = field
- v = '{}'.format(
- search_action_with_data(st, unescaped_val, book_id, field), a(
- _('Click to see books with {0}: {1}').format(metadata['name'] or field, val)), val)
- val = v + add_other_links(field, val)
+ elif metadata['datatype'] == 'text':
+ if metadata['is_multiple']:
+ try:
+ st = metadata['search_terms'][0]
+ except Exception:
+ st = field
+ all_vals = mi.get(field)
+ if not metadata.get('display', {}).get('is_names', False):
+ all_vals = sorted(all_vals, key=sort_key)
+ links = []
+ if show_links:
+ for x in all_vals:
+ if metadata['is_custom']:
+ u,v = cc_search_action_with_data(field, x, book_id, metadata, mi, field)
+ v = '{}'.format(u, v, p(x))
+ else:
+ v = '{}'.format(
+ search_action_with_data(st, x, book_id, field),
+ _('Click to see books with {0}: {1}').format(
+ metadata['name'] or field, a(x)), p(x))
+ v += add_other_links(field, x)
+ links.append(v)
+ else:
+ links = all_vals
+ val = value_list(metadata['is_multiple']['list_to_ui'], links)
+ else:
+ try:
+ st = metadata['search_terms'][0]
+ except Exception:
+ st = field
+ if show_links:
+ if metadata['is_custom']:
+ u,v = cc_search_action_with_data(st, x, book_id, metadata, mi, field)
+ v = '{}'.format(u, v, p(x))
+ else:
+ v = '{}'.format(
+ search_action_with_data(st, x, book_id, field),
+ _('Click to see books with {0}: {1}').format(
+ metadata['name'] or field, a(x)), p(x))
+ else:
+ v = val
+ val = v + add_other_links(field, val)
+ elif metadata['datatype'] == 'enumeration':
+ u,v = cc_search_action_with_data(field, x, book_id, metadata, mi, field)
+ val = '{}'.format(u, v, p(x)) + add_other_links(field, val)
elif metadata['datatype'] == 'bool':
val = '{}'.format(
search_action_with_data(field, val, book_id, None), a(