diff --git a/src/calibre/ebooks/metadata/sources/identify.py b/src/calibre/ebooks/metadata/sources/identify.py
index 99f82daf11..f69eaa4454 100644
--- a/src/calibre/ebooks/metadata/sources/identify.py
+++ b/src/calibre/ebooks/metadata/sources/identify.py
@@ -502,7 +502,8 @@ def identify(log, abort, # {{{
(len(results), time.time() - start_time))
tm_rules = msprefs['tag_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
am_rules = msprefs['author_map_rules']
if am_rules:
@@ -535,6 +536,9 @@ def identify(log, abort, # {{{
if pm_rules and r.publisher:
pubs = map_tags([r.publisher], pm_rules)
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']:
for r in results:
diff --git a/src/calibre/ebooks/metadata/sources/prefs.py b/src/calibre/ebooks/metadata/sources/prefs.py
index 956494ef34..c8485ffe33 100644
--- a/src/calibre/ebooks/metadata/sources/prefs.py
+++ b/src/calibre/ebooks/metadata/sources/prefs.py
@@ -21,6 +21,7 @@ msprefs.defaults['append_comments'] = False
msprefs.defaults['tag_map_rules'] = ()
msprefs.defaults['author_map_rules'] = ()
msprefs.defaults['publisher_map_rules'] = ()
+msprefs.defaults['series_map_rules'] = ()
msprefs.defaults['id_link_rules'] = {}
msprefs.defaults['keep_dups'] = False
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index c8e80234d9..86acfda56e 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -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_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 '
- 'tag_map_rules author_map_rules publisher_map_rules'
+ 'tag_map_rules author_map_rules publisher_map_rules series_map_rules'
)
null = object()
@@ -111,6 +111,8 @@ class MyBlockingBusy(QDialog): # {{{
self.selected_options += 1
if args.publisher_map_rules:
self.selected_options += 1
+ if args.series_map_rules:
+ self.selected_options += 1
if DEBUG:
print("Number of steps for bulk metadata: %d" % self.selected_options)
print("Optionslist: ")
@@ -425,6 +427,19 @@ class MyBlockingBusy(QDialog): # {{{
cache.set_field('publisher', changed)
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:
self.progress_next_step_range.emit(0)
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_authors.clicked.connect(self.transform_authors)
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(_(
'Clear the 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_authors, len(self.author_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_authors_rules.setVisible(bool(self.author_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):
n = self.sender().objectName()
@@ -675,6 +693,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.author_map_rules = ()
elif 'publisher' in n:
self.publisher_map_rules = ()
+ elif 'series' in n:
+ self.series_map_rules = ()
self.update_transform_labels()
def _change_transform_rules(self, RulesDialog, which):
@@ -700,6 +720,10 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
from calibre.gui2.publisher_mapper import RulesDialog
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):
geom = self.screen().availableSize()
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,
read_file_metadata, self.casing_map[self.casing_algorithm.currentIndex()],
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:
print('Running bulk metadata operation with settings:')
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui
index 324e4ea178..43c69c03d1 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.ui
+++ b/src/calibre/gui2/dialogs/metadata_bulk.ui
@@ -783,7 +783,7 @@ as that of the first selected book.
0
0
- 729
+ 719
429
@@ -1271,7 +1271,7 @@ not multiple and the destination field is multiple
0
0
- 187
+ 687
72
@@ -1443,6 +1443,40 @@ not multiple and the destination field is multiple
+ -
+
+
-
+
+
+ Transform &series
+
+
+
+ -
+
+
+ -
+
+
+ ...
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
-
diff --git a/src/calibre/gui2/preferences/metadata_sources.py b/src/calibre/gui2/preferences/metadata_sources.py
index 0ed73a71f1..56cf5df01f 100644
--- a/src/calibre/gui2/preferences/metadata_sources.py
+++ b/src/calibre/gui2/preferences/metadata_sources.py
@@ -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.changed_signal)
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.addAction(_('Tags')).triggered.connect(self.change_tag_map_rules)
m.addAction(_('Authors')).triggered.connect(self.change_author_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)
l = self.page.layout()
l.setStretch(0, 1)
@@ -401,6 +402,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.publisher_map_rules = d.rules
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):
from calibre.gui2.author_mapper import RulesDialog
d = RulesDialog(self)
diff --git a/src/calibre/gui2/series_mapper.py b/src/calibre/gui2/series_mapper.py
new file mode 100644
index 0000000000..0b4285a6dd
--- /dev/null
+++ b/src/calibre/gui2/series_mapper.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python
+# License: GPL v3 Copyright: 2018, Kovid Goyal
+
+
+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 = _(
+ '{action} the series name, if it {match_type}: {query}').format(
+ action=RuleEdit.ACTION_MAP[rule['action']], match_type=RuleEdit.MATCH_TYPE_MAP[rule['match_type']], query=query)
+ if rule['action'] == 'replace':
+ text += '
' + _('to the name') + ' %s' % rule['replace']
+ return '' + text + '
'
+
+
+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 = '
'
+
+ 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