%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/preferences/ |
| Current File : //lib/calibre/calibre/gui2/preferences/coloring.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import json
import os
import textwrap
from functools import partial
from qt.core import (
QAbstractItemView, QAbstractListModel, QApplication, QCheckBox, QComboBox,
QDialog, QDialogButtonBox, QDoubleValidator, QFrame, QGridLayout, QIcon,
QIntValidator, QItemSelectionModel, QLabel, QLineEdit, QListView,
QPalette, QPushButton, QScrollArea, QSize, QSizePolicy, QSpacerItem,
QStandardItem, QStandardItemModel, Qt, QToolButton, QVBoxLayout, QWidget,
QItemSelection, QListWidget, QListWidgetItem, pyqtSignal
)
from calibre import as_unicode, prepare_string_for_xml, sanitize_file_name
from calibre.constants import config_dir
from calibre.gui2 import (
choose_files, choose_save_file, error_dialog, gprefs, open_local_file,
pixmap_to_data, question_dialog
)
from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.gui2.metadata.single_download import RichTextDelegate
from calibre.gui2.widgets2 import ColorButton, FlowLayout, Separator
from calibre.library.coloring import (
Rule, color_row_key, conditionable_columns, displayable_columns,
rule_from_template
)
from calibre.utils.icu import lower, sort_key
from calibre.utils.localization import lang_map
from polyglot.builtins import iteritems
all_columns_string = _('All columns')
icon_rule_kinds = [(_('icon with text'), 'icon'),
(_('icon with no text'), 'icon_only'),
(_('composed icons w/text'), 'icon_composed'),
(_('composed icons w/no text'), 'icon_only_composed'),]
class ConditionEditor(QWidget): # {{{
ACTION_MAP = {
'bool2' : (
(_('is true'), 'is true',),
(_('is false'), 'is not true'),
),
'bool' : (
(_('is true'), 'is true',),
(_('is not true'), 'is not true'),
(_('is false'), 'is false'),
(_('is not false'), 'is not false'),
(_('is undefined'), 'is undefined'),
(_('is defined'), 'is defined'),
),
'ondevice' : (
(_('is true'), 'is set',),
(_('is false'), 'is not set'),
),
'identifiers' : (
(_('has id'), 'has id'),
(_('does not have id'), 'does not have id'),
),
'int' : (
(_('is equal to'), 'eq'),
(_('is less than'), 'lt'),
(_('is greater than'), 'gt'),
(_('is set'), 'is set'),
(_('is not set'), 'is not set')
),
'datetime' : (
(_('is equal to'), 'eq'),
(_('is earlier than'), 'lt'),
(_('is later than'), 'gt'),
(_('is today'), 'is today'),
(_('is set'), 'is set'),
(_('is not set'), 'is not set'),
(_('is more days ago than'), 'older count days'),
(_('is fewer days ago than'), 'count_days'),
(_('is more days from now than'), 'newer future days'),
(_('is fewer days from now than'), 'older future days')
),
'multiple' : (
(_('has'), 'has'),
(_('does not have'), 'does not have'),
(_('has pattern'), 'has pattern'),
(_('does not have pattern'), 'does not have pattern'),
(_('is set'), 'is set'),
(_('is not set'), 'is not set'),
),
'multiple_no_isset' : (
(_('has'), 'has'),
(_('does not have'), 'does not have'),
(_('has pattern'), 'has pattern'),
(_('does not have pattern'), 'does not have pattern'),
),
'single' : (
(_('is'), 'is'),
(_('is not'), 'is not'),
(_('contains'), 'contains'),
(_('does not contain'), 'does not contain'),
(_('matches pattern'), 'matches pattern'),
(_('does not match pattern'), 'does not match pattern'),
(_('is set'), 'is set'),
(_('is not set'), 'is not set'),
),
'single_no_isset' : (
(_('is'), 'is'),
(_('is not'), 'is not'),
(_('contains'), 'contains'),
(_('does not contain'), 'does not contain'),
(_('matches pattern'), 'matches pattern'),
(_('does not match pattern'), 'does not match pattern'),
),
}
for x in ('float', 'rating'):
ACTION_MAP[x] = ACTION_MAP['int']
def __init__(self, fm, parent=None):
QWidget.__init__(self, parent)
self.fm = fm
self.action_map = self.ACTION_MAP
self.l = l = QGridLayout(self)
self.setLayout(l)
texts = _('If the ___ column ___ value')
try:
one, two, three = texts.split('___')
except:
one, two, three = 'If the ', ' column ', ' value '
self.l1 = l1 = QLabel(one)
l.addWidget(l1, 0, 0)
self.column_box = QComboBox(self)
l.addWidget(self.column_box, 0, 1)
self.l2 = l2 = QLabel(two)
l.addWidget(l2, 0, 2)
self.action_box = QComboBox(self)
l.addWidget(self.action_box, 0, 3)
self.l3 = l3 = QLabel(three)
l.addWidget(l3, 0, 4)
self.value_box = QLineEdit(self)
l.addWidget(self.value_box, 0, 5)
self.column_box.addItem('', '')
for key in sorted(
conditionable_columns(fm),
key=lambda key: sort_key(fm[key]['name'])):
self.column_box.addItem('{} ({})'.format(fm[key]['name'], key), key)
self.column_box.setCurrentIndex(0)
self.column_box.currentIndexChanged.connect(self.init_action_box)
self.action_box.currentIndexChanged.connect(self.init_value_box)
for b in (self.column_box, self.action_box):
b.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
b.setMinimumContentsLength(20)
@property
def current_col(self):
idx = self.column_box.currentIndex()
return str(self.column_box.itemData(idx) or '')
@current_col.setter
def current_col(self, val):
for idx in range(self.column_box.count()):
c = str(self.column_box.itemData(idx) or '')
if c == val:
self.column_box.setCurrentIndex(idx)
return
raise ValueError('Column %r not found'%val)
@property
def current_action(self):
idx = self.action_box.currentIndex()
return str(self.action_box.itemData(idx) or '')
@current_action.setter
def current_action(self, val):
for idx in range(self.action_box.count()):
c = str(self.action_box.itemData(idx) or '')
if c == val:
self.action_box.setCurrentIndex(idx)
return
raise ValueError('Action %r not valid for current column'%val)
@property
def current_val(self):
ans = str(self.value_box.text()).strip()
if not self.value_box.isEnabled():
ans = ''
if self.current_col == 'languages':
rmap = {lower(v):k for k, v in iteritems(lang_map())}
ans = rmap.get(lower(ans), ans)
return ans
@property
def condition(self):
c, a, v = (self.current_col, self.current_action,
self.current_val)
if not c or not a:
return None
return (c, a, v)
@condition.setter
def condition(self, condition):
c, a, v = condition
if not v:
v = ''
v = v.strip()
self.current_col = c
self.current_action = a
self.value_box.setText(v)
def init_action_box(self):
self.action_box.blockSignals(True)
self.action_box.clear()
self.action_box.addItem('', '')
col = self.current_col
if col:
m = self.fm[col]
dt = m['datatype']
if dt == 'bool':
from calibre.gui2.ui import get_gui
if not get_gui().current_db.new_api.pref('bools_are_tristate'):
dt = 'bool2'
if dt in self.action_map:
actions = self.action_map[dt]
else:
if col == 'ondevice':
k = 'ondevice'
elif col == 'identifiers':
k = 'identifiers'
elif col == 'authors':
k = 'multiple_no_isset'
elif col == 'title':
k = 'single_no_isset'
else:
k = 'multiple' if m['is_multiple'] else 'single'
actions = self.action_map[k]
for text, key in actions:
self.action_box.addItem(text, key)
self.action_box.setCurrentIndex(0)
self.action_box.blockSignals(False)
self.init_value_box()
def init_value_box(self):
self.value_box.setEnabled(True)
self.value_box.setText('')
self.value_box.setInputMask('')
self.value_box.setValidator(None)
col = self.current_col
if not col:
return
action = self.current_action
if not action:
return
m = self.fm[col]
dt = m['datatype']
tt = ''
if col == 'identifiers':
tt = _('Enter either an identifier type or an '
'identifier type and value of the form identifier:value')
elif col == 'languages':
tt = _('Enter a 3 letter ISO language code, like fra for French'
' or deu for German or eng for English. You can also use'
' the full language name, in which case calibre will try to'
' automatically convert it to the language code.')
elif dt in ('int', 'float', 'rating'):
tt = _('Enter a number')
v = QIntValidator if dt == 'int' else QDoubleValidator
self.value_box.setValidator(v(self.value_box))
elif dt == 'datetime':
if action == 'count_days':
self.value_box.setValidator(QIntValidator(self.value_box))
tt = _('Enter the maximum days old the item can be. Zero is today. '
'Dates in the future always match')
elif action == 'older count days':
self.value_box.setValidator(QIntValidator(self.value_box))
tt = _('Enter the minimum days old the item can be. Zero is today. '
'Dates in the future never match')
elif action == 'older future days':
self.value_box.setValidator(QIntValidator(self.value_box))
tt = _('Enter the maximum days in the future the item can be. '
'Zero is today. Dates in the past always match')
elif action == 'newer future days':
self.value_box.setValidator(QIntValidator(self.value_box))
tt = _('Enter the minimum days in the future the item can be. '
'Zero is today. Dates in the past never match')
else:
self.value_box.setInputMask('9999-99-99')
tt = _('Enter a date in the format YYYY-MM-DD')
else:
tt = _('Enter a string.')
if 'pattern' in action:
tt = _('Enter a regular expression')
elif m.get('is_multiple', False):
tt += '\n' + _('You can match multiple values by separating'
' them with %s')%m['is_multiple']['ui_to_list']
self.value_box.setToolTip(tt)
if action in ('is set', 'is not set', 'is true', 'is false', 'is undefined', 'is today'):
self.value_box.setEnabled(False)
# }}}
class RemoveIconFileDialog(QDialog): # {{{
def __init__(self, parent, icon_file_names, icon_folder):
self.files_to_remove = []
QDialog.__init__(self, parent)
self.setWindowTitle(_('Remove icons'))
self.setWindowFlags(self.windowFlags()&(~Qt.WindowType.WindowContextHelpButtonHint))
l = QVBoxLayout(self)
t = QLabel('<p>' + _('Select the icons you wish to remove. The icon files will be '
'removed when you press OK. There is no undo.') + '</p>')
t.setWordWrap(True)
t.setTextFormat(Qt.TextFormat.RichText)
l.addWidget(t)
self.listbox = lw = QListWidget(parent)
lw.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
for fn in icon_file_names:
item = QListWidgetItem(fn)
item.setIcon(QIcon(os.path.join(icon_folder, fn)))
lw.addItem(item)
l.addWidget(lw)
self.bb = bb = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
l.addWidget(bb)
def sizeHint(self):
return QSize(700, 600)
def accept(self):
self.files_to_remove = [item.text() for item in self.listbox.selectedItems()]
if not self.files_to_remove:
return error_dialog(self, _('No icons selected'), _(
'You must select at least one icon to remove'), show=True)
if question_dialog(self,
_('Remove icons'),
ngettext('One icon will be removed.', '{} icons will be removed.', len(self.files_to_remove)
).format(len(self.files_to_remove)) + ' ' + _('This will prevent any rules that use this icon from working. Are you sure?'),
yes_text=_('Yes'),
no_text=_('No'),
det_msg='\n'.join(self.files_to_remove),
skip_dialog_name='remove_icon_confirmation_dialog'
):
QDialog.accept(self)
# }}}
class RuleEditor(QDialog): # {{{
@property
def doing_multiple(self):
return hasattr(self, 'multiple_icon_cb') and self.multiple_icon_cb.isChecked()
def __init__(self, fm, pref_name, parent=None):
QDialog.__init__(self, parent)
self.fm = fm
if pref_name == 'column_color_rules':
self.rule_kind = 'color'
rule_text = _('column coloring')
elif pref_name == 'column_icon_rules':
self.rule_kind = 'icon'
rule_text = _('column icon')
elif pref_name == 'cover_grid_icon_rules':
self.rule_kind = 'emblem'
rule_text = _('Cover grid emblem')
self.setWindowIcon(QIcon(I('format-fill-color.png')))
self.setWindowTitle(_('Create/edit a {0} rule').format(rule_text))
self.l = l = QGridLayout(self)
self.setLayout(l)
self.l1 = l1 = QLabel(_('Create a {0} rule by'
' filling in the boxes below').format(rule_text))
l.addWidget(l1, 0, 0, 1, 8)
self.f1 = QFrame(self)
self.f1.setFrameShape(QFrame.Shape.HLine)
l.addWidget(self.f1, 1, 0, 1, 8)
# self.l2 = l2 = QLabel(_('Add the emblem:') if self.rule_kind == 'emblem' else _('Set the'))
# l.addWidget(l2, 2, 0)
if self.rule_kind == 'emblem':
self.l2 = l2 = QLabel(_('Add the emblem:'))
l.addWidget(l2, 2, 0)
elif self.rule_kind == 'color':
l.addWidget(QLabel(_('Set the color of the column:')), 2, 0)
elif self.rule_kind == 'icon':
l.addWidget(QLabel(_('Set the:')), 2, 0)
self.kind_box = QComboBox(self)
for tt, t in icon_rule_kinds:
self.kind_box.addItem(tt, t)
l.addWidget(self.kind_box, 3, 0)
self.kind_box.setToolTip(textwrap.fill(_(
'Choosing icon with text will add an icon to the left of the'
' column content, choosing icon with no text will hide'
' the column content and leave only the icon.'
' If you choose composed icons and multiple rules match, then all the'
' matching icons will be combined, otherwise the icon from the'
' first rule to match will be used.')))
self.l3 = l3 = QLabel(_('of the column:'))
l.addWidget(l3, 2, 2)
else:
pass
self.column_box = QComboBox(self)
l.addWidget(self.column_box, 3, 0 if self.rule_kind == 'color' else 2)
self.l4 = l4 = QLabel(_('to:'))
l.addWidget(l4, 2, 5)
if self.rule_kind == 'emblem':
self.column_box.setVisible(False), l4.setVisible(False)
def create_filename_box():
self.filename_box = f = QComboBox()
self.filenamebox_view = v = QListView()
v.setIconSize(QSize(32, 32))
self.filename_box.setView(v)
self.orig_filenamebox_view = f.view()
f.setMinimumContentsLength(20), f.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
self.populate_icon_filenames()
if self.rule_kind == 'color':
self.color_box = ColorButton(parent=self)
self.color_label = QLabel('Sample text Sample text')
self.color_label.setTextFormat(Qt.TextFormat.RichText)
l.addWidget(self.color_box, 3, 5)
l.addWidget(self.color_label, 3, 6)
l.addItem(QSpacerItem(10, 10, QSizePolicy.Policy.Expanding), 2, 7)
elif self.rule_kind == 'emblem':
create_filename_box()
self.update_filename_box()
self.filename_button = QPushButton(QIcon(I('document_open.png')),
_('&Add new image'))
l.addWidget(self.filename_box, 3, 0)
l.addWidget(self.filename_button, 3, 2)
l.addWidget(QLabel(_('(Images should be square-ish)')), 3, 4)
l.setColumnStretch(7, 10)
else:
create_filename_box()
self.multiple_icon_cb = QCheckBox(_('Choose &more than one icon'))
l.addWidget(self.multiple_icon_cb, 4, 5)
self.update_filename_box()
self.multiple_icon_cb.clicked.connect(self.multiple_box_clicked)
l.addWidget(self.filename_box, 3, 5)
self.filename_button = QPushButton(QIcon(I('document_open.png')),
_('&Add icon'))
l.addWidget(self.filename_button, 3, 6)
l.addWidget(QLabel(_('(Icons should be square or landscape)')), 4, 6)
l.setColumnStretch(7, 10)
self.l5 = l5 = QLabel(
_('Only if the following conditions are all satisfied:'))
l.addWidget(l5, 5, 0, 1, 7)
self.scroll_area = sa = QScrollArea(self)
sa.setMinimumHeight(300)
sa.setMinimumWidth(700)
sa.setWidgetResizable(True)
l.addWidget(sa, 6, 0, 1, 8)
self.add_button = b = QPushButton(QIcon(I('plus.png')),
_('Add &another condition'))
l.addWidget(b, 7, 0, 1, 8)
b.clicked.connect(self.add_blank_condition)
self.l6 = l6 = QLabel(_('You can disable a condition by'
' blanking all of its boxes'))
l.addWidget(l6, 8, 0, 1, 8)
self.bb = bb = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
l.addWidget(bb, 9, 0, 1, 8)
if self.rule_kind != 'color':
self.remove_button = b = bb.addButton(_('&Remove icons'), QDialogButtonBox.ButtonRole.ActionRole)
b.setIcon(QIcon(I('minus.png')))
b.clicked.connect(self.remove_icon_file_dialog)
b.setToolTip('<p>' + _('Remove previously added icons. Note that removing an '
'icon will cause rules that use it to stop working.') + '</p>')
self.conditions_widget = QWidget(self)
sa.setWidget(self.conditions_widget)
self.conditions_widget.setLayout(QVBoxLayout())
self.conditions_widget.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
self.conditions = []
if self.rule_kind == 'color':
for b in (self.column_box, ):
b.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
b.setMinimumContentsLength(15)
for key in sorted(displayable_columns(fm),
key=lambda k: sort_key(fm[k]['name']) if k != color_row_key else b''):
if key == color_row_key and self.rule_kind != 'color':
continue
name = all_columns_string if key == color_row_key else fm[key]['name']
if name:
self.column_box.addItem(name +
(' (' + key + ')' if key != color_row_key else ''), key)
self.column_box.setCurrentIndex(0)
if self.rule_kind == 'color':
self.color_box.color = '#000'
self.update_color_label()
self.color_box.color_changed.connect(self.update_color_label)
else:
self.rule_icon_files = []
self.filename_button.clicked.connect(self.filename_button_clicked)
self.resize(self.sizeHint())
def multiple_box_clicked(self):
self.update_filename_box()
self.update_icon_filenames_in_box()
@property
def icon_folder(self):
return os.path.join(config_dir, 'cc_icons')
def populate_icon_filenames(self):
d = self.icon_folder
self.icon_file_names = []
if os.path.exists(d):
for icon_file in os.listdir(d):
icon_file = lower(icon_file)
if os.path.exists(os.path.join(d, icon_file)) and icon_file.endswith('.png'):
self.icon_file_names.append(icon_file)
self.icon_file_names.sort(key=sort_key)
def update_filename_box(self):
doing_multiple = self.doing_multiple
model = QStandardItemModel()
self.filename_box.setModel(model)
self.icon_file_names.sort(key=sort_key)
if doing_multiple:
item = QStandardItem(_('Open to see checkboxes'))
item.setIcon(QIcon(I('blank.png')))
else:
item = QStandardItem('')
item.setFlags(Qt.ItemFlag(0))
model.appendRow(item)
for i,filename in enumerate(self.icon_file_names):
item = QStandardItem(filename)
if doing_multiple:
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
else:
item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
icon = QIcon(os.path.join(self.icon_folder, filename))
item.setIcon(icon)
model.appendRow(item)
def update_color_label(self):
pal = QApplication.palette()
bg1 = str(pal.color(QPalette.ColorRole.Base).name())
bg2 = str(pal.color(QPalette.ColorRole.AlternateBase).name())
c = self.color_box.color
self.color_label.setText('''
<span style="color: {c}; background-color: {bg1}"> {st} </span>
<span style="color: {c}; background-color: {bg2}"> {st} </span>
'''.format(c=c, bg1=bg1, bg2=bg2, st=_('Sample text')))
def sanitize_icon_file_name(self, icon_path):
n = lower(sanitize_file_name(
os.path.splitext(
os.path.basename(icon_path))[0]+'.png'))
return n.replace("'", '_')
def filename_button_clicked(self):
try:
path = choose_files(self, 'choose_category_icon',
_('Select icon'), filters=[
(_('Images'), ['png', 'gif', 'jpg', 'jpeg'])],
all_files=False, select_only_single_file=True)
if path:
icon_path = path[0]
icon_name = self.sanitize_icon_file_name(icon_path)
if icon_name not in self.icon_file_names:
self.icon_file_names.append(icon_name)
try:
p = QIcon(icon_path).pixmap(QSize(128, 128))
d = self.icon_folder
if not os.path.exists(os.path.join(d, icon_name)):
if not os.path.exists(d):
os.makedirs(d)
with open(os.path.join(d, icon_name), 'wb') as f:
f.write(pixmap_to_data(p, format='PNG'))
except:
import traceback
traceback.print_exc()
self.update_filename_box()
if self.doing_multiple:
if icon_name not in self.rule_icon_files:
self.rule_icon_files.append(icon_name)
self.update_icon_filenames_in_box()
else:
self.filename_box.setCurrentIndex(self.filename_box.findText(icon_name))
self.filename_box.adjustSize()
except:
import traceback
traceback.print_exc()
return
def get_filenames_from_box(self):
if self.doing_multiple:
model = self.filename_box.model()
fnames = []
for i in range(1, model.rowCount()):
item = model.item(i, 0)
if item.checkState() == Qt.CheckState.Checked:
fnames.append(lower(str(item.text())))
fname = ' : '.join(fnames)
else:
fname = lower(str(self.filename_box.currentText()))
return fname
def update_icon_filenames_in_box(self):
if self.rule_icon_files:
if not self.doing_multiple:
idx = self.filename_box.findText(self.rule_icon_files[0])
if idx >= 0:
self.filename_box.setCurrentIndex(idx)
else:
self.filename_box.setCurrentIndex(0)
else:
model = self.filename_box.model()
for icon in self.rule_icon_files:
idx = self.filename_box.findText(icon)
if idx >= 0:
item = model.item(idx)
item.setCheckState(Qt.CheckState.Checked)
def remove_icon_file_dialog(self):
d = RemoveIconFileDialog(self, self.icon_file_names, self.icon_folder)
if d.exec() == QDialog.DialogCode.Accepted:
if len(d.files_to_remove) > 0:
for name in d.files_to_remove:
try:
os.remove(os.path.join(self.icon_folder, name))
except OSError:
pass
self.populate_icon_filenames()
self.update_filename_box()
self.update_icon_filenames_in_box()
def add_blank_condition(self):
c = ConditionEditor(self.fm, parent=self.conditions_widget)
self.conditions.append(c)
self.conditions_widget.layout().addWidget(c)
def apply_rule(self, kind, col, rule):
if kind == 'color':
if rule.color:
self.color_box.color = rule.color
else:
if self.rule_kind == 'icon':
for i, tup in enumerate(icon_rule_kinds):
if kind == tup[1]:
self.kind_box.setCurrentIndex(i)
break
self.rule_icon_files = [ic.strip() for ic in rule.color.split(':')]
if len(self.rule_icon_files) > 1:
self.multiple_icon_cb.setChecked(True)
self.update_filename_box()
self.update_icon_filenames_in_box()
for i in range(self.column_box.count()):
c = str(self.column_box.itemData(i) or '')
if col == c:
self.column_box.setCurrentIndex(i)
break
for c in rule.conditions:
ce = ConditionEditor(self.fm, parent=self.conditions_widget)
self.conditions.append(ce)
self.conditions_widget.layout().addWidget(ce)
try:
ce.condition = c
except:
import traceback
traceback.print_exc()
def accept(self):
if self.rule_kind != 'color':
fname = self.get_filenames_from_box()
if not fname:
error_dialog(self, _('No icon selected'),
_('You must choose an icon for this rule'), show=True)
return
if self.validate():
QDialog.accept(self)
def validate(self):
r = Rule(self.fm)
for c in self.conditions:
condition = c.condition
if condition is not None:
try:
r.add_condition(*condition)
except Exception as e:
import traceback
error_dialog(self, _('Invalid condition'),
_('One of the conditions for this rule is'
' invalid: <b>%s</b>')%e,
det_msg=traceback.format_exc(), show=True)
return False
if len(r.conditions) < 1:
error_dialog(self, _('No conditions'),
_('You must specify at least one non-empty condition'
' for this rule'), show=True)
return False
return True
@property
def rule(self):
r = Rule(self.fm)
if self.rule_kind != 'color':
r.color = self.get_filenames_from_box()
else:
r.color = self.color_box.color
idx = self.column_box.currentIndex()
col = str(self.column_box.itemData(idx) or '')
for c in self.conditions:
condition = c.condition
if condition is not None:
r.add_condition(*condition)
if self.rule_kind == 'icon':
kind = str(self.kind_box.itemData(
self.kind_box.currentIndex()) or '')
else:
kind = self.rule_kind
return kind, col, r
# }}}
class RulesModel(QAbstractListModel): # {{{
EXIM_VERSION = 1
def load_rule(self, col, template):
if col not in self.fm and col != color_row_key:
return
try:
rule = rule_from_template(self.fm, template)
except:
rule = template
return rule
def __init__(self, prefs, fm, pref_name, parent=None):
QAbstractListModel.__init__(self, parent)
self.fm = fm
self.pref_name = pref_name
if pref_name == 'column_color_rules':
self.rule_kind = 'color'
rules = list(prefs[pref_name])
self.rules = []
for col, template in rules:
rule = self.load_rule(col, template)
if rule is not None:
self.rules.append(('color', col, rule))
else:
self.rule_kind = 'icon' if pref_name == 'column_icon_rules' else 'emblem'
rules = list(prefs[pref_name])
self.rules = []
for kind, col, template in rules:
rule = self.load_rule(col, template)
if rule is not None:
self.rules.append((kind, col, rule))
def rowCount(self, *args):
return len(self.rules)
def data(self, index, role):
row = index.row()
try:
kind, col, rule = self.rules[row]
except:
return None
if role == Qt.ItemDataRole.DisplayRole:
if col == color_row_key:
col = all_columns_string
else:
col = self.fm[col]['name']
return self.rule_to_html(kind, col, rule)
if role == Qt.ItemDataRole.UserRole:
return (kind, col, rule)
def add_rule(self, kind, col, rule, selected_row=None):
self.beginResetModel()
if selected_row:
self.rules.insert(selected_row.row(), (kind, col, rule))
else:
self.rules.append((kind, col, rule))
self.endResetModel()
if selected_row:
return self.index(selected_row.row())
return self.index(len(self.rules)-1)
def replace_rule(self, index, kind, col, r):
self.rules[index.row()] = (kind, col, r)
self.dataChanged.emit(index, index)
def remove_rule(self, index):
self.beginResetModel()
self.rules.remove(self.rules[index.row()])
self.endResetModel()
def rules_as_list(self, for_export=False):
rules = []
for kind, col, r in self.rules:
if isinstance(r, Rule):
r = r.template
if r is not None:
if not for_export and kind == 'color':
rules.append((col, r))
else:
rules.append((kind, col, r))
return rules
def import_rules(self, rules):
self.beginResetModel()
for kind, col, template in rules:
if self.pref_name == 'column_color_rules':
kind = 'color'
rule = self.load_rule(col, template)
if rule is not None:
self.rules.append((kind, col, rule))
self.endResetModel()
def commit(self, prefs):
prefs[self.pref_name] = self.rules_as_list()
def move(self, idx, delta):
row = idx.row() + delta
if row >= 0 and row < len(self.rules):
self.beginResetModel()
t = self.rules.pop(row-delta)
self.rules.insert(row, t) # does append if row >= len(rules)
self.endResetModel()
idx = self.index(row)
return idx
def clear(self):
self.rules = []
self.beginResetModel()
self.endResetModel()
def rule_to_html(self, kind, col, rule):
trans_kind = 'not found'
if kind == 'color':
trans_kind = _('color')
else:
for tt, t in icon_rule_kinds:
if kind == t:
trans_kind = tt
break
if not isinstance(rule, Rule):
if kind == 'color':
return _('''
<p>Advanced rule for column <b>%(col)s</b>:
<pre>%(rule)s</pre>
''')%dict(col=col, rule=prepare_string_for_xml(rule))
elif self.rule_kind == 'emblem':
return _('''
<p>Advanced rule:
<pre>%(rule)s</pre>
''')%dict(rule=prepare_string_for_xml(rule))
else:
return _('''
<p>Advanced rule: set <b>%(typ)s</b> for column <b>%(col)s</b>:
<pre>%(rule)s</pre>
''')%dict(col=col,
typ=trans_kind,
rule=prepare_string_for_xml(rule))
conditions = [self.condition_to_html(c) for c in rule.conditions]
sample = '' if kind != 'color' else (
_('(<span style="color: %s;">sample</span>)') % rule.color)
if kind == 'emblem':
return _('<p>Add the emblem <b>{0}</b> to the cover if the following conditions are met:</p>'
'\n<ul>{1}</ul>').format(rule.color, ''.join(conditions))
return _('''\
<p>Set the <b>%(kind)s</b> of <b>%(col)s</b> to <b>%(color)s</b> %(sample)s
if the following conditions are met:</p>
<ul>%(rule)s</ul>
''') % dict(kind=trans_kind, col=col, color=rule.color,
sample=sample, rule=''.join(conditions))
def condition_to_html(self, condition):
col, a, v = condition
dt = self.fm[col]['datatype']
c = self.fm[col]['name']
action_name = a
if col in ConditionEditor.ACTION_MAP:
# look for a column-name-specific label
for trans, ac in ConditionEditor.ACTION_MAP[col]:
if ac == a:
action_name = trans
break
elif dt in ConditionEditor.ACTION_MAP:
# Look for a type-specific label
for trans, ac in ConditionEditor.ACTION_MAP[dt]:
if ac == a:
action_name = trans
break
else:
# Wasn't a type-specific or column-specific label. Look for a text-type
for dt in ['single', 'multiple']:
for trans, ac in ConditionEditor.ACTION_MAP[dt]:
if ac == a:
action_name = trans
break
else:
continue
break
if action_name == Rule.INVALID_CONDITION:
return (
_('<li>The condition using column <b>%(col)s</b> is <b>invalid</b>')
% dict(col=c))
return (
_('<li>If the <b>%(col)s</b> column <b>%(action)s</b> %(val_label)s<b>%(val)s</b>') % dict(
col=c, action=action_name, val=prepare_string_for_xml(v),
val_label=_('value: ') if v else ''))
# }}}
class RulesView(QListView): # {{{
def __init__(self, parent, enable_convert_buttons_function):
QListView.__init__(self, parent)
self.enable_convert_buttons_function = enable_convert_buttons_function
def currentChanged(self, new, prev):
if self.model() and new.isValid():
_, _, rule = self.model().data(new, Qt.ItemDataRole.UserRole)
self.enable_convert_buttons_function(isinstance(rule, Rule))
return super().currentChanged(new, prev)
# }}}
class EditRules(QWidget): # {{{
changed = pyqtSignal()
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QGridLayout(self)
self.setLayout(l)
self.enabled = c = QCheckBox(self)
l.addWidget(c, l.rowCount(), 0, 1, 2)
c.setVisible(False)
c.stateChanged.connect(self.changed)
self.l1 = l1 = QLabel('')
l1.setWordWrap(True)
l.addWidget(l1, l.rowCount(), 0, 1, 2)
self.add_button = QPushButton(QIcon(I('plus.png')), _('&Add rule'),
self)
self.remove_button = QPushButton(QIcon(I('minus.png')),
_('&Remove rule(s)'), self)
self.add_button.clicked.connect(self.add_rule)
self.remove_button.clicked.connect(self.remove_rule)
l.addWidget(self.add_button, l.rowCount(), 0)
l.addWidget(self.remove_button, l.rowCount() - 1, 1)
self.g = g = QGridLayout()
self.rules_view = RulesView(self, self.do_enable_convert_buttons)
self.rules_view.doubleClicked.connect(self.edit_rule)
self.rules_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.rules_view.setAlternatingRowColors(True)
self.rtfd = RichTextDelegate(parent=self.rules_view, max_width=400)
self.rules_view.setItemDelegate(self.rtfd)
g.addWidget(self.rules_view, 0, 0, 2, 1)
self.up_button = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-up.png')))
b.setToolTip(_('Move the selected rule up'))
b.clicked.connect(partial(self.move_rows, moving_up=True))
g.addWidget(b, 0, 1, 1, 1, Qt.AlignmentFlag.AlignTop)
self.down_button = b = QToolButton(self)
b.setIcon(QIcon(I('arrow-down.png')))
b.setToolTip(_('Move the selected rule down'))
b.clicked.connect(partial(self.move_rows, moving_up=False))
g.addWidget(b, 1, 1, 1, 1, Qt.AlignmentFlag.AlignBottom)
l.addLayout(g, l.rowCount(), 0, 1, 2)
l.setRowStretch(l.rowCount() - 1, 10)
self.add_advanced_button = b = QPushButton(QIcon(I('plus.png')),
_('Add ad&vanced rule'), self)
b.clicked.connect(self.add_advanced)
self.hb = hb = FlowLayout()
l.addLayout(hb, l.rowCount(), 0, 1, 2)
hb.addWidget(b)
self.duplicate_rule_button = b = QPushButton(QIcon(I('edit-copy.png')),
_('Du&plicate rule'), self)
b.clicked.connect(self.duplicate_rule)
b.setEnabled(False)
hb.addWidget(b)
self.convert_to_advanced_button = b = QPushButton(QIcon(I('modified.png')),
_('Convert to advanced r&ule'), self)
b.clicked.connect(self.convert_to_advanced)
b.setEnabled(False)
hb.addWidget(b)
sep = Separator(self, b)
hb.addWidget(sep)
self.open_icon_folder_button = b = QPushButton(QIcon(I('icon_choose.png')),
_('Open icon folder'), self)
b.clicked.connect(self.open_icon_folder)
hb.addWidget(b)
sep = Separator(self, b)
hb.addWidget(sep)
self.export_button = b = QPushButton(_('E&xport'), self)
b.clicked.connect(self.export_rules)
b.setToolTip(_('Export these rules to a file'))
hb.addWidget(b)
self.import_button = b = QPushButton(_('&Import'), self)
b.setToolTip(_('Import rules from a file'))
b.clicked.connect(self.import_rules)
hb.addWidget(b)
def open_icon_folder(self):
path = os.path.join(config_dir, 'cc_icons')
os.makedirs(path, exist_ok=True)
open_local_file(path)
def initialize(self, fm, prefs, mi, pref_name):
self.pref_name = pref_name
self.model = RulesModel(prefs, fm, self.pref_name)
self.rules_view.setModel(self.model)
self.fm = fm
self.mi = mi
if pref_name == 'column_color_rules':
text = _(
'You can control the color of columns in the'
' book list by creating "rules" that tell calibre'
' what color to use. Click the "Add rule" button below'
' to get started.<p>You can <b>change an existing rule</b> by'
' double clicking it.')
elif pref_name == 'column_icon_rules':
text = _(
'You can add icons to columns in the'
' book list by creating "rules" that tell calibre'
' what icon to use. Click the "Add rule" button below'
' to get started.<p>You can <b>change an existing rule</b> by'
' double clicking it.')
elif pref_name == 'cover_grid_icon_rules':
text = _('You can add emblems (small icons) that are displayed on the side of covers'
' in the Cover grid by creating "rules" that tell calibre'
' what image to use. Click the "Add rule" button below'
' to get started.<p>You can <b>change an existing rule</b> by'
' double clicking it.')
self.enabled.setVisible(True)
self.enabled.setChecked(gprefs['show_emblems'])
self.enabled.setText(_('Show &emblems next to the covers'))
self.enabled.stateChanged.connect(self.enabled_toggled)
self.enabled.setToolTip(_(
'If checked, you can tell calibre to display icons of your choosing'
' next to the covers shown in the Cover grid, controlled by the'
' metadata of the book.'))
self.enabled_toggled()
self.l1.setText('<p>'+ text)
def enabled_toggled(self):
enabled = self.enabled.isChecked()
for x in ('add_advanced_button', 'rules_view', 'up_button', 'down_button', 'add_button', 'remove_button'):
getattr(self, x).setEnabled(enabled)
def do_enable_convert_buttons(self, to_what):
self.convert_to_advanced_button.setEnabled(to_what)
self.duplicate_rule_button.setEnabled(True)
def convert_to_advanced(self):
sm = self.rules_view.selectionModel()
rows = list(sm.selectedRows())
if not rows or len(rows) != 1:
error_dialog(self, _('Select one rule'),
_('You must select only one rule.'), show=True)
return
idx = self.rules_view.currentIndex()
if idx.isValid():
kind, col, rule = self.model.data(idx, Qt.ItemDataRole.UserRole)
if isinstance(rule, Rule):
template = '\n'.join(
[l for l in rule.template.splitlines() if not l.startswith(Rule.SIGNATURE)])
orig_row = idx.row()
self.model.remove_rule(idx)
new_idx = self.model.add_rule(kind, col, template)
new_idx = self.model.move(new_idx, -(self.model.rowCount() - orig_row - 1))
self.rules_view.setCurrentIndex(new_idx)
self.changed.emit()
def duplicate_rule(self):
sm = self.rules_view.selectionModel()
rows = list(sm.selectedRows())
if not rows or len(rows) != 1:
error_dialog(self, _('Select one rule'),
_('You must select only one rule.'), show=True)
return
idx = self.rules_view.currentIndex()
if idx.isValid():
kind, col, rule = self.model.data(idx, Qt.ItemDataRole.UserRole)
orig_row = idx.row() + 1
new_idx = self.model.add_rule(kind, col, rule)
new_idx = self.model.move(new_idx, -(self.model.rowCount() - orig_row - 1))
self.rules_view.setCurrentIndex(new_idx)
self.changed.emit()
def add_rule(self):
d = RuleEditor(self.model.fm, self.pref_name)
d.add_blank_condition()
if d.exec() == QDialog.DialogCode.Accepted:
kind, col, r = d.rule
if kind and r and col:
selected_row = self.get_first_selected_row()
idx = self.model.add_rule(kind, col, r, selected_row=selected_row)
self.rules_view.scrollTo(idx)
self.changed.emit()
def add_advanced(self):
selected_row = self.get_first_selected_row()
if self.pref_name == 'column_color_rules':
td = TemplateDialog(self, '', mi=self.mi, fm=self.fm, color_field='')
if td.exec() == QDialog.DialogCode.Accepted:
col, r = td.rule
if r and col:
idx = self.model.add_rule('color', col, r, selected_row=selected_row)
self.rules_view.scrollTo(idx)
self.changed.emit()
else:
if self.pref_name == 'cover_grid_icon_rules':
td = TemplateDialog(self, '', mi=self.mi, fm=self.fm, doing_emblem=True)
else:
td = TemplateDialog(self, '', mi=self.mi, fm=self.fm, icon_field_key='')
if td.exec() == QDialog.DialogCode.Accepted:
typ, col, r = td.rule
if typ and r and col:
idx = self.model.add_rule(typ, col, r, selected_row=selected_row)
self.rules_view.scrollTo(idx)
self.changed.emit()
def edit_rule(self, index):
try:
kind, col, rule = self.model.data(index, Qt.ItemDataRole.UserRole)
except:
return
if isinstance(rule, Rule):
d = RuleEditor(self.model.fm, self.pref_name)
d.apply_rule(kind, col, rule)
elif self.pref_name == 'column_color_rules':
d = TemplateDialog(self, rule, mi=self.mi, fm=self.fm, color_field=col)
elif self.pref_name == 'cover_grid_icon_rules':
d = TemplateDialog(self, rule, mi=self.mi, fm=self.fm, doing_emblem=True)
else:
d = TemplateDialog(self, rule, mi=self.mi, fm=self.fm, icon_field_key=col,
icon_rule_kind=kind)
if d.exec() == QDialog.DialogCode.Accepted:
if len(d.rule) == 2: # Convert template dialog rules to a triple
d.rule = ('color', d.rule[0], d.rule[1])
kind, col, r = d.rule
if kind and r is not None and col:
self.model.replace_rule(index, kind, col, r)
self.rules_view.scrollTo(index)
self.changed.emit()
def get_first_selected_row(self):
r = self.get_selected_row('', show_error=False)
if r:
return r[-1]
return None
def get_selected_row(self, txt, show_error=True):
sm = self.rules_view.selectionModel()
rows = list(sm.selectedRows())
if not rows:
if show_error:
error_dialog(self, _('No rule selected'), _('No rule selected for %s.')%txt, show=True)
return None
return sorted(rows, reverse=True)
def remove_rule(self):
rows = self.get_selected_row(_('removal'))
if rows is not None:
for row in rows:
self.model.remove_rule(row)
self.changed.emit()
def move_rows(self, moving_up=True):
sm = self.rules_view.selectionModel()
rows = sorted(list(sm.selectedRows()), reverse=not moving_up)
if rows:
if rows[0].row() == (0 if moving_up else self.model.rowCount() - 1):
return
sm.clear()
indices_to_select = []
for idx in rows:
if idx.isValid():
idx = self.model.move(idx, -1 if moving_up else 1)
if idx is not None:
indices_to_select.append(idx)
if indices_to_select:
new_selections = QItemSelection()
for idx in indices_to_select:
new_selections.merge(QItemSelection(idx, idx),
QItemSelectionModel.SelectionFlag.Select)
sm.select(new_selections, QItemSelectionModel.SelectionFlag.Select)
self.rules_view.scrollTo(indices_to_select[0])
self.changed.emit()
def clear(self):
self.model.clear()
self.changed.emit()
def commit(self, prefs):
self.model.commit(prefs)
if self.pref_name == 'cover_grid_icon_rules':
gprefs['show_emblems'] = self.enabled.isChecked()
def export_rules(self):
path = choose_save_file(self, 'export-coloring-rules', _('Choose file to export to'),
filters=[(_('Rules'), ['rules'])], all_files=False, initial_filename=self.pref_name + '.rules')
if path:
rules = {
'version': self.model.EXIM_VERSION,
'type': self.model.pref_name,
'rules': self.model.rules_as_list(for_export=True)
}
data = json.dumps(rules, indent=2)
if not isinstance(data, bytes):
data = data.encode('utf-8')
with lopen(path, 'wb') as f:
f.write(data)
def import_rules(self):
files = choose_files(self, 'import-coloring-rules', _('Choose file to import from'),
filters=[(_('Rules'), ['rules'])], all_files=False, select_only_single_file=True)
if files:
with lopen(files[0], 'rb') as f:
raw = f.read()
try:
rules = json.loads(raw)
if rules['version'] != self.model.EXIM_VERSION:
raise ValueError('Unsupported rules version: {}'.format(rules['version']))
if rules['type'] != self.pref_name:
raise ValueError('Rules are not of the correct type')
rules = list(rules['rules'])
except Exception as e:
return error_dialog(self, _('No valid rules found'), _(
'No valid rules were found in {}.').format(files[0]), det_msg=as_unicode(e), show=True)
self.model.import_rules(rules)
self.changed.emit()
# }}}
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application([])
from calibre.library import db
db = db()
if False:
d = RuleEditor(db.field_metadata, 'column_icon_rules')
d.add_blank_condition()
d.exec()
kind, col, r = d.rule
print('Column to be colored:', col)
print('Template:')
print(r.template)
else:
d = EditRules()
d.resize(QSize(800, 600))
d.initialize(db.field_metadata, db.prefs, None, 'column_color_rules')
d.show()
app.exec()
d.commit(db.prefs)