diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py index eab625f7be..13fcb90b49 100644 --- a/src/calibre/devices/usbms/books.py +++ b/src/calibre/devices/usbms/books.py @@ -137,7 +137,6 @@ class CollectionsBookList(BookList): # For existing books, modify the collections only if the user # specified 'on_connect' attrs = collection_attributes - meta_vals = book.get_all_non_none_attributes() for attr in attrs: attr = attr.strip() ign, val, orig_val, fm = book.format_field_extended(attr) @@ -166,7 +165,7 @@ class CollectionsBookList(BookList): continue if attr == 'series' or \ ('series' in collection_attributes and - meta_vals.get('series', None) == category): + book.get('series', None) == category): is_series = True cat_name = self.compute_category_name(attr, category, fm) if cat_name not in collections: @@ -177,10 +176,10 @@ class CollectionsBookList(BookList): collections_lpaths[cat_name].add(lpath) if is_series: collections[cat_name].append( - (book, meta_vals.get(attr+'_index', sys.maxint))) + (book, book.get(attr+'_index', sys.maxint))) else: collections[cat_name].append( - (book, meta_vals.get('title_sort', 'zzzz'))) + (book, book.get('title_sort', 'zzzz'))) # Sort collections result = {} for category, books in collections.items(): diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index e087f8072d..e6dff9110b 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -81,9 +81,8 @@ DEVICE_METADATA_FIELDS = frozenset([ CALIBRE_METADATA_FIELDS = frozenset([ 'application_id', # An application id, currently set to the db_id. - # the calibre primary key of the item. 'db_id', # the calibre primary key of the item. - # TODO: NEWMETA: May want to remove once Sony's no longer use it + 'formats', # list of formats (extensions) for this book ] ) @@ -124,5 +123,5 @@ SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union( PUBLICATION_METADATA_FIELDS).union( CALIBRE_METADATA_FIELDS).union( DEVICE_METADATA_FIELDS) - \ - frozenset(['device_collections']) - # device_collections is rebuilt when needed + frozenset(['device_collections', 'formats']) + # these are rebuilt when needed diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index b252f518da..31485dfe1b 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -5,8 +5,7 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import copy -import traceback +import copy, re, string, traceback from calibre import prints from calibre.ebooks.metadata.book import SC_COPYABLE_FIELDS @@ -33,6 +32,23 @@ NULL_VALUES = { field_metadata = FieldMetadata() +class SafeFormat(string.Formatter): + ''' + Provides a format function that substitutes '' for any missing value + ''' + def get_value(self, key, args, mi): + ign, v = mi.format_field(key, series_with_index=False) + if v is None: + return '' + return v + +composite_formatter = SafeFormat() +compress_spaces = re.compile(r'\s+') + +def format_composite(x, mi): + ans = composite_formatter.vformat(x, [], mi).strip() + return compress_spaces.sub(' ', ans) + class Metadata(object): ''' @@ -343,18 +359,19 @@ class Metadata(object): def format_rating(self): return unicode(self.rating) - def format_field(self, key): - name, val, ign, ign = self.format_field_extended(key) + def format_field(self, key, series_with_index=True): + name, val, ign, ign = self.format_field_extended(key, series_with_index) return (name, val) - def format_field_extended(self, key): + def format_field_extended(self, key, series_with_index=True): from calibre.ebooks.metadata import authors_to_string ''' returns the tuple (field_name, formatted_value) ''' if key in self.user_metadata_keys: res = self.get(key, None) - if res is None or res == '': + cmeta = self.get_user_metadata(key, make_copy=False) + if cmeta['datatype'] != 'composite' and (res is None or res == ''): return (None, None, None, None) orig_res = res cmeta = self.get_user_metadata(key, make_copy=False) @@ -362,13 +379,15 @@ class Metadata(object): datatype = cmeta['datatype'] if datatype == 'text' and cmeta['is_multiple']: res = u', '.join(res) - elif datatype == 'series': + elif datatype == 'series' and series_with_index: 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': res = _('Yes') if res else _('No') + elif datatype == 'composite': + res = format_composite(cmeta['display']['composite_template'], self) return (name, res, orig_res, cmeta) if key in field_metadata and field_metadata[key]['kind'] == 'field': @@ -383,7 +402,7 @@ class Metadata(object): res = authors_to_string(res) elif datatype == 'text' and fmeta['is_multiple']: res = u', '.join(res) - elif datatype == 'series': + elif datatype == 'series' and series_with_index: res = res + ' [%s]'%self.format_series_index() elif datatype == 'datetime': res = format_date(res, fmeta['display'].get('date_format','dd MMM yyyy')) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index e9e688c93b..7839b89d7e 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -86,6 +86,7 @@ class BooksModel(QAbstractTableModel): # {{{ self.last_search = '' # The last search performed on this model self.column_map = [] self.headers = {} + self.metadata_cache = {} self.alignment_map = {} self.buffer_size = buffer self.cover_cache = None @@ -114,6 +115,16 @@ class BooksModel(QAbstractTableModel): # {{{ def clear_caches(self): if self.cover_cache: self.cover_cache.clear_cache() + self.metadata_cache = {} + + def get_cached_metadata(self, idx): + if idx not in self.metadata_cache: + self.metadata_cache[idx] = self.db.get_metadata(idx) + return self.metadata_cache[idx] + + def remove_cached_metadata(self, idx): + if idx in self.metadata_cache: + del self.metadata_cache[idx] def read_config(self): self.use_roman_numbers = config['use_roman_numerals_for_series_number'] @@ -146,6 +157,7 @@ class BooksModel(QAbstractTableModel): # {{{ elif col in self.custom_columns: self.headers[col] = self.custom_columns[col]['name'] + self.metadata_cache = {} self.build_data_convertors() self.reset() self.database_changed.emit(db) @@ -159,11 +171,13 @@ class BooksModel(QAbstractTableModel): # {{{ db.add_listener(refresh_cover) def refresh_ids(self, ids, current_row=-1): + self.metadata_cache = {} rows = self.db.refresh_ids(ids) if rows: self.refresh_rows(rows, current_row=current_row) def refresh_rows(self, rows, current_row=-1): + self.metadata_cache = {} for row in rows: if row == current_row: self.new_bookdisplay_data.emit( @@ -193,6 +207,7 @@ class BooksModel(QAbstractTableModel): # {{{ return ret def count_changed(self, *args): + self.metadata_cache = {} self.count_changed_signal.emit(self.db.count()) def row_indices(self, index): @@ -262,6 +277,7 @@ class BooksModel(QAbstractTableModel): # {{{ self.sorting_done.emit(self.db.index) def refresh(self, reset=True): + self.metadata_cache = {} self.db.refresh(field=None) self.resort(reset=reset) @@ -318,7 +334,7 @@ class BooksModel(QAbstractTableModel): # {{{ data[_('Series')] = \ _('Book %s of %s.')%\ (sidx, prepare_string_for_xml(series)) - mi = self.db.get_metadata(idx) + mi = self.get_cached_metadata(idx) for key in mi.user_metadata_keys: name, val = mi.format_field(key) if val is not None: @@ -327,6 +343,7 @@ class BooksModel(QAbstractTableModel): # {{{ def set_cache(self, idx): l, r = 0, self.count()-1 + self.remove_cached_metadata(idx) if self.cover_cache is not None: l = max(l, idx-self.buffer_size) r = min(r, idx+self.buffer_size) @@ -586,6 +603,10 @@ class BooksModel(QAbstractTableModel): # {{{ def number_type(r, idx=-1): return QVariant(self.db.data[r][idx]) + def composite_type(r, key=None): + mi = self.get_cached_metadata(r) + return QVariant(mi.format_field(key)[1]) + self.dc = { 'title' : functools.partial(text_type, idx=self.db.field_metadata['title']['rec_index'], mult=False), @@ -620,7 +641,8 @@ class BooksModel(QAbstractTableModel): # {{{ idx = self.custom_columns[col]['rec_index'] datatype = self.custom_columns[col]['datatype'] if datatype in ('text', 'comments'): - self.dc[col] = functools.partial(text_type, idx=idx, mult=self.custom_columns[col]['is_multiple']) + self.dc[col] = functools.partial(text_type, idx=idx, + mult=self.custom_columns[col]['is_multiple']) elif datatype in ('int', 'float'): self.dc[col] = functools.partial(number_type, idx=idx) elif datatype == 'datetime': @@ -628,13 +650,15 @@ class BooksModel(QAbstractTableModel): # {{{ elif datatype == 'bool': self.dc[col] = functools.partial(bool_type, idx=idx) self.dc_decorator[col] = functools.partial( - bool_type_decorator, idx=idx, - bool_cols_are_tristate=tweaks['bool_custom_columns_are_tristate'] == 'yes') + bool_type_decorator, idx=idx, + bool_cols_are_tristate=tweaks['bool_custom_columns_are_tristate'] == 'yes') elif datatype == 'rating': self.dc[col] = functools.partial(rating_type, idx=idx) elif datatype == 'series': self.dc[col] = functools.partial(series_type, idx=idx, siix=self.db.field_metadata.cc_series_index_column_for(col)) + elif datatype == 'composite': + self.dc[col] = functools.partial(composite_type, key=col) else: print 'What type is this?', col, datatype # build a index column to data converter map, to remove the string lookup in the data loop @@ -729,6 +753,7 @@ class BooksModel(QAbstractTableModel): # {{{ if role == Qt.EditRole: row, col = index.row(), index.column() column = self.column_map[col] + self.remove_cached_metadata(row) if self.is_custom_column(column): if not self.set_custom_column_data(row, column, value): return False diff --git a/src/calibre/gui2/preferences/columns.py b/src/calibre/gui2/preferences/columns.py index c1b9230f42..761a9880b1 100644 --- a/src/calibre/gui2/preferences/columns.py +++ b/src/calibre/gui2/preferences/columns.py @@ -155,7 +155,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): name=self.custcols[c]['name'], datatype=self.custcols[c]['datatype'], is_multiple=self.custcols[c]['is_multiple'], - display = self.custcols[c]['display']) + display = self.custcols[c]['display'], + editable = self.custcols[c]['editable']) must_restart = True elif '*deleteme' in self.custcols[c]: db.delete_custom_column(label=self.custcols[c]['label']) diff --git a/src/calibre/gui2/preferences/create_custom_column.py b/src/calibre/gui2/preferences/create_custom_column.py index e8ab8707e2..4b21301ccd 100644 --- a/src/calibre/gui2/preferences/create_custom_column.py +++ b/src/calibre/gui2/preferences/create_custom_column.py @@ -38,6 +38,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): 'is_multiple':False}, 8:{'datatype':'bool', 'text':_('Yes/No'), 'is_multiple':False}, + 8:{'datatype':'composite', + 'text':_('Field built from other fields'), 'is_multiple':False}, } def __init__(self, parent, editing, standard_colheads, standard_colnames): @@ -86,6 +88,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): if ct == 'datetime': if c['display'].get('date_format', None): self.date_format_box.setText(c['display'].get('date_format', '')) + elif ct == 'composite': + self.composite_box.setText(c['display'].get('composite_template', '')) self.datatype_changed() self.exec_() @@ -94,9 +98,10 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): col_type = self.column_types[self.column_type_box.currentIndex()]['datatype'] except: col_type = None - df_visible = col_type == 'datetime' for x in ('box', 'default_label', 'label'): - getattr(self, 'date_format_'+x).setVisible(df_visible) + getattr(self, 'date_format_'+x).setVisible(col_type == 'datetime') + for x in ('box', 'default_label', 'label'): + getattr(self, 'composite_'+x).setVisible(col_type == 'composite') def accept(self): @@ -122,6 +127,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): bad_col = True if bad_col: return self.simple_error('', _('The lookup name %s is already used')%col) + bad_head = False for t in self.parent.custcols: if self.parent.custcols[t]['name'] == col_heading: @@ -133,12 +139,20 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): if bad_head: return self.simple_error('', _('The heading %s is already used')%col_heading) - date_format = {} + display_dict = {} if col_type == 'datetime': if self.date_format_box.text(): - date_format = {'date_format':unicode(self.date_format_box.text())} + display_dict = {'date_format':unicode(self.date_format_box.text())} else: - date_format = {'date_format': None} + display_dict = {'date_format': None} + + if col_type == 'composite': + if not self.composite_box.text(): + return self.simple_error('', _('You must enter a template for composite fields')%col_heading) + display_dict = {'composite_template':unicode(self.composite_box.text())} + is_editable = False + else: + is_editable = True db = self.parent.gui.library_view.model().db key = db.field_metadata.custom_field_prefix+col @@ -148,8 +162,8 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): 'label':col, 'name':col_heading, 'datatype':col_type, - 'editable':True, - 'display':date_format, + 'editable':is_editable, + 'display':display_dict, 'normalized':None, 'colnum':None, 'is_multiple':is_multiple, @@ -164,7 +178,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn): item.setText(col_heading) self.parent.custcols[self.orig_column_name]['label'] = col self.parent.custcols[self.orig_column_name]['name'] = col_heading - self.parent.custcols[self.orig_column_name]['display'].update(date_format) + self.parent.custcols[self.orig_column_name]['display'].update(display_dict) self.parent.custcols[self.orig_column_name]['*edited'] = True self.parent.custcols[self.orig_column_name]['*must_restart'] = True QDialog.accept(self) diff --git a/src/calibre/gui2/preferences/create_custom_column.ui b/src/calibre/gui2/preferences/create_custom_column.ui index 5cb9494845..640becca8c 100644 --- a/src/calibre/gui2/preferences/create_custom_column.ui +++ b/src/calibre/gui2/preferences/create_custom_column.ui @@ -147,9 +147,59 @@ + + + + + + + 0 + 0 + + + + <p>Field template. Uses the same syntax as save templates. + + + + + + + Similar to save templates. For example, {title} {isbn} + + + Default: (nothing) + + + + + + + + + &Template + + + composite_box + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + - + Qt::Horizontal @@ -184,6 +234,7 @@ column_heading_box column_type_box date_format_box + composite_box button_box diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 4ba664dadc..d74024280e 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -18,7 +18,7 @@ from calibre.utils.date import parse_date class CustomColumns(object): CUSTOM_DATA_TYPES = frozenset(['rating', 'text', 'comments', 'datetime', - 'int', 'float', 'bool', 'series']) + 'int', 'float', 'bool', 'series', 'composite']) def custom_table_names(self, num): return 'custom_column_%d'%num, 'books_custom_column_%d_link'%num @@ -540,7 +540,7 @@ class CustomColumns(object): if datatype not in self.CUSTOM_DATA_TYPES: raise ValueError('%r is not a supported data type'%datatype) normalized = datatype not in ('datetime', 'comments', 'int', 'bool', - 'float') + 'float', 'composite') is_multiple = is_multiple and datatype in ('text',) num = self.conn.execute( ('INSERT INTO ' @@ -551,7 +551,7 @@ class CustomColumns(object): if datatype in ('rating', 'int'): dt = 'INT' - elif datatype in ('text', 'comments', 'series'): + elif datatype in ('text', 'comments', 'series', 'composite'): dt = 'TEXT' elif datatype in ('float',): dt = 'REAL' diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 9e9e75a26e..d06d217b76 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -538,6 +538,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): mi.pubdate = self.pubdate(idx, index_is_id=index_is_id) mi.uuid = self.uuid(idx, index_is_id=index_is_id) mi.title_sort = self.title_sort(idx, index_is_id=index_is_id) + mi.formats = self.formats(idx, index_is_id=index_is_id).split(',') tags = self.tags(idx, index_is_id=index_is_id) if tags: mi.tags = [i.strip() for i in tags.split(',')] diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index 2773f573b2..dcdfcfd9d6 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -68,7 +68,7 @@ class FieldMetadata(dict): ''' VALID_DATA_TYPES = frozenset([None, 'rating', 'text', 'comments', 'datetime', - 'int', 'float', 'bool', 'series']) + 'int', 'float', 'bool', 'series', 'composite']) # Builtin metadata {{{ diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py index ab5b39eed8..8e7c75b0ac 100644 --- a/src/calibre/library/server/mobile.py +++ b/src/calibre/library/server/mobile.py @@ -228,29 +228,19 @@ class MobileServer(object): for key in CKEYS: def concat(name, val): return '%s:#:%s'%(name, unicode(val)) - val = record[CFM[key]['rec_index']] - if val: - datatype = CFM[key]['datatype'] - if datatype in ['comments']: - continue - name = CFM[key]['name'] - if datatype == 'text' and CFM[key]['is_multiple']: - book[key] = concat(name, - format_tag_string(val, '|', - no_tag_count=True)) - elif datatype == 'series': - book[key] = concat(name, '%s [%s]'%(val, - fmt_sidx(record[CFM.cc_series_index_column_for(key)]))) - elif datatype == 'datetime': - book[key] = concat(name, - format_date(val, CFM[key]['display'].get('date_format','dd MMM yyyy'))) - elif datatype == 'bool': - if val: - book[key] = concat(name, __builtin__._('Yes')) - else: - book[key] = concat(name, __builtin__._('No')) - else: - book[key] = concat(name, val) + mi = self.db.get_metadata(record[CFM['id']['rec_index']], index_is_id=True) + name, val = mi.format_field(key) + if val is None: + continue + datatype = CFM[key]['datatype'] + if datatype in ['comments']: + continue + if datatype == 'text' and CFM[key]['is_multiple']: + book[key] = concat(name, + format_tag_string(val, ',', + no_tag_count=True)) + else: + book[key] = concat(name, val) updated = self.db.last_modified() diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index e495598a2f..0eb7379ac5 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -132,7 +132,8 @@ def CATALOG_GROUP_ENTRY(item, category, base_href, version, updated): link ) -def ACQUISITION_ENTRY(item, version, FM, updated, CFM, CKEYS): +def ACQUISITION_ENTRY(item, version, db, updated, CFM, CKEYS): + FM = db.FIELD_MAP title = item[FM['title']] if not title: title = _('Unknown') @@ -157,22 +158,16 @@ def ACQUISITION_ENTRY(item, version, FM, updated, CFM, CKEYS): (series, fmt_sidx(float(item[FM['series_index']])))) for key in CKEYS: - val = item[CFM[key]['rec_index']] + mi = db.get_metadata(item[CFM['id']['rec_index']], index_is_id=True) + name, val = mi.format_field(key) if val is not None: - name = CFM[key]['name'] datatype = CFM[key]['datatype'] if datatype == 'text' and CFM[key]['is_multiple']: - extra.append('%s: %s
'%(name, format_tag_string(val, '|', + extra.append('%s: %s
'%(name, format_tag_string(val, ',', ignore_max=True, no_tag_count=True))) - elif datatype == 'series': - extra.append('%s: %s [%s]
'%(name, val, - fmt_sidx(item[CFM.cc_series_index_column_for(key)]))) - elif datatype == 'datetime': - extra.append('%s: %s
'%(name, - format_date(val, CFM[key]['display'].get('date_format','dd MMM yyyy')))) else: - extra.append('%s: %s
' % (CFM[key]['name'], val)) + extra.append('%s: %s
'%(name, val)) comments = item[FM['comments']] if comments: comments = comments_to_html(comments) @@ -280,13 +275,14 @@ class NavFeed(Feed): class AcquisitionFeed(NavFeed): def __init__(self, updated, id_, items, offsets, page_url, up_url, version, - FM, CFM): + db): NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url) + CFM = db.field_metadata CKEYS = [key for key in sorted(CFM.get_custom_fields(), cmp=lambda x,y: cmp(CFM[x]['name'].lower(), CFM[y]['name'].lower()))] for item in items: - self.root.append(ACQUISITION_ENTRY(item, version, FM, updated, + self.root.append(ACQUISITION_ENTRY(item, version, db, updated, CFM, CKEYS)) class CategoryFeed(NavFeed): @@ -384,7 +380,7 @@ class OPDSServer(object): cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) cherrypy.response.headers['Content-Type'] = 'application/atom+xml;profile=opds-catalog' return str(AcquisitionFeed(updated, id_, items, offsets, - page_url, up_url, version, self.db.FIELD_MAP, self.db.field_metadata)) + page_url, up_url, version, self.db)) def opds_search(self, query=None, version=0, offset=0): try: diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py index 8715dda7d0..7f5bc31e70 100644 --- a/src/calibre/library/server/xml.py +++ b/src/calibre/library/server/xml.py @@ -102,31 +102,21 @@ class XMLServer(object): for key in CKEYS: def concat(name, val): return '%s:#:%s'%(name, unicode(val)) - val = record[CFM[key]['rec_index']] - if val: - datatype = CFM[key]['datatype'] - if datatype in ['comments']: - continue - k = str('CF_'+key[1:]) - name = CFM[key]['name'] - custcols.append(k) - if datatype == 'text' and CFM[key]['is_multiple']: - kwargs[k] = concat('#T#'+name, - format_tag_string(val,'|', - ignore_max=True)) - elif datatype == 'series': - kwargs[k] = concat(name, '%s [%s]'%(val, - fmt_sidx(record[CFM.cc_series_index_column_for(key)]))) - elif datatype == 'datetime': - kwargs[k] = concat(name, - format_date(val, CFM[key]['display'].get('date_format','dd MMM yyyy'))) - elif datatype == 'bool': - if val: - kwargs[k] = concat(name, __builtin__._('Yes')) - else: - kwargs[k] = concat(name, __builtin__._('No')) - else: - kwargs[k] = concat(name, val) + mi = self.db.get_metadata(record[CFM['id']['rec_index']], index_is_id=True) + name, val = mi.format_field(key) + if not val: + continue + datatype = CFM[key]['datatype'] + if datatype in ['comments']: + continue + k = str('CF_'+key[1:]) + name = CFM[key]['name'] + custcols.append(k) + if datatype == 'text' and CFM[key]['is_multiple']: + kwargs[k] = concat('#T#'+name, format_tag_string(val,',', + ignore_max=True)) + else: + kwargs[k] = concat(name, val) kwargs['custcols'] = ','.join(custcols) books.append(E.book(c, **kwargs))