Allow creation of rules to convert arbitrary identifiers into clickable links in the book details panel

This commit is contained in:
Kovid Goyal 2016-04-10 19:53:09 +05:30
parent 9269e62723
commit b20610910e
4 changed files with 155 additions and 14 deletions

View File

@ -14,6 +14,7 @@ from threading import Thread
from io import BytesIO from io import BytesIO
from operator import attrgetter from operator import attrgetter
from urlparse import urlparse from urlparse import urlparse
from urllib import quote
from calibre.customize.ui import metadata_plugins, all_metadata_plugins from calibre.customize.ui import metadata_plugins, all_metadata_plugins
from calibre.ebooks.metadata import check_issn from calibre.ebooks.metadata import check_issn
@ -25,6 +26,7 @@ from calibre.utils.date import utc_tz, as_utc
from calibre.utils.html2text import html2text from calibre.utils.html2text import html2text
from calibre.utils.icu import lower from calibre.utils.icu import lower
from calibre.utils.date import UNDEFINED_DATE from calibre.utils.date import UNDEFINED_DATE
from calibre.utils.formatter import EvalFormatter
# Download worker {{{ # Download worker {{{
class Worker(Thread): class Worker(Thread):
@ -477,9 +479,9 @@ def identify(log, abort, # {{{
if f == 'series': if f == 'series':
result.series_index = dummy.series_index result.series_index = dummy.series_index
result.relevance_in_source = i result.relevance_in_source = i
result.has_cached_cover_url = (plugin.cached_cover_url_is_reliable result.has_cached_cover_url = (
and plugin.get_cached_cover_url(result.identifiers) is not plugin.cached_cover_url_is_reliable and
None) plugin.get_cached_cover_url(result.identifiers) is not None)
result.identify_plugin = plugin result.identify_plugin = plugin
if msprefs['txt_comments']: if msprefs['txt_comments']:
if plugin.has_html_comments and result.comments: if plugin.has_html_comments and result.comments:
@ -524,6 +526,20 @@ def identify(log, abort, # {{{
def urls_from_identifiers(identifiers): # {{{ def urls_from_identifiers(identifiers): # {{{
identifiers = {k.lower():v for k, v in identifiers.iteritems()} identifiers = {k.lower():v for k, v in identifiers.iteritems()}
ans = [] ans = []
rules = msprefs['id_link_rules']
if rules:
formatter = EvalFormatter()
for k, val in identifiers.iteritems():
vals = {'id':quote(val)}
items = rules.get(k) or ()
for name, template in items:
try:
url = formatter.safe_format(template, vals, '', vals)
except Exception:
import traceback
traceback.format_exc()
continue
ans.append((name, k, val, url))
for plugin in all_metadata_plugins(): for plugin in all_metadata_plugins():
try: try:
for id_type, id_val, url in plugin.get_book_urls(identifiers): for id_type, id_val, url in plugin.get_book_urls(identifiers):

View File

@ -20,6 +20,7 @@ msprefs.defaults['fewer_tags'] = True
msprefs.defaults['find_first_edition_date'] = False msprefs.defaults['find_first_edition_date'] = False
msprefs.defaults['append_comments'] = False msprefs.defaults['append_comments'] = False
msprefs.defaults['tag_map_rules'] = [] msprefs.defaults['tag_map_rules'] = []
msprefs.defaults['id_link_rules'] = {}
# Google covers are often poor quality (scans/errors) but they have high # Google covers are often poor quality (scans/errors) but they have high
# resolution, so they trump covers from better sources. So make sure they # resolution, so they trump covers from better sources. So make sure they

View File

@ -7,19 +7,23 @@ __docformat__ = 'restructuredtext en'
import json import json
from collections import defaultdict
from threading import Thread from threading import Thread
from functools import partial from functools import partial
from PyQt5.Qt import ( from PyQt5.Qt import (
QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, QPainter, QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, QPainter,
QAbstractListModel, Qt, QIcon, QKeySequence, QColor, pyqtSignal, QCursor, QAbstractListModel, Qt, QIcon, QKeySequence, QColor, pyqtSignal, QCursor,
QWidget, QSizePolicy, QBrush, QPixmap, QSize, QPushButton, QVBoxLayout) QWidget, QSizePolicy, QBrush, QPixmap, QSize, QPushButton, QVBoxLayout,
QTableWidget, QTableWidgetItem, QLabel, QFormLayout, QLineEdit
)
from calibre import human_readable from calibre import human_readable
from calibre.ebooks.metadata.sources.prefs import msprefs
from calibre.gui2.dialogs.template_dialog import TemplateDialog from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList
from calibre.gui2.preferences.look_feel_ui import Ui_Form from calibre.gui2.preferences.look_feel_ui import Ui_Form
from calibre.gui2 import config, gprefs, qt_app, open_local_file, question_dialog from calibre.gui2 import config, gprefs, qt_app, open_local_file, question_dialog, error_dialog
from calibre.utils.localization import (available_translations, from calibre.utils.localization import (available_translations,
get_language, get_lang) get_language, get_lang)
from calibre.utils.config import prefs from calibre.utils.config import prefs
@ -27,6 +31,7 @@ from calibre.utils.icu import sort_key
from calibre.gui2.book_details import get_field_list from calibre.gui2.book_details import get_field_list
from calibre.gui2.preferences.coloring import EditRules from calibre.gui2.preferences.coloring import EditRules
from calibre.gui2.library.alternate_views import auto_height, CM_TO_INCH from calibre.gui2.library.alternate_views import auto_height, CM_TO_INCH
from calibre.gui2.widgets2 import Dialog
class BusyCursor(object): class BusyCursor(object):
@ -36,6 +41,115 @@ class BusyCursor(object):
def __exit__(self, *args): def __exit__(self, *args):
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
# IdLinksEditor {{{
class IdLinksRuleEdit(Dialog):
def __init__(self, key='', name='', template='', parent=None):
title = _('Edit rule') if key else _('Create a new rule')
Dialog.__init__(self, title=title, name='id-links-rule-editor', parent=parent)
self.key.setText(key), self.nw.setText(name), self.template.setText(template or 'http://example.com/{id}')
@property
def rule(self):
return self.key.text().lower(), self.nw.text(), self.template.text()
def setup_ui(self):
self.l = l = QFormLayout(self)
l.setFieldGrowthPolicy(l.AllNonFixedFieldsGrow)
l.addRow(QLabel(_(
'The key of the identifier, for example, in isbn:XXX, they key is isbn')))
self.key = k = QLineEdit(self)
l.addRow(_('&Key:'), k)
l.addRow(QLabel(_(
'The name that will appear in the book details panel')))
self.nw = n = QLineEdit(self)
l.addRow(_('&Name:'), n)
la = QLabel(_(
'The template used to create the link. The placeholder {id} in the template will be replaced with the actual identifier value.'))
la.setWordWrap(True)
l.addRow(la)
self.template = t = QLineEdit(self)
l.addRow(_('&Template:'), t)
t.selectAll()
t.setFocus(Qt.OtherFocusReason)
l.addWidget(self.bb)
def accept(self):
r = self.rule
for i, which in enumerate([_('Key'), _('Name'), _('Template')]):
if not r[i]:
return error_dialog(self, _('Value needed'), _(
'The %s field cannot be empty') % which, show=True)
Dialog.accept(self)
class IdLinksEditor(Dialog):
def __init__(self, parent=None):
Dialog.__init__(self, title=_('Create rules for identifiers'), name='id-links-rules-editor', parent=parent)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.la = la = QLabel(_(
'Create rules to convert identifiers into links.'))
la.setWordWrap(True)
l.addWidget(la)
items = []
for k, lx in msprefs['id_link_rules'].iteritems():
for n, t in lx:
items.append((k, n, t))
items.sort(key=lambda x:sort_key(x[1]))
self.table = t = QTableWidget(len(items), 3, self)
t.setHorizontalHeaderLabels([_('Key'), _('Name'), _('Template')])
for r, (key, val, template) in enumerate(items):
t.setItem(r, 0, QTableWidgetItem(key))
t.setItem(r, 1, QTableWidgetItem(val))
t.setItem(r, 2, QTableWidgetItem(template))
l.addWidget(t)
t.horizontalHeader().setSectionResizeMode(2, t.horizontalHeader().Stretch)
self.cb = b = QPushButton(QIcon(I('plus.png')), _('&Add rule'), self)
b.clicked.connect(lambda : self.edit_rule())
self.bb.addButton(b, self.bb.ActionRole)
self.rb = b = QPushButton(QIcon(I('minus.png')), _('&Remove rule'), self)
b.clicked.connect(lambda : self.remove_rule())
self.bb.addButton(b, self.bb.ActionRole)
self.eb = b = QPushButton(QIcon(I('modified.png')), _('&Edit rule'), self)
b.clicked.connect(lambda : self.edit_rule(self.table.currentRow()))
self.bb.addButton(b, self.bb.ActionRole)
l.addWidget(self.bb)
def sizeHint(self):
return QSize(700, 550)
def accept(self):
rules = defaultdict(list)
for r in range(self.table.rowCount()):
def item(c):
return self.table.item(r, c).text()
rules[item(0)].append([item(1), item(2)])
msprefs['id_link_rules'] = dict(rules)
Dialog.accept(self)
def edit_rule(self, r=-1):
key = name = template = ''
if r > -1:
key, name, template = map(lambda c: self.table.item(r, c).text(), range(3))
d = IdLinksRuleEdit(key, name, template, self)
if d.exec_() == d.Accepted:
if r < 0:
self.table.setRowCount(self.table.rowCount() + 1)
r = self.table.rowCount() - 1
rule = d.rule
for c in range(3):
self.table.setItem(r, c, QTableWidgetItem(rule[c]))
self.table.scrollToItem(self.table.item(r, 0))
def remove_rule(self):
r = self.table.currentRow()
if r > -1:
self.table.removeRow(r)
# }}}
class DisplayedFields(QAbstractListModel): # {{{ class DisplayedFields(QAbstractListModel): # {{{
def __init__(self, db, parent=None): def __init__(self, db, parent=None):
@ -178,6 +292,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
(_('Left'), 'left'), (_('Top'), 'top'), (_('Right'), 'right'), (_('Bottom'), 'bottom')]) (_('Left'), 'left'), (_('Top'), 'top'), (_('Right'), 'right'), (_('Bottom'), 'bottom')])
r('book_list_extra_row_spacing', gprefs) r('book_list_extra_row_spacing', gprefs)
self.cover_browser_title_template_button.clicked.connect(self.edit_cb_title_template) self.cover_browser_title_template_button.clicked.connect(self.edit_cb_title_template)
self.id_links_button.clicked.connect(self.edit_id_link_rules)
def get_esc_lang(l): def get_esc_lang(l):
if l == 'en': if l == 'en':
@ -310,6 +425,10 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.icon_theme.setText(_('Icon theme: <b>%s</b>') % self.icon_theme_title) self.icon_theme.setText(_('Icon theme: <b>%s</b>') % self.icon_theme_title)
self.changed_signal.emit() self.changed_signal.emit()
def edit_id_link_rules(self):
if IdLinksEditor(self).exec_() == Dialog.Accepted:
self.changed_signal.emit()
@property @property
def current_cover_size(self): def current_cover_size(self):
cval = self.opt_cover_grid_height.value() cval = self.opt_cover_grid_height.value()
@ -519,5 +638,3 @@ if __name__ == '__main__':
from calibre.gui2 import Application from calibre.gui2 import Application
app = Application([]) app = Application([])
test_widget('Interface', 'Look & Feel') test_widget('Interface', 'Look & Feel')

View File

@ -6,7 +6,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>820</width> <width>843</width>
<height>546</height> <height>546</height>
</rect> </rect>
</property> </property>
@ -661,7 +661,7 @@ A value of zero means calculate automatically.</string>
<string>Book Details</string> <string>Book Details</string>
</attribute> </attribute>
<layout class="QGridLayout" name="gridLayout_12"> <layout class="QGridLayout" name="gridLayout_12">
<item row="2" column="1"> <item row="3" column="1">
<widget class="QLabel" name="label_3"> <widget class="QLabel" name="label_3">
<property name="text"> <property name="text">
<string>Note that &lt;b&gt;comments&lt;/b&gt; will always be displayed at the end, regardless of the position you assign here.</string> <string>Note that &lt;b&gt;comments&lt;/b&gt; will always be displayed at the end, regardless of the position you assign here.</string>
@ -671,7 +671,7 @@ A value of zero means calculate automatically.</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0" rowspan="2"> <item row="3" column="0" rowspan="2">
<widget class="QGroupBox" name="groupBox"> <widget class="QGroupBox" name="groupBox">
<property name="title"> <property name="title">
<string>Select displayed metadata</string> <string>Select displayed metadata</string>
@ -801,6 +801,13 @@ Manage Authors. You can use the values {author} and
</item> </item>
</layout> </layout>
</item> </item>
<item row="2" column="0" colspan="2">
<widget class="QPushButton" name="id_links_button">
<property name="text">
<string>Create rules to convert &amp;identifiers into links</string>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="tab_2"> <widget class="QWidget" name="tab_2">
@ -900,15 +907,15 @@ then the tags will be displayed each on their own line.</string>
</item> </item>
<item row="8" column="0" colspan="2"> <item row="8" column="0" colspan="2">
<widget class="QCheckBox" name="opt_tag_browser_hide_empty_categories"> <widget class="QCheckBox" name="opt_tag_browser_hide_empty_categories">
<property name="text">
<string>Hi&amp;de empty categories (columns) in the tag browser</string>
</property>
<property name="toolTip"> <property name="toolTip">
<string>When checked, calibre will automatically hide any category <string>When checked, calibre will automatically hide any category
(a column, custom or standard) that has no items to show. For example, some (a column, custom or standard) that has no items to show. For example, some
categories might not have values when using virtual libraries. Checking this categories might not have values when using virtual libraries. Checking this
box will cause these empty categories to be hidden.</string> box will cause these empty categories to be hidden.</string>
</property> </property>
<property name="text">
<string>Hi&amp;de empty categories (columns) in the tag browser</string>
</property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="1" column="1">