From 7b1df14c362b7f1300e08aad6ae11a7fbd794589 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 08:20:34 +0100 Subject: [PATCH 01/22] Remove escaping backslash in replacement string in formatter. --- src/calibre/utils/formatter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index fdfd7b77c3..50c08977cd 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -82,6 +82,7 @@ class TemplateFormatter(string.Formatter): format_string_re = re.compile(r'^(.*)\|(.*)\|(.*)$') compress_spaces = re.compile(r'\s+') + backslash_comma_to_comma = re.compile(r'\\,') arg_parser = re.Scanner([ (r',', lambda x,t: ''), @@ -123,6 +124,7 @@ class TemplateFormatter(string.Formatter): field = fmt[colon:p] func = self.functions[field] args = self.arg_parser.scan(fmt[p+1:])[0] + args = [self.backslash_comma_to_comma.sub(',', a) for a in args] if (func[0] == 0 and (len(args) != 1 or args[0])) or \ (func[0] > 0 and func[0] != len(args)): raise ValueError('Incorrect number of arguments for function '+ fmt[0:p]) From ce0de68aa0bacea7f4223ecfeb204c40a393e6ce Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 10:18:25 +0100 Subject: [PATCH 02/22] Make the field 'title_sort' work. It didn't because of the name mismatch with 'sort' --- src/calibre/ebooks/metadata/book/base.py | 7 +++++-- src/calibre/library/field_metadata.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index b8288e210c..8e4385100a 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -484,9 +484,12 @@ class Metadata(object): res = self.format_series_index(res) return (name, unicode(res), orig_res, cmeta) - if key in field_metadata and field_metadata[key]['kind'] == 'field': + # Translate aliases into the standard field name + fmkey = field_metadata.search_term_to_field_key(key) + + if fmkey in field_metadata and field_metadata[fmkey]['kind'] == 'field': res = self.get(key, None) - fmeta = field_metadata[key] + fmeta = field_metadata[fmkey] name = unicode(fmeta['name']) if res is None or res == '': return (name, res, None, None) diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index 37393d0d2c..b43a6620d0 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -259,8 +259,8 @@ class FieldMetadata(dict): 'datatype':'text', 'is_multiple':None, 'kind':'field', - 'name':None, - 'search_terms':[], + 'name':_('Title Sort'), + 'search_terms':['title_sort'], 'is_custom':False, 'is_category':False}), ('size', {'table':None, From 6ade149cbd149ea87c114d6f14c79515f8902574 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 10:18:41 +0100 Subject: [PATCH 03/22] Slight improvement to manual --- src/calibre/manual/template_lang.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst index 7b2afadd2b..2d7aa763c7 100644 --- a/src/calibre/manual/template_lang.rst +++ b/src/calibre/manual/template_lang.rst @@ -119,10 +119,11 @@ The functions available are: * ``ifempty(text)`` -- if the field is not empty, return the value of the field. Otherwise return `text`. * ``test(text if not empty, text if empty)`` -- return `text if not empty` if the field is not empty, otherwise return `text if empty`. * ``contains(pattern, text if match, text if not match`` -- checks if field contains matches for the regular expression `pattern`. Returns `text if match` if matches are found, otherwise it returns `text if no match`. - * ``switch(pattern, value, pattern, value, ..., else_value)`` -- for each ``pattern, value`` pair, checks if the field matches the ``pattern`` and if so, returns that ``value``. If no ``pattern`` matches, then ``else_value`` is returned. You can have as many ``pattern, value`` pairs as you want. + * ``switch(pattern, value, pattern, value, ..., else_value)`` -- for each ``pattern, value`` pair, checks if the field matches the regular expression ``pattern`` and if so, returns that ``value``. If no ``pattern`` matches, then ``else_value`` is returned. You can have as many ``pattern, value`` pairs as you want. + * ``re(pattern, replacement)`` -- return the field after applying the regular expression. All instances of `pattern` are replaced with `replacement`. As in all of |app|, these are python-compatible regular expressions. * ``shorten(left chars, middle text, right chars)`` -- Return a shortened version of the field, consisting of `left chars` characters from the beginning of the field, followed by `middle text`, followed by `right chars` characters from the end of the string. `Left chars` and `right chars` must be integers. For example, assume the title of the book is `Ancient English Laws in the Times of Ivanhoe`, and you want it to fit in a space of at most 15 characters. If you use ``{title:shorten(9,-,5)}``, the result will be `Ancient E-nhoe`. If the field's length is less than ``left chars`` + ``right chars`` + the length of ``middle text``, then the field will be used intact. For example, the title `The Dome` would not be changed. * ``lookup(field if not empty, field if empty)`` -- like test, except the arguments are field (metadata) names, not text. The value of the appropriate field will be fetched and used. Note that because composite columns are fields, you can use this function in one composite field to use the value of some other composite field. This is extremely useful when constructing variable save paths (more later). - * ``re(pattern, replacement)`` -- return the field after applying the regular expression. All instances of `pattern` are replaced with `replacement`. As in all of |app|, these are python-compatible regular expressions. + Now, about using functions and formatting in the same field. Suppose you have an integer custom column called ``#myint`` that you want to see with leading zeros, as in ``003``. To do this, you would use a format of ``0>3s``. However, by default, if a number (integer or float) equals zero then the field produces the empty value, so zero values will produce nothing, not ``000``. If you really want to see ``000`` values, then you use both the format string and the ``ifempty`` function to change the empty value back to a zero. The field reference would be:: From 3c5e8c0291dfe73427ed7df59251f8686a4d5d28 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 10:19:09 +0100 Subject: [PATCH 04/22] Fix recover_database to use the right options parser --- src/calibre/library/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py index e3e52b516d..cd8305b059 100644 --- a/src/calibre/library/cli.py +++ b/src/calibre/library/cli.py @@ -971,14 +971,14 @@ def restore_database_option_parser(): files in each directory of the calibre library. This is useful if your metadata.db file has been corrupted. - WARNING: This completely regenerates your datbase. You will + WARNING: This completely regenerates your database. You will lose stored per-book conversion settings and custom recipes. ''')) return parser def command_restore_database(args, dbpath): from calibre.library.restore import Restore - parser = saved_searches_option_parser() + parser = restore_database_option_parser() opts, args = parser.parse_args(args) if len(args) != 0: parser.print_help() From 3941b9c4a139e80d56fc270f29264b9c1704d44d Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 10:49:42 +0100 Subject: [PATCH 05/22] fix exceptions in mobi caused by new thumbnails being larger than the one stored in the file. Why things are done this way, I don't know. --- src/calibre/ebooks/metadata/mobi.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/calibre/ebooks/metadata/mobi.py b/src/calibre/ebooks/metadata/mobi.py index 408bab828d..30668d70f7 100644 --- a/src/calibre/ebooks/metadata/mobi.py +++ b/src/calibre/ebooks/metadata/mobi.py @@ -404,14 +404,16 @@ class MetadataUpdater(object): if self.cover_record is not None: size = len(self.cover_record) cover = rescale_image(data, size) - cover += '\0' * (size - len(cover)) - self.cover_record[:] = cover + if len(cover) <= size: + cover += '\0' * (size - len(cover)) + self.cover_record[:] = cover if self.thumbnail_record is not None: size = len(self.thumbnail_record) thumbnail = rescale_image(data, size, dimen=MAX_THUMB_DIMEN) - thumbnail += '\0' * (size - len(thumbnail)) - self.thumbnail_record[:] = thumbnail - return + if len(thumbnail) <= size: + thumbnail += '\0' * (size - len(thumbnail)) + self.thumbnail_record[:] = thumbnail + return def set_metadata(stream, mi): mu = MetadataUpdater(stream) From 4ae7a3b8c9c1b69695148675dfd47ceedc927c8a Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 11:51:27 +0100 Subject: [PATCH 06/22] Make Metadata smart update handle classifiers correctly --- src/calibre/ebooks/metadata/book/__init__.py | 6 ++--- src/calibre/ebooks/metadata/book/base.py | 24 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 761a573b69..2da0d1b8fb 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -104,7 +104,8 @@ STANDARD_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union( SC_FIELDS_NOT_COPIED = frozenset(['title', 'title_sort', 'authors', 'author_sort', 'author_sort_map', - 'cover_data', 'tags', 'language']) + 'cover_data', 'tags', 'language', + 'classifiers']) # Metadata fields that smart update should copy only if the source is not None SC_FIELDS_COPY_NOT_NULL = frozenset(['lpath', 'size', 'comments', 'thumbnail']) @@ -114,8 +115,7 @@ SC_COPYABLE_FIELDS = SOCIAL_METADATA_FIELDS.union( PUBLICATION_METADATA_FIELDS).union( BOOK_STRUCTURE_FIELDS).union( DEVICE_METADATA_FIELDS).union( - CALIBRE_METADATA_FIELDS).union( - TOP_LEVEL_CLASSIFIERS) - \ + CALIBRE_METADATA_FIELDS) - \ SC_FIELDS_NOT_COPIED.union( SC_FIELDS_COPY_NOT_NULL) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 8e4385100a..29a285918f 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -164,6 +164,18 @@ class Metadata(object): def set(self, field, val, extra=None): self.__setattr__(field, val, extra) + def get_classifiers(self): + ''' + Return a copy of the classifiers dictionary. + The dict is small, and the penalty for using a reference where a copy is + needed is large. Also, we don't want any manipulations of the returned + dict to show up in the book. + ''' + return copy.deepcopy(object.__getattribute__(self, '_data')['classifiers']) + + def set_classifiers(self, classifiers): + object.__getattribute__(self, '_data')['classifiers'] = classifiers + # field-oriented interface. Intended to be the same as in LibraryDatabase def standard_field_keys(self): @@ -369,6 +381,7 @@ class Metadata(object): self.set_all_user_metadata(other.get_all_user_metadata(make_copy=True)) for x in SC_FIELDS_COPY_NOT_NULL: copy_not_none(self, other, x) + self.set_classifiers(other.get_classifiers()) # language is handled below else: for attr in SC_COPYABLE_FIELDS: @@ -423,6 +436,17 @@ class Metadata(object): if len(other_comments.strip()) > len(my_comments.strip()): self.comments = other_comments + # Copy all the non-none classifiers + if callable(getattr(other, 'get_classifiers', None)): + d = self.get_classifiers() + s = other.get_classifiers() + d.update([v for v in s.iteritems() if v[1] is not None]) + self.set_classifiers(d) + else: + # other structure not Metadata. Copy the top-level classifiers + for attr in TOP_LEVEL_CLASSIFIERS: + copy_not_none(self, other, attr) + other_lang = getattr(other, 'language', None) if other_lang and other_lang.lower() != 'und': self.language = other_lang From 7e1e6bd7465dd0376d1093dd9794ba95c9f19f76 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 14:02:33 +0100 Subject: [PATCH 07/22] Add a special deepcopy that only copies metadata, not the other random attributes that have been added. --- src/calibre/devices/prs505/sony_cache.py | 2 +- src/calibre/ebooks/metadata/book/base.py | 5 +++++ src/calibre/gui2/device.py | 2 +- src/calibre/library/save_to_disk.py | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py index 5247e051f1..38d2001709 100644 --- a/src/calibre/devices/prs505/sony_cache.py +++ b/src/calibre/devices/prs505/sony_cache.py @@ -360,7 +360,7 @@ class XMLCache(object): if record is None: record = self.create_text_record(root, i, book.lpath) if plugboard is not None: - newmi = book.deepcopy() + newmi = book.deepcopy_metadata() newmi.template_to_attribute(book, plugboard) else: newmi = book diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 29a285918f..6106bea180 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -148,6 +148,11 @@ class Metadata(object): object.__setattr__(m, '_data', copy.deepcopy(object.__getattribute__(self, '_data'))) return m + def deepcopy_metadata(self): + m = Metadata(None) + object.__setattr__(m, '_data', copy.deepcopy(object.__getattribute__(self, '_data'))) + return m + def get(self, field, default=None): try: return self.__getattribute__(field) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index d06b77b4e2..f6e575439a 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -357,7 +357,7 @@ class DeviceManager(Thread): # {{{ f, file=sys.__stdout__) with open(f, 'r+b') as stream: if cpb: - newmi = mi.deepcopy() + newmi = mi.deepcopy_metadata() newmi.template_to_attribute(mi, cpb) else: newmi = mi diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index c86b83e001..27de7667c1 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -281,7 +281,7 @@ def save_book_to_disk(id, db, root, opts, length): stream.seek(0) try: if cpb: - newmi = mi.deepcopy() + newmi = mi.deepcopy_metadata() newmi.template_to_attribute(mi, cpb) else: newmi = mi From 7be5e77580e53dde3621be4156c5e9d55ee98122 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 15:21:17 +0100 Subject: [PATCH 08/22] Take out DEBUG test, because apparently the sub-processes don't see it. --- src/calibre/library/save_to_disk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 27de7667c1..d37c92f595 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -268,8 +268,8 @@ def save_book_to_disk(id, db, root, opts, length): cpb = cpb[dev_name] else: cpb = None - if DEBUG: - prints('Save-to-disk using plugboard:', fmt, cpb) + # Leave this here for a while, in case problems arise. + prints('Save-to-disk using plugboard:', fmt, cpb) data = db.format(id, fmt, index_is_id=True) if data is None: continue From f89c43148c42a073d96ef8c6d504b6dd0ab91da0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 15:21:36 +0100 Subject: [PATCH 09/22] Drag & drop on the tag browser --- src/calibre/gui2/tag_view.py | 71 +++++++++++++++++++++++++------- src/calibre/library/database2.py | 18 ++++---- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 91b07c4d7e..07f8f92114 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -66,6 +66,7 @@ class TagsView(QTreeView): # {{{ author_sort_edit = pyqtSignal(object, object) tag_item_renamed = pyqtSignal() search_item_renamed = pyqtSignal() + drag_drop_finished = pyqtSignal(object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) @@ -121,10 +122,12 @@ class TagsView(QTreeView): # {{{ p = m.parent(idx) if idx.isValid() and p.isValid(): item = m.data(p, Qt.UserRole) - if item.type == TagTreeItem.CATEGORY and \ - item.category_key in \ - ('tags', 'series', 'authors', 'rating', 'publisher'): - allowed = True + fm = self.db.metadata_for_field(item.category_key) + if item.category_key in \ + ('tags', 'series', 'authors', 'rating', 'publisher') or\ + (fm['is_custom'] and \ + (fm['datatype'] == 'text' or fm['datatype'] == 'rating')): + allowed = True if allowed: event.acceptProposedAction() else: @@ -136,18 +139,54 @@ class TagsView(QTreeView): # {{{ p = m.parent(idx) if idx.isValid() and p.isValid(): item = m.data(p, Qt.UserRole) - if item.type == TagTreeItem.CATEGORY and \ - item.category_key in \ - ('tags', 'series', 'authors', 'rating', 'publisher'): - child = m.data(idx, Qt.UserRole) - md = event.mimeData() - mime = 'application/calibre+from_library' - ids = list(map(int, str(md.data(mime)).split())) - self.handle_drop(item, child, ids) - event.accept() + if item.type == TagTreeItem.CATEGORY: + fm = self.db.metadata_for_field(item.category_key) + if item.category_key in \ + ('tags', 'series', 'authors', 'rating', 'publisher') or\ + (fm['is_custom'] and \ + (fm['datatype'] == 'text' or fm['datatype'] == 'rating')): + child = m.data(idx, Qt.UserRole) + md = event.mimeData() + mime = 'application/calibre+from_library' + ids = list(map(int, str(md.data(mime)).split())) + self.handle_drop(item, child, ids) + event.accept() def handle_drop(self, parent, child, ids): - print 'Dropped ids:', ids + # print 'Dropped ids:', ids, parent.category_key, child.tag.name + key = parent.category_key + fm = self.db.metadata_for_field(key) + is_multiple = fm['is_multiple'] + val = child.tag.name + for id in ids: + mi = self.db.get_metadata(id, index_is_id=True) + + # Prepare to ignore the author, unless it is changed. Title is + # always ignored -- see the call to set_metadata + set_authors = False + + # Author_sort cannot change explicitly. Changing the author might + # change it. + mi.author_sort = None # Never will change by itself. + + if key == 'authors': + mi.authors = [val] + set_authors=True + elif fm['datatype'] == 'rating': + mi.set(key, len(val) * 2) + elif is_multiple: + new_val = mi.get(key, []) + if val in new_val: + # Fortunately, only one field can change, so the continue + # won't break anything + continue + new_val.append(val) + mi.set(key, new_val) + else: + mi.set(key, val) + self.db.set_metadata(id, mi, set_title=False, + set_authors=set_authors) + self.drag_drop_finished.emit(ids) @property def match_all(self): @@ -729,6 +768,7 @@ class TagBrowserMixin(object): # {{{ self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) self.tags_view.search_item_renamed.connect(self.saved_searches_changed) + self.tags_view.drag_drop_finished.connect(self.drag_drop_finished) self.edit_categories.clicked.connect(lambda x: self.do_user_categories_edit()) @@ -810,6 +850,9 @@ class TagBrowserMixin(object): # {{{ self.library_view.model().refresh() self.tags_view.recount() + def drag_drop_finished(self, ids): + self.library_view.model().refresh_ids(ids) + # }}} class TagBrowserWidget(QWidget): # {{{ diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 529fd3fdc4..068f249f2a 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1247,7 +1247,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.set_path(id, True) self.notify('metadata', [id]) - def set_metadata(self, id, mi, ignore_errors=False): + def set_metadata(self, id, mi, ignore_errors=False, + set_title=True, set_authors=True): ''' Set metadata for the book `id` from the `Metadata` object `mi` ''' @@ -1259,14 +1260,15 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): traceback.print_exc() else: raise - if mi.title: + if set_title and mi.title: self.set_title(id, mi.title, commit=False) - if not mi.authors: - mi.authors = [_('Unknown')] - authors = [] - for a in mi.authors: - authors += string_to_authors(a) - self.set_authors(id, authors, notify=False, commit=False) + if set_authors: + if not mi.authors: + mi.authors = [_('Unknown')] + authors = [] + for a in mi.authors: + authors += string_to_authors(a) + self.set_authors(id, authors, notify=False, commit=False) if mi.author_sort: doit(self.set_author_sort, id, mi.author_sort, notify=False, commit=False) From 249ca16c4c2526b9c4535475243358c8b0f1605e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 15:45:47 +0100 Subject: [PATCH 10/22] Add another device driver plugin call point for Apple devices --- src/calibre/gui2/device.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index f6e575439a..348b0f846c 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -345,6 +345,12 @@ class DeviceManager(Thread): # {{{ def _upload_books(self, files, names, on_card=None, metadata=None, plugboards=None): '''Upload books to device: ''' + if hasattr(self.connected_device, 'use_plugboard_ext') and \ + callable(self.connected_device.use_plugboard_ext): + ext = self.connected_device.use_plugboard_ext() + if ext is not None: + self.connected_device.set_plugboard( + self.find_plugboard(ext, plugboards)) if metadata and files and len(metadata) == len(files): for f, mi in zip(files, metadata): if isinstance(f, unicode): From ddcde43f2cf5281225d2e33b01a4e303672f3a27 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 16:36:23 +0100 Subject: [PATCH 11/22] Speed up setting metadata for tag browser drag & drop. Add series columns. --- src/calibre/gui2/tag_view.py | 22 +++++++++++++++++++--- src/calibre/library/database2.py | 5 +++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 07f8f92114..442b909a8a 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -16,10 +16,12 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QPushButton, QWidget, QItemDelegate from calibre.ebooks.metadata import title_sort +from calibre.ebooks.metadata.book import ALL_METADATA_FIELDS from calibre.gui2 import config, NONE from calibre.library.field_metadata import TagsIcons from calibre.utils.search_query_parser import saved_searches from calibre.gui2 import error_dialog +from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories from calibre.gui2.dialogs.tag_list_editor import TagListEditor from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog @@ -126,7 +128,7 @@ class TagsView(QTreeView): # {{{ if item.category_key in \ ('tags', 'series', 'authors', 'rating', 'publisher') or\ (fm['is_custom'] and \ - (fm['datatype'] == 'text' or fm['datatype'] == 'rating')): + fm['datatype'] in ['text', 'rating', 'series']): allowed = True if allowed: event.acceptProposedAction() @@ -144,7 +146,7 @@ class TagsView(QTreeView): # {{{ if item.category_key in \ ('tags', 'series', 'authors', 'rating', 'publisher') or\ (fm['is_custom'] and \ - (fm['datatype'] == 'text' or fm['datatype'] == 'rating')): + fm['datatype'] in ['text', 'rating', 'series']): child = m.data(idx, Qt.UserRole) md = event.mimeData() mime = 'application/calibre+from_library' @@ -155,6 +157,17 @@ class TagsView(QTreeView): # {{{ def handle_drop(self, parent, child, ids): # print 'Dropped ids:', ids, parent.category_key, child.tag.name key = parent.category_key + if (key == 'authors' and len(ids) >= 5): + if not confirm('

'+_('Changing the authors for several books can ' + 'take a while. Are you sure?') + +'

', 'tag_browser_drop_authors', self): + return + elif len(ids) > 15: + if not confirm('

'+_('Changing the metadata for that many books ' + 'can take a while. Are you sure?') + +'

', 'tag_browser_many_changes', self): + return + fm = self.db.metadata_for_field(key) is_multiple = fm['is_multiple'] val = child.tag.name @@ -174,6 +187,8 @@ class TagsView(QTreeView): # {{{ set_authors=True elif fm['datatype'] == 'rating': mi.set(key, len(val) * 2) + elif fm['is_custom'] and fm['datatype'] == 'series': + mi.set(key, val, extra=1.0) elif is_multiple: new_val = mi.get(key, []) if val in new_val: @@ -185,7 +200,8 @@ class TagsView(QTreeView): # {{{ else: mi.set(key, val) self.db.set_metadata(id, mi, set_title=False, - set_authors=set_authors) + set_authors=set_authors, commit=False) + self.db.commit() self.drag_drop_finished.emit(ids) @property diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 068f249f2a..6666af8a8c 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1248,7 +1248,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.notify('metadata', [id]) def set_metadata(self, id, mi, ignore_errors=False, - set_title=True, set_authors=True): + set_title=True, set_authors=True, commit=True): ''' Set metadata for the book `id` from the `Metadata` object `mi` ''' @@ -1306,7 +1306,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): val=mi.get(key), extra=mi.get_extra(key), label=user_mi[key]['label'], commit=False) - self.conn.commit() + if commit: + self.conn.commit() self.notify('metadata', [id]) def authors_sort_strings(self, id, index_is_id=False): From 6f8197ecfac205b1f39dbdf330fd2226e71f2401 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 4 Oct 2010 20:00:10 +0100 Subject: [PATCH 12/22] Changes to device plugboard architecture --- src/calibre/devices/folder_device/driver.py | 2 + src/calibre/devices/prs505/driver.py | 17 +++--- src/calibre/gui2/device.py | 62 ++++++++++----------- src/calibre/gui2/preferences/plugboard.py | 8 +-- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py index fb491ac5ce..d2bcf7ce3d 100644 --- a/src/calibre/devices/folder_device/driver.py +++ b/src/calibre/devices/folder_device/driver.py @@ -21,6 +21,7 @@ class FOLDER_DEVICE_FOR_CONFIG(USBMS): VENDOR_ID = 0xffff PRODUCT_ID = 0xffff BCD = 0xffff + DEVICE_PLUGBOARD_NAME = 'FOLDER_DEVICE' class FOLDER_DEVICE(USBMS): @@ -36,6 +37,7 @@ class FOLDER_DEVICE(USBMS): VENDOR_ID = 0xffff PRODUCT_ID = 0xffff BCD = 0xffff + DEVICE_PLUGBOARD_NAME = 'FOLDER_DEVICE' THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py index f0aa58e711..bb62e4dc76 100644 --- a/src/calibre/devices/prs505/driver.py +++ b/src/calibre/devices/prs505/driver.py @@ -64,6 +64,7 @@ class PRS505(USBMS): EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(['series', 'tags']) plugboard = None + plugboard_func = None def windows_filter_pnp_id(self, pnp_id): return '_LAUNCHER' in pnp_id @@ -152,7 +153,12 @@ class PRS505(USBMS): else: collections = [] debug_print('PRS505: collection fields:', collections) - c.update(blists, collections, self.plugboard) + pb = None + if self.plugboard_func: + pb = self.plugboard_func(self.__class__.__name__, + 'device_db', self.plugboards) + debug_print('PRS505: use plugboards', pb) + c.update(blists, collections, pb) c.write() USBMS.sync_booklists(self, booklists, end_session=end_session) @@ -165,9 +171,6 @@ class PRS505(USBMS): c.write() debug_print('PRS505: finished rebuild_collections') - def use_plugboard_ext(self): - return 'device_db' - - def set_plugboard(self, pb): - debug_print('PRS505: use plugboard', pb) - self.plugboard = pb + def set_plugboards(self, plugboards, pb_func): + self.plugboards = plugboards + self.plugboard_func = pb_func diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 348b0f846c..01f9347f67 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -104,6 +104,28 @@ class DeviceJob(BaseJob): # {{{ # }}} +def find_plugboard(device_name, format, plugboards): + cpb = None + if format in plugboards: + cpb = plugboards[format] + elif plugboard_any_format_value in plugboards: + cpb = plugboards[plugboard_any_format_value] + if cpb is not None: + if device_name in cpb: + cpb = cpb[device_name] + elif plugboard_any_device_value in cpb: + cpb = cpb[plugboard_any_device_value] + else: + cpb = None + if DEBUG: + prints('Device using plugboard', format, device_name, cpb) + return cpb + +def device_name_for_plugboards(device_class): + if hasattr(device_class, 'DEVICE_PLUGBOARD_NAME'): + return device_class.DEVICE_PLUGBOARD_NAME + return device_class.__class__.__name__ + class DeviceManager(Thread): # {{{ def __init__(self, connected_slot, job_manager, open_feedback_slot, sleep_time=2): @@ -311,12 +333,9 @@ class DeviceManager(Thread): # {{{ return self.device.card_prefix(end_session=False), self.device.free_space() def sync_booklists(self, done, booklists, plugboards): - if hasattr(self.connected_device, 'use_plugboard_ext') and \ - callable(self.connected_device.use_plugboard_ext): - ext = self.connected_device.use_plugboard_ext() - if ext is not None: - self.connected_device.set_plugboard( - self.find_plugboard(ext, plugboards)) + if hasattr(self.connected_device, 'set_plugboards') and \ + callable(self.connected_device.set_plugboards): + self.connected_device.set_plugboards(plugboards, find_plugboard) return self.create_job(self._sync_booklists, done, args=[booklists], description=_('Send metadata to device')) @@ -325,37 +344,18 @@ class DeviceManager(Thread): # {{{ args=[booklist, on_card], description=_('Send collections to device')) - def find_plugboard(self, ext, plugboards): - dev_name = self.connected_device.__class__.__name__ - cpb = None - if ext in plugboards: - cpb = plugboards[ext] - elif plugboard_any_format_value in plugboards: - cpb = plugboards[plugboard_any_format_value] - if cpb is not None: - if dev_name in cpb: - cpb = cpb[dev_name] - elif plugboard_any_device_value in cpb: - cpb = cpb[plugboard_any_device_value] - else: - cpb = None - if DEBUG: - prints('Device using plugboard', ext, dev_name, cpb) - return cpb - def _upload_books(self, files, names, on_card=None, metadata=None, plugboards=None): '''Upload books to device: ''' - if hasattr(self.connected_device, 'use_plugboard_ext') and \ - callable(self.connected_device.use_plugboard_ext): - ext = self.connected_device.use_plugboard_ext() - if ext is not None: - self.connected_device.set_plugboard( - self.find_plugboard(ext, plugboards)) + if hasattr(self.connected_device, 'set_plugboards') and \ + callable(self.connected_device.set_plugboards): + self.connected_device.set_plugboards(plugboards, find_plugboard) if metadata and files and len(metadata) == len(files): for f, mi in zip(files, metadata): if isinstance(f, unicode): ext = f.rpartition('.')[-1].lower() - cpb = self.find_plugboard(ext, plugboards) + cpb = find_plugboard( + device_name_for_plugboards(self.connected_device), + ext, plugboards) if ext: try: if DEBUG: diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 4781921073..774c0d6beb 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -9,6 +9,7 @@ from PyQt4 import QtGui from PyQt4.Qt import Qt from calibre.gui2 import error_dialog +from calibre.gui2.device import device_name_for_plugboards from calibre.gui2.preferences import ConfigWidgetBase, test_widget from calibre.gui2.preferences.plugboard_ui import Ui_Form from calibre.customize.ui import metadata_writers, device_plugins @@ -47,10 +48,9 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.devices = [''] for device in device_plugins(): - n = device.__class__.__name__ - if n.startswith('FOLDER_DEVICE'): - n = 'FOLDER_DEVICE' - self.devices.append(n) + n = device_name_for_plugboards(device) + if n not in self.devices: + self.devices.append(n) self.devices.sort(cmp=lambda x, y: cmp(x.lower(), y.lower())) self.devices.insert(1, plugboard_save_to_disk_value) self.devices.insert(2, plugboard_any_device_value) From 0b655b46fa26c991094c5cca652bcd386c6dd6c0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 05:23:53 +0100 Subject: [PATCH 13/22] Fix series_index not working in non-save templates --- src/calibre/ebooks/metadata/book/base.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 6106bea180..e34a7c7634 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -488,6 +488,16 @@ class Metadata(object): ''' returns the tuple (field_name, formatted_value) ''' + + # Handle custom series index + if key.startswith('#') and key.endswith('_index'): + tkey = key[:-6] # strip the _index + cmeta = self.get_user_metadata(tkey, make_copy=False) + if cmeta['datatype'] == 'series': + res = self.get_extra(tkey) + return (unicode(cmeta['name']+'_index'), + self.format_series_index(res), res, cmeta) + if key in self.custom_field_keys(): res = self.get(key, None) cmeta = self.get_user_metadata(key, make_copy=False) @@ -509,8 +519,6 @@ class Metadata(object): res = format_date(res, cmeta['display'].get('date_format','dd MMM yyyy')) elif datatype == 'bool': res = _('Yes') if res else _('No') - elif datatype == 'float' and key.endswith('_index'): - res = self.format_series_index(res) return (name, unicode(res), orig_res, cmeta) # Translate aliases into the standard field name From effa69add29c25d54e40d7918ba70ec08cc80b09 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 06:23:39 +0100 Subject: [PATCH 14/22] Fix problem where the _new_book indication was being lost --- src/calibre/devices/kobo/driver.py | 5 +++-- src/calibre/devices/prs505/sony_cache.py | 1 + src/calibre/devices/usbms/books.py | 10 +++++++--- src/calibre/devices/usbms/driver.py | 5 +++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index b8516aab4f..6fd86110ce 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -325,8 +325,9 @@ class KOBO(USBMS): book = Book(prefix, lpath, '', '', '', '', '', '', other=info) if book.size is None: book.size = os.stat(self.normalize_path(path)).st_size - book._new_book = True # Must be before add_book - booklists[blist].add_book(book, replace_metadata=True) + b = booklists[blist].add_book(book, replace_metadata=True) + if b: + b._new_book = True self.report_progress(1.0, _('Adding books to device metadata listing...')) def contentid_from_path(self, path, ContentType): diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py index 38d2001709..ce24dcd03f 100644 --- a/src/calibre/devices/prs505/sony_cache.py +++ b/src/calibre/devices/prs505/sony_cache.py @@ -362,6 +362,7 @@ class XMLCache(object): if plugboard is not None: newmi = book.deepcopy_metadata() newmi.template_to_attribute(book, plugboard) + newmi.set('_new_book', getattr(book, '_new_book', False)) else: newmi = book (gtz_count, ltz_count, use_tz_var) = \ diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py index 915d937379..a267d18584 100644 --- a/src/calibre/devices/usbms/books.py +++ b/src/calibre/devices/usbms/books.py @@ -71,17 +71,21 @@ class BookList(_BookList): return False def add_book(self, book, replace_metadata): + ''' + Add the book to the booklist, if needed. Return None if the book is + already there and not updated, otherwise return the book. + ''' try: b = self.index(book) except (ValueError, IndexError): b = None if b is None: self.append(book) - return True + return book if replace_metadata: self[b].smart_update(book, replace_metadata=True) - return True - return False + return self[b] + return None def remove_book(self, book): self.remove(book) diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py index b4fe5d25fc..a83a8eb0ea 100644 --- a/src/calibre/devices/usbms/driver.py +++ b/src/calibre/devices/usbms/driver.py @@ -242,8 +242,9 @@ class USBMS(CLI, Device): book = self.book_class(prefix, lpath, other=info) if book.size is None: book.size = os.stat(self.normalize_path(path)).st_size - book._new_book = True # Must be before add_book - booklists[blist].add_book(book, replace_metadata=True) + b = booklists[blist].add_book(book, replace_metadata=True) + if b: + b._new_book = True self.report_progress(1.0, _('Adding books to device metadata listing...')) debug_print('USBMS: finished adding metadata') From a6a0de8ff436758555a7dc92a189581094256a6b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 12:22:21 +0100 Subject: [PATCH 15/22] Don't bother printing None plugboards in save-to-disk --- src/calibre/library/save_to_disk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index d37c92f595..94f9dbd229 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -269,7 +269,8 @@ def save_book_to_disk(id, db, root, opts, length): else: cpb = None # Leave this here for a while, in case problems arise. - prints('Save-to-disk using plugboard:', fmt, cpb) + if cpb is not None: + prints('Save-to-disk using plugboard:', fmt, cpb) data = db.format(id, fmt, index_is_id=True) if data is None: continue From 6c35aeec6e88f8a4cdac0ab0062b4c8daeded43b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 13:06:53 +0100 Subject: [PATCH 16/22] Special case for apple and folder_Device mountables. --- src/calibre/gui2/preferences/plugboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 774c0d6beb..296387106c 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -46,7 +46,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): else: self.device_label.setText(_('Device currently connected: None')) - self.devices = [''] + self.devices = ['', 'APPLE', 'FOLDER_DEVICE'] for device in device_plugins(): n = device_name_for_plugboards(device) if n not in self.devices: From 7f55930602f2ef07c4180db61ef585f63d974395 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 17:20:14 +0100 Subject: [PATCH 17/22] Fix custom series index right this time --- src/calibre/ebooks/metadata/book/base.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index e34a7c7634..9067277bfb 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -494,9 +494,12 @@ class Metadata(object): tkey = key[:-6] # strip the _index cmeta = self.get_user_metadata(tkey, make_copy=False) if cmeta['datatype'] == 'series': - res = self.get_extra(tkey) - return (unicode(cmeta['name']+'_index'), - self.format_series_index(res), res, cmeta) + if self.get(tkey): + res = self.get_extra(tkey) + return (unicode(cmeta['name']+'_index'), + self.format_series_index(res), res, cmeta) + else: + return (unicode(cmeta['name']+'_index'), '', '', cmeta) if key in self.custom_field_keys(): res = self.get(key, None) From 3ac7d9552af37f8bc8b8fdb2c49d428ee36101a1 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 17:28:00 +0100 Subject: [PATCH 18/22] Make editing standard columns on GUI refresh the ID so composites are refreshed. --- src/calibre/gui2/library/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 4033d3c98d..0b3786d2fe 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -799,7 +799,7 @@ class BooksModel(QAbstractTableModel): # {{{ self.db.set_pubdate(id, qt_to_dt(val, as_utc=False)) else: self.db.set(row, column, val) - self.refresh_rows([row], row) + self.refresh_ids([id], row) self.dataChanged.emit(index, index) return True From 780bd84d2960080499f4b1a8dd155f7bc5e4d043 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 5 Oct 2010 23:09:15 +0100 Subject: [PATCH 19/22] Fix indenting error in setData --- src/calibre/gui2/library/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 0b3786d2fe..848c93f485 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -799,7 +799,7 @@ class BooksModel(QAbstractTableModel): # {{{ self.db.set_pubdate(id, qt_to_dt(val, as_utc=False)) else: self.db.set(row, column, val) - self.refresh_ids([id], row) + self.refresh_ids([id], row) self.dataChanged.emit(index, index) return True From b40e5bba6e46ddbf94d4d6376564b19c12fc1fb6 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 6 Oct 2010 09:28:05 +0100 Subject: [PATCH 20/22] Two robustness changes for formatting custom series indices --- src/calibre/ebooks/metadata/book/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 9067277bfb..45f69508c1 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -461,7 +461,7 @@ class Metadata(object): v = self.series_index if val is None else val try: x = float(v) - except ValueError: + except ValueError, TypeError: x = 1 return fmt_sidx(x) @@ -516,8 +516,9 @@ class Metadata(object): if datatype == 'text' and cmeta['is_multiple']: res = u', '.join(res) elif datatype == 'series' and series_with_index: - res = res + \ - ' [%s]'%self.format_series_index(val=self.get_extra(key)) + if self.get_extra(key): + res = res + \ + ' [%s]'%self.format_series_index(val=self.get_extra(key)) elif datatype == 'datetime': res = format_date(res, cmeta['display'].get('date_format','dd MMM yyyy')) elif datatype == 'bool': From 9571e23b19b2deffcb1be9848bb2365429bd4878 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 6 Oct 2010 16:07:08 +0100 Subject: [PATCH 21/22] Better fix for #7078 --- src/calibre/ebooks/metadata/book/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 45f69508c1..551d8ebe44 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -516,7 +516,7 @@ class Metadata(object): if datatype == 'text' and cmeta['is_multiple']: res = u', '.join(res) elif datatype == 'series' and series_with_index: - if self.get_extra(key): + if self.get_extra(key) is not None: res = res + \ ' [%s]'%self.format_series_index(val=self.get_extra(key)) elif datatype == 'datetime': From a24834d295992836a442df891e044158d10001ea Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 6 Oct 2010 16:13:37 +0100 Subject: [PATCH 22/22] Fix typo in template faq --- src/calibre/manual/template_lang.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst index 2d7aa763c7..b731dfe26e 100644 --- a/src/calibre/manual/template_lang.rst +++ b/src/calibre/manual/template_lang.rst @@ -38,11 +38,11 @@ If a particular book does not have a particular piece of metadata, the field in If a book has a series, the template will produce:: - {Asimov, Isaac}/Foundation/Second Foundation - 3 + Asimov, Isaac/Foundation/Second Foundation 3 and if a book does not have a series:: - {Asimov, Isaac}/Second Foundation + Asimov, Isaac/Second Foundation (|app| automatically removes multiple slashes and leading or trailing spaces).