Allow creating rules to transform series names in the Bulk metadata editor and Preferences->Metadata download. Fixes #2091268 [[Enhancement] Series Mapper](https://bugs.launchpad.net/calibre/+bug/2091268)

This commit is contained in:
Kovid Goyal 2025-01-07 13:32:11 +05:30
parent 94395c3e3c
commit 925bd06a1b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 218 additions and 7 deletions

View File

@ -502,7 +502,8 @@ def identify(log, abort, # {{{
(len(results), time.time() - start_time)) (len(results), time.time() - start_time))
tm_rules = msprefs['tag_map_rules'] tm_rules = msprefs['tag_map_rules']
pm_rules = msprefs['publisher_map_rules'] pm_rules = msprefs['publisher_map_rules']
if tm_rules or pm_rules: s_rules = msprefs['series_map_rules']
if tm_rules or pm_rules or s_rules:
from calibre.ebooks.metadata.tag_mapper import map_tags from calibre.ebooks.metadata.tag_mapper import map_tags
am_rules = msprefs['author_map_rules'] am_rules = msprefs['author_map_rules']
if am_rules: if am_rules:
@ -535,6 +536,9 @@ def identify(log, abort, # {{{
if pm_rules and r.publisher: if pm_rules and r.publisher:
pubs = map_tags([r.publisher], pm_rules) pubs = map_tags([r.publisher], pm_rules)
r.publisher = pubs[0] if pubs else '' r.publisher = pubs[0] if pubs else ''
if s_rules and r.series:
ss = map_tags([r.series], s_rules)
r.series = ss[0] if ss else ''
if msprefs['swap_author_names']: if msprefs['swap_author_names']:
for r in results: for r in results:

View File

@ -21,6 +21,7 @@ msprefs.defaults['append_comments'] = False
msprefs.defaults['tag_map_rules'] = () msprefs.defaults['tag_map_rules'] = ()
msprefs.defaults['author_map_rules'] = () msprefs.defaults['author_map_rules'] = ()
msprefs.defaults['publisher_map_rules'] = () msprefs.defaults['publisher_map_rules'] = ()
msprefs.defaults['series_map_rules'] = ()
msprefs.defaults['id_link_rules'] = {} msprefs.defaults['id_link_rules'] = {}
msprefs.defaults['keep_dups'] = False msprefs.defaults['keep_dups'] = False

View File

@ -54,7 +54,7 @@ Settings = namedtuple('Settings',
'do_swap_ta do_remove_conv do_auto_author series do_series_restart series_start_value series_increment ' 'do_swap_ta do_remove_conv do_auto_author series do_series_restart series_start_value series_increment '
'do_title_case cover_action clear_series clear_pub pubdate adddate do_title_sort languages clear_languages ' 'do_title_case cover_action clear_series clear_pub pubdate adddate do_title_sort languages clear_languages '
'restore_original comments generate_cover_settings read_file_metadata casing_algorithm do_compress_cover compress_cover_quality ' 'restore_original comments generate_cover_settings read_file_metadata casing_algorithm do_compress_cover compress_cover_quality '
'tag_map_rules author_map_rules publisher_map_rules' 'tag_map_rules author_map_rules publisher_map_rules series_map_rules'
) )
null = object() null = object()
@ -111,6 +111,8 @@ class MyBlockingBusy(QDialog): # {{{
self.selected_options += 1 self.selected_options += 1
if args.publisher_map_rules: if args.publisher_map_rules:
self.selected_options += 1 self.selected_options += 1
if args.series_map_rules:
self.selected_options += 1
if DEBUG: if DEBUG:
print("Number of steps for bulk metadata: %d" % self.selected_options) print("Number of steps for bulk metadata: %d" % self.selected_options)
print("Optionslist: ") print("Optionslist: ")
@ -425,6 +427,19 @@ class MyBlockingBusy(QDialog): # {{{
cache.set_field('publisher', changed) cache.set_field('publisher', changed)
self.progress_finished_cur_step.emit() self.progress_finished_cur_step.emit()
if args.series_map_rules:
self.progress_next_step_range.emit(0)
from calibre.ebooks.metadata.tag_mapper import map_tags
series_map = cache.all_field_for('series', self.ids)
changed = {}
for book_id, series in series_map.items():
new_series = map_tags([series], args.series_map_rules)
new_series = new_series[0] if new_series else ''
if new_series != series:
changed[book_id] = new_series
cache.set_field('series', changed)
self.progress_finished_cur_step.emit()
if args.clear_series: if args.clear_series:
self.progress_next_step_range.emit(0) self.progress_next_step_range.emit(0)
cache.set_field('series', {bid: '' for bid in self.ids}) cache.set_field('series', {bid: '' for bid in self.ids})
@ -643,7 +658,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.button_transform_tags.clicked.connect(self.transform_tags) self.button_transform_tags.clicked.connect(self.transform_tags)
self.button_transform_authors.clicked.connect(self.transform_authors) self.button_transform_authors.clicked.connect(self.transform_authors)
self.button_transform_publishers.clicked.connect(self.transform_publishers) self.button_transform_publishers.clicked.connect(self.transform_publishers)
self.tag_map_rules = self.author_map_rules = self.publisher_map_rules = () self.button_transform_series.clicked.connect(self.transform_series)
self.tag_map_rules = self.author_map_rules = self.publisher_map_rules = self.series_map_rules = ()
tuple(map(lambda b: (b.clicked.connect(self.clear_transform_rules_for), b.setIcon(QIcon.ic('clear_left.png')), b.setToolTip(_( tuple(map(lambda b: (b.clicked.connect(self.clear_transform_rules_for), b.setIcon(QIcon.ic('clear_left.png')), b.setToolTip(_(
'Clear the rules'))), 'Clear the rules'))),
(self.button_clear_tags_rules, self.button_clear_authors_rules, self.button_clear_publishers_rules) (self.button_clear_tags_rules, self.button_clear_authors_rules, self.button_clear_publishers_rules)
@ -663,9 +679,11 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
f(self.label_transform_tags, len(self.tag_map_rules)) f(self.label_transform_tags, len(self.tag_map_rules))
f(self.label_transform_authors, len(self.author_map_rules)) f(self.label_transform_authors, len(self.author_map_rules))
f(self.label_transform_publishers, len(self.publisher_map_rules)) f(self.label_transform_publishers, len(self.publisher_map_rules))
f(self.label_transform_series, len(self.series_map_rules))
self.button_clear_tags_rules.setVisible(bool(self.tag_map_rules)) self.button_clear_tags_rules.setVisible(bool(self.tag_map_rules))
self.button_clear_authors_rules.setVisible(bool(self.author_map_rules)) self.button_clear_authors_rules.setVisible(bool(self.author_map_rules))
self.button_clear_publishers_rules.setVisible(bool(self.publisher_map_rules)) self.button_clear_publishers_rules.setVisible(bool(self.publisher_map_rules))
self.button_clear_series_rules.setVisible(bool(self.series_map_rules))
def clear_transform_rules_for(self): def clear_transform_rules_for(self):
n = self.sender().objectName() n = self.sender().objectName()
@ -675,6 +693,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.author_map_rules = () self.author_map_rules = ()
elif 'publisher' in n: elif 'publisher' in n:
self.publisher_map_rules = () self.publisher_map_rules = ()
elif 'series' in n:
self.series_map_rules = ()
self.update_transform_labels() self.update_transform_labels()
def _change_transform_rules(self, RulesDialog, which): def _change_transform_rules(self, RulesDialog, which):
@ -700,6 +720,10 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
from calibre.gui2.publisher_mapper import RulesDialog from calibre.gui2.publisher_mapper import RulesDialog
self._change_transform_rules(RulesDialog, 'publisher') self._change_transform_rules(RulesDialog, 'publisher')
def transform_series(self):
from calibre.gui2.series_mapper import RulesDialog
self._change_transform_rules(RulesDialog, 'series')
def sizeHint(self): def sizeHint(self):
geom = self.screen().availableSize() geom = self.screen().availableSize()
nh, nw = max(300, geom.height()-50), max(400, geom.width()-70) nh, nw = max(300, geom.height()-50), max(400, geom.width()-70)
@ -1378,7 +1402,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
restore_original, self.comments, self.generate_cover_settings, restore_original, self.comments, self.generate_cover_settings,
read_file_metadata, self.casing_map[self.casing_algorithm.currentIndex()], read_file_metadata, self.casing_map[self.casing_algorithm.currentIndex()],
do_compress_cover, compress_cover_quality, self.tag_map_rules, self.author_map_rules, do_compress_cover, compress_cover_quality, self.tag_map_rules, self.author_map_rules,
self.publisher_map_rules self.publisher_map_rules, self.series_map_rules,
) )
if DEBUG: if DEBUG:
print('Running bulk metadata operation with settings:') print('Running bulk metadata operation with settings:')

View File

@ -783,7 +783,7 @@ as that of the first selected book.</string>
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>729</width> <width>719</width>
<height>429</height> <height>429</height>
</rect> </rect>
</property> </property>
@ -1271,7 +1271,7 @@ not multiple and the destination field is multiple</string>
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>187</width> <width>687</width>
<height>72</height> <height>72</height>
</rect> </rect>
</property> </property>
@ -1443,6 +1443,40 @@ not multiple and the destination field is multiple</string>
</item> </item>
</layout> </layout>
</item> </item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_12">
<item>
<widget class="QPushButton" name="button_transform_series">
<property name="text">
<string>Transform &amp;series</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_transform_series"/>
</item>
<item>
<widget class="QToolButton" name="button_clear_series_rules">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_10">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item> <item>
<spacer name="verticalSpacer_4"> <spacer name="verticalSpacer_4">
<property name="orientation"> <property name="orientation">

View File

@ -342,11 +342,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.select_default_button.clicked.connect(self.fields_model.select_user_defaults) self.select_default_button.clicked.connect(self.fields_model.select_user_defaults)
self.select_default_button.clicked.connect(self.changed_signal) self.select_default_button.clicked.connect(self.changed_signal)
self.set_as_default_button.clicked.connect(self.fields_model.commit_user_defaults) self.set_as_default_button.clicked.connect(self.fields_model.commit_user_defaults)
self.tag_map_rules = self.author_map_rules = self.publisher_map_rules = None self.tag_map_rules = self.author_map_rules = self.publisher_map_rules = self.series_map_rules = None
m = QMenu(self) m = QMenu(self)
m.addAction(_('Tags')).triggered.connect(self.change_tag_map_rules) m.addAction(_('Tags')).triggered.connect(self.change_tag_map_rules)
m.addAction(_('Authors')).triggered.connect(self.change_author_map_rules) m.addAction(_('Authors')).triggered.connect(self.change_author_map_rules)
m.addAction(_('Publisher')).triggered.connect(self.change_publisher_map_rules) m.addAction(_('Publisher')).triggered.connect(self.change_publisher_map_rules)
m.addAction(_('Series')).triggered.connect(self.change_series_map_rules)
self.map_rules_button.setMenu(m) self.map_rules_button.setMenu(m)
l = self.page.layout() l = self.page.layout()
l.setStretch(0, 1) l.setStretch(0, 1)
@ -401,6 +402,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.publisher_map_rules = d.rules self.publisher_map_rules = d.rules
self.changed_signal.emit() self.changed_signal.emit()
def change_series_map_rules(self):
from calibre.gui2.series_mapper import RulesDialog
d = RulesDialog(self)
if msprefs.get('series_map_rules'):
d.rules = list(msprefs['series_map_rules'])
if d.exec() == QDialog.DialogCode.Accepted:
self.series_map_rules = d.rules
self.changed_signal.emit()
def change_author_map_rules(self): def change_author_map_rules(self):
from calibre.gui2.author_mapper import RulesDialog from calibre.gui2.author_mapper import RulesDialog
d = RulesDialog(self) d = RulesDialog(self)

View File

@ -0,0 +1,138 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
from collections import OrderedDict
from calibre.ebooks.metadata.tag_mapper import map_tags
from calibre.gui2 import Application, elided_text
from calibre.gui2.tag_mapper import RuleEdit as RuleEditBase
from calibre.gui2.tag_mapper import RuleEditDialog as RuleEditDialogBase
from calibre.gui2.tag_mapper import RuleItem as RuleItemBase
from calibre.gui2.tag_mapper import Rules as RulesBase
from calibre.gui2.tag_mapper import RulesDialog as RulesDialogBase
from calibre.gui2.tag_mapper import Tester as TesterBase
from calibre.utils.config import JSONConfig
series_maps = JSONConfig('series-mapping-rules')
class RuleEdit(RuleEditBase):
ACTION_MAP = OrderedDict((
('replace', _('Change')),
('capitalize', _('Capitalize')),
('titlecase', _('Title-case')),
('lower', _('Lower-case')),
('upper', _('Upper-case')),
))
MATCH_TYPE_MAP = OrderedDict((
('one_of', _('is one of')),
('not_one_of', _('is not one of')),
('has', _('contains')),
('matches', _('matches regex pattern')),
('not_matches', _('does not match regex pattern')),
))
MSG = _('Create the rule below, the rule can be used to modify series')
SUBJECT = _('the series, if the series name')
VALUE_ERROR = _('You must provide a value for the series name to match')
REPLACE_TEXT = _('with the name:')
SINGLE_EDIT_FIELD_NAME = 'series'
@property
def can_use_tag_editor(self):
return False
def update_state(self):
a = self.action.currentData()
replace = a == 'replace'
self.la3.setVisible(replace), self.replace.setVisible(replace)
m = self.match_type.currentData()
is_match = 'matches' in m
self.regex_help.setVisible(is_match)
@property
def rule(self):
return {
'action': self.action.currentData(),
'match_type': self.match_type.currentData(),
'query': self.query.text().strip(),
'replace': self.replace.text().strip(),
}
@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('match_type'), sc('action')
self.query.setText(str(rule.get('query', '')).strip())
self.replace.setText(str(rule.get('replace', '')).strip())
class RuleEditDialog(RuleEditDialogBase):
PREFS_NAME = 'edit-series-mapping-rule'
RuleEditClass = RuleEdit
class RuleItem(RuleItemBase):
@staticmethod
def text_from_rule(rule, parent):
query = elided_text(rule['query'], font=parent.font(), width=200, pos='right')
text = _(
'<b>{action}</b> the series name, 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>' + _('to the name') + ' <b>%s</b>' % rule['replace']
return '<div style="white-space: nowrap">' + text + '</div>'
class Rules(RulesBase):
RuleItemClass = RuleItem
RuleEditDialogClass = RuleEditDialog
MSG = _('You can specify rules to manipulate series names here.'
' Click the "Add Rule" button'
' below to get started. The rules will be processed in order for every series.')
class Tester(TesterBase):
DIALOG_TITLE = _('Test series mapping rules')
PREFS_NAME = 'test-series-mapping-rules'
LABEL = _('Enter a series name to test:')
PLACEHOLDER = _('Enter series and click the "Test" button')
EMPTY_RESULT = '<p>&nbsp;</p>'
def do_test(self):
series = self.value.strip()
ans = map_tags([series], self.rules)
self.result.setText((ans or ('',))[0])
class RulesDialog(RulesDialogBase):
DIALOG_TITLE = _('Edit series mapping rules')
PREFS_NAME = 'edit-series-mapping-rules'
RulesClass = Rules
TesterClass = Tester
PREFS_OBJECT = series_maps
if __name__ == '__main__':
app = Application([])
d = RulesDialog()
d.rules = [
{'action':'replace', 'query':'alice Bob', 'match_type':'one_of', 'replace':'Alice Bob'},
]
d.exec()
from pprint import pprint
pprint(d.rules)
del d, app