| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /lib/calibre/calibre/gui2/ |
Upload File : |
#!/usr/bin/env python3
# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net>
import textwrap
from collections import OrderedDict
from qt.core import (
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QIcon,
QInputDialog, QItemSelectionModel, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMenu, QPalette, QPushButton, QSize, QStaticText, QStyle, QStyledItemDelegate, Qt,
QToolButton, QVBoxLayout, QWidget, pyqtSignal,
)
from calibre.ebooks.metadata.tag_mapper import compile_pat, map_tags
from calibre.gui2 import Application, error_dialog, question_dialog
from calibre.gui2.complete2 import EditWithComplete
from calibre.gui2.ui import get_gui
from calibre.gui2.widgets2 import Dialog
from calibre.startup import connect_lambda
from calibre.utils.config import JSONConfig
from calibre.utils.localization import localize_user_manual_link
from polyglot.builtins import iteritems
tag_maps = JSONConfig('tag-map-rules')
def intelligent_strip(action, val):
ans = val.strip()
if not ans and action == 'split':
ans = ' '
return ans
class QueryEdit(QLineEdit):
def contextMenuEvent(self, ev):
menu = self.createStandardContextMenu()
self.parent().specialise_context_menu(menu)
menu.exec(ev.globalPos())
class SingleTagEdit(EditWithComplete):
def __init__(self, parent):
super().__init__(parent)
self.set_separator(None)
gui = get_gui()
if gui:
self.update_items_cache(gui.current_db.new_api.all_field_names(getattr(parent, 'SINGLE_EDIT_FIELD_NAME', 'tags')))
class RuleEdit(QWidget):
ACTION_MAP = OrderedDict((
('remove', _('Remove')),
('replace', _('Replace')),
('keep', _('Keep')),
('capitalize', _('Capitalize')),
('titlecase', _('Title-case')),
('lower', _('Lower-case')),
('upper', _('Upper-case')),
('split', _('Split')),
))
MATCH_TYPE_MAP = OrderedDict((
('one_of', _('is one of')),
('not_one_of', _('is not one of')),
('matches', _('matches pattern')),
('not_matches', _('does not match pattern')),
('has', _('contains')),
))
MSG = _('Create the rule below, the rule can be used to remove or replace tags')
SUBJECT = _('the tag, if it')
VALUE_ERROR = _('You must provide a value for the tag to match')
REPLACE_TEXT = _('with the tag:')
SPLIT_TEXT = _('on the character:')
SPLIT_TOOLTIP = _(
'The character on which to split tags. Note that technically you can specify'
' a sub-string, not just a single character. Then splitting will happen on the sub-string.')
REPLACE_TOOLTIP = _(
'What to replace the tag with. Note that if you use a pattern to match'
' tags, you can replace with parts of the matched pattern. See '
' the User Manual on how to use regular expressions for details.')
REGEXP_HELP_TEXT = _('For help with regex pattern matching, see the <a href="%s">User Manual</a>')
SINGLE_EDIT_FIELD_NAME = 'tags'
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.h = h = QHBoxLayout()
self.la = la = QLabel(self.MSG)
la.setWordWrap(True)
l.addWidget(la)
l.addLayout(h)
self.action = a = QComboBox(self)
h.addWidget(a)
for action, text in iteritems(self.ACTION_MAP):
a.addItem(text, action)
a.currentIndexChanged.connect(self.update_state)
self.la1 = la = QLabel('\xa0' + self.SUBJECT + '\xa0')
h.addWidget(la)
self.match_type = q = QComboBox(self)
h.addWidget(q)
for action, text in iteritems(self.MATCH_TYPE_MAP):
q.addItem(text, action)
q.currentIndexChanged.connect(self.update_state)
self.la2 = la = QLabel(':\xa0')
h.addWidget(la)
self.query = q = QueryEdit(self)
h.addWidget(q)
self.tag_editor_button = b = QToolButton(self)
b.setIcon(QIcon.ic('chapters.png'))
b.setToolTip(_('Edit the list of tags with the Tag editor'))
h.addWidget(b), b.clicked.connect(self.edit_tags)
b.setVisible(self.can_use_tag_editor)
self.h2 = h = QHBoxLayout()
l.addLayout(h)
self.la3 = la = QLabel(self.REPLACE_TEXT + '\xa0')
h.addWidget(la)
self.replace = r = SingleTagEdit(self)
h.addWidget(r)
self.regex_help = la = QLabel('<p>' + self.REGEXP_HELP_TEXT % localize_user_manual_link(
'https://manual.calibre-ebook.com/regexp.html'))
la.setOpenExternalLinks(True)
la.setWordWrap(True)
l.addWidget(la)
la.setVisible(False)
l.addStretch(10)
self.la3.setVisible(False), self.replace.setVisible(False)
self.update_state()
def sizeHint(self):
a = QWidget.sizeHint(self)
a.setHeight(a.height() + 75)
a.setWidth(a.width() + 100)
return a
@property
def can_use_tag_editor(self):
return self.SUBJECT is RuleEdit.SUBJECT and 'matches' not in self.match_type.currentData() and get_gui() is not None
def update_state(self):
a = self.action.currentData()
replace = a == 'replace'
split = a == 'split'
self.la3.setVisible(replace or split), self.replace.setVisible(replace or split)
tt = _('A comma separated list of tags')
m = self.match_type.currentData()
is_match = 'matches' in m
self.tag_editor_button.setVisible(self.can_use_tag_editor)
if is_match:
tt = _('A regular expression')
elif m == 'has':
tt = _('Tags that contain this string will match')
self.regex_help.setVisible(is_match)
self.la3.setText((self.SPLIT_TEXT if split else self.REPLACE_TEXT) + '\xa0')
self.query.setToolTip(tt)
self.replace.setToolTip(textwrap.fill(self.SPLIT_TOOLTIP if split else self.REPLACE_TOOLTIP))
def specialise_context_menu(self, menu):
if self.can_use_tag_editor:
menu.addAction(_('Use the Tag editor to edit the list of tags'), self.edit_tags)
def edit_tags(self):
from calibre.gui2.dialogs.tag_editor import TagEditor
d = TagEditor(self, get_gui().current_db, current_tags=list(filter(None, [x.strip() for x in self.query.text().split(',')])))
if d.exec() == QDialog.DialogCode.Accepted:
self.query.setText(', '.join(d.tags))
@property
def rule(self):
ac = self.action.currentData()
return {
'action': ac,
'match_type': self.match_type.currentData(),
'query': intelligent_strip(ac, self.query.text()),
'replace': intelligent_strip(ac, self.replace.text()),
}
@rule.setter
def rule(self, rule):
def sc(name):
c = getattr(self, name)
idx = c.findData(str(rule.get(name, '')))
if idx < 0:
idx = 0
c.setCurrentIndex(idx)
sc('action'), sc('match_type')
ac = self.action.currentData()
self.query.setText(intelligent_strip(ac, str(rule.get('query', ''))))
self.replace.setText(intelligent_strip(ac, str(rule.get('replace', ''))))
def validate(self):
rule = self.rule
if not rule['query']:
error_dialog(self, _('Query required'), self.VALUE_ERROR, show=True)
return False
if 'matches' in rule['match_type']:
try:
compile_pat(rule['query'])
except Exception:
error_dialog(self, _('Query invalid'), _(
'%s is not a valid regular expression') % rule['query'], show=True)
return False
return True
class RuleEditDialog(Dialog):
PREFS_NAME = 'edit-tag-mapper-rule'
DIALOG_TITLE = _('Edit rule')
RuleEditClass = RuleEdit
def __init__(self, parent=None):
Dialog.__init__(self, self.DIALOG_TITLE, self.PREFS_NAME, parent=None)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.edit_widget = w = self.RuleEditClass(self)
l.addWidget(w)
l.addWidget(self.bb)
def accept(self):
if self.edit_widget.validate():
Dialog.accept(self)
DATA_ROLE = Qt.ItemDataRole.UserRole
RENDER_ROLE = DATA_ROLE + 1
class RuleItem(QListWidgetItem):
@staticmethod
def text_from_rule(rule, parent):
query = rule['query']
text = _(
'<b>{action}</b> the tag, if it <i>{match_type}</i>: <b>{query}</b>').format(
action=RuleEdit.ACTION_MAP[rule['action']], match_type=RuleEdit.MATCH_TYPE_MAP[rule['match_type']], query=query)
if rule['action'] == 'replace':
text += '<br>' + _('with the tag:') + ' <b>%s</b>' % rule['replace']
if rule['action'] == 'split':
text += '<br>' + _('on the character:') + ' <b>%s</b>' % rule['replace']
return '<div style="white-space: nowrap">' + text + '</div>'
def __init__(self, rule, parent):
QListWidgetItem.__init__(self, '', parent)
st = self.text_from_rule(rule, parent)
self.setData(RENDER_ROLE, st)
self.setData(DATA_ROLE, rule)
class Delegate(QStyledItemDelegate):
MARGIN = 16
def sizeHint(self, option, index):
st = QStaticText(index.data(RENDER_ROLE))
st.prepare(font=self.parent().font())
width = max(option.rect.width(), self.parent().width() - 50)
if width and width != st.textWidth():
st.setTextWidth(width)
br = st.size()
return QSize(int(br.width() + self.MARGIN), int(br.height() + self.MARGIN))
def paint(self, painter, option, index):
QStyledItemDelegate.paint(self, painter, option, index)
pal = option.palette
color = pal.color(QPalette.ColorRole.HighlightedText if option.state & QStyle.StateFlag.State_Selected else QPalette.ColorRole.Text).name()
text = f'<div style="color:{color}">{index.data(RENDER_ROLE)}</div>'
st = QStaticText(text)
st.setTextWidth(option.rect.width())
painter.drawStaticText(option.rect.left() + self.MARGIN // 2, option.rect.top() + self.MARGIN // 2, st)
class Rules(QWidget):
RuleItemClass = RuleItem
RuleEditDialogClass = RuleEditDialog
changed = pyqtSignal()
ACTION_KEY = 'action'
MSG = _('You can specify rules to filter/transform tags here. Click the "Add rule" button'
' below to get started. The rules will be processed in order for every tag until either a'
' "remove" or a "keep" rule matches.')
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QVBoxLayout(self)
self.msg_label = la = QLabel(
'<p>' + self.MSG + '<p>' + _(
'You can <b>change an existing rule</b> by double clicking it')
)
la.setWordWrap(True)
l.addWidget(la)
self.h = h = QHBoxLayout()
l.addLayout(h)
self.add_button = b = QPushButton(QIcon.ic('plus.png'), _('&Add rule'), self)
b.clicked.connect(self.add_rule)
h.addWidget(b)
self.remove_button = b = QPushButton(QIcon.ic('minus.png'), _('&Remove rule(s)'), self)
b.clicked.connect(self.remove_rules)
h.addWidget(b)
self.h3 = h = QHBoxLayout()
l.addLayout(h)
self.rule_list = r = QListWidget(self)
self.delegate = Delegate(self)
r.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
r.setItemDelegate(self.delegate)
r.doubleClicked.connect(self.edit_rule)
h.addWidget(r)
r.setDragEnabled(True)
r.viewport().setAcceptDrops(True)
r.setDropIndicatorShown(True)
r.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
r.setDefaultDropAction(Qt.DropAction.MoveAction)
self.l2 = l = QVBoxLayout()
h.addLayout(l)
self.up_button = b = QToolButton(self)
b.setIcon(QIcon.ic('arrow-up.png')), b.setToolTip(_('Move current rule up'))
b.clicked.connect(self.move_up)
l.addWidget(b)
self.down_button = b = QToolButton(self)
b.setIcon(QIcon.ic('arrow-down.png')), b.setToolTip(_('Move current rule down'))
b.clicked.connect(self.move_down)
l.addStretch(10), l.addWidget(b)
def sizeHint(self):
return QSize(800, 600)
def add_rule(self):
d = self.RuleEditDialogClass(self)
if d.exec() == QDialog.DialogCode.Accepted:
i = self.RuleItemClass(d.edit_widget.rule, self.rule_list)
self.rule_list.scrollToItem(i)
self.changed.emit()
def edit_rule(self):
i = self.rule_list.currentItem()
if i is not None:
d = self.RuleEditDialogClass(self)
d.edit_widget.rule = i.data(Qt.ItemDataRole.UserRole)
if d.exec() == QDialog.DialogCode.Accepted:
rule = d.edit_widget.rule
i.setData(DATA_ROLE, rule)
i.setData(RENDER_ROLE, self.RuleItemClass.text_from_rule(rule, self.rule_list))
self.changed.emit()
def remove_rules(self):
changed = False
for item in self.rule_list.selectedItems():
self.rule_list.takeItem(self.rule_list.row(item))
changed = True
if changed:
self.changed.emit()
def move_selected(self, delta=-1):
current_item = self.rule_list.currentItem()
items = self.rule_list.selectedItems()
if current_item is None or not items or not len(items):
return
row_map = {id(item): self.rule_list.row(item) for item in items}
items.sort(key=lambda item: row_map[id(item)])
num = self.rule_list.count()
for item in items:
row = row_map[id(item)]
nrow = (row + delta + num) % num
self.rule_list.takeItem(row)
self.rule_list.insertItem(nrow, item)
sm = self.rule_list.selectionModel()
for item in items:
sm.select(self.rule_list.indexFromItem(item), QItemSelectionModel.SelectionFlag.Select)
sm.setCurrentIndex(self.rule_list.indexFromItem(current_item), QItemSelectionModel.SelectionFlag.Current)
self.changed.emit()
def move_up(self):
self.move_selected()
def move_down(self):
self.move_selected(1)
@property
def rules(self):
ans = []
for r in range(self.rule_list.count()):
ans.append(self.rule_list.item(r).data(DATA_ROLE))
return ans
@rules.setter
def rules(self, rules):
self.rule_list.clear()
for rule in (rules or ()):
if self.ACTION_KEY in rule and 'match_type' in rule and 'query' in rule:
self.RuleItemClass(rule, self.rule_list)
class Tester(Dialog):
DIALOG_TITLE = _('Test tag mapper rules')
PREFS_NAME = 'test-tag-mapper-rules'
LABEL = _('Enter a comma separated list of &tags to test:')
PLACEHOLDER = _('Enter tags and click the "Test" button')
EMPTY_RESULT = '<p> <br> </p>'
def __init__(self, rules, parent=None):
self.rules = rules
Dialog.__init__(self, self.DIALOG_TITLE, self.PREFS_NAME, parent=parent)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Close)
self.la = la = QLabel(self.LABEL)
l.addWidget(la)
self.tags = t = QLineEdit(self)
la.setBuddy(t)
t.setPlaceholderText(self.PLACEHOLDER)
self.h = h = QHBoxLayout()
l.addLayout(h)
h.addWidget(t)
self.test_button = b = QPushButton(_('&Test'), self)
b.clicked.connect(self.do_test)
h.addWidget(b)
self.result = la = QLabel(self)
la.setWordWrap(True)
la.setText(self.EMPTY_RESULT)
l.addWidget(la)
l.addWidget(self.bb)
@property
def value(self):
return self.tags.text()
def do_test(self):
tags = [x.strip() for x in self.value.split(',')]
tags = map_tags(tags, self.rules)
self.result.setText(_('<b>Resulting tags:</b> %s') % ', '.join(tags))
def sizeHint(self):
ans = Dialog.sizeHint(self)
ans.setWidth(ans.width() + 150)
return ans
class SaveLoadMixin:
def save_ruleset(self):
if not self.rules:
error_dialog(self, _('No rules'), _(
'Cannot save as no rules have been created'), show=True)
return
text, ok = QInputDialog.getText(self, _('Save ruleset as'), _(
'Enter a name for this ruleset:'), text=self.loaded_ruleset or '')
if ok and text:
if self.loaded_ruleset and text == self.loaded_ruleset:
if not question_dialog(self, _('Are you sure?'), _(
'A ruleset with the name "%s" already exists, do you want to replace it?') % text):
return
self.loaded_ruleset = text
rules = self.rules
if rules:
self.PREFS_OBJECT[text] = self.rules
elif text in self.PREFS_OBJECT:
del self.PREFS_OBJECT[text]
self.build_load_menu()
def build_load_menu(self):
self.load_menu.clear()
if len(self.PREFS_OBJECT):
for name, rules in iteritems(self.PREFS_OBJECT):
ac = self.load_menu.addAction(name)
ac.setObjectName(name)
connect_lambda(ac.triggered, self, lambda self: self.load_ruleset(self.sender().objectName()))
self.load_menu.addSeparator()
m = self.load_menu.addMenu(_('Delete saved rulesets'))
for name, rules in iteritems(self.PREFS_OBJECT):
ac = m.addAction(name)
ac.setObjectName(name)
connect_lambda(ac.triggered, self, lambda self: self.delete_ruleset(self.sender().objectName()))
else:
self.load_menu.addAction(_('No saved rulesets available'))
def load_ruleset(self, name):
self.rules = self.PREFS_OBJECT[name]
self.loaded_ruleset = name
def delete_ruleset(self, name):
del self.PREFS_OBJECT[name]
self.build_load_menu()
class RulesDialog(Dialog, SaveLoadMixin):
DIALOG_TITLE = _('Edit tag mapper rules')
PREFS_NAME = 'edit-tag-mapper-rules'
RulesClass = Rules
TesterClass = Tester
PREFS_OBJECT = tag_maps
def __init__(self, parent=None):
self.loaded_ruleset = None
Dialog.__init__(self, self.DIALOG_TITLE, self.PREFS_NAME, parent=parent)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.edit_widget = w = self.RulesClass(self)
l.addWidget(w)
ebw = self.extra_bottom_widget()
if ebw is None:
l.addWidget(self.bb)
else:
self.h = h = QHBoxLayout()
l.addLayout(h)
h.addWidget(ebw), h.addStretch(10), h.addWidget(self.bb)
self.save_button = b = self.bb.addButton(_('&Save'), QDialogButtonBox.ButtonRole.ActionRole)
b.setToolTip(_('Save this ruleset for later re-use'))
b.clicked.connect(self.save_ruleset)
self.load_button = b = self.bb.addButton(_('&Load'), QDialogButtonBox.ButtonRole.ActionRole)
b.setToolTip(_('Load a previously saved ruleset'))
self.load_menu = QMenu(self)
b.setMenu(self.load_menu)
self.build_load_menu()
self.test_button = b = self.bb.addButton(_('&Test rules'), QDialogButtonBox.ButtonRole.ActionRole)
b.clicked.connect(self.test_rules)
def extra_bottom_widget(self):
pass
@property
def rules(self):
return self.edit_widget.rules
@rules.setter
def rules(self, rules):
self.edit_widget.rules = rules
def test_rules(self):
self.TesterClass(self.rules, self).exec()
def sizeHint(self):
ans = super().sizeHint()
ans.setWidth(ans.width() + 100)
return ans
if __name__ == '__main__':
app = Application([])
d = RulesDialog()
d.rules = [
{'action':'remove', 'query':'moose', 'match_type':'one_of', 'replace':''},
{'action':'replace', 'query':'moose,sfdg,sfdg,dfsg,dfgsh,sd,er,erg,egrer,ger,s,fgfsgfsga', 'match_type':'one_of', 'replace':'xxxx'},
{'action':'split', 'query':'/', 'match_type':'has', 'replace':'/'},
]
d.exec()
from pprint import pprint
pprint(d.rules)
del d, app