From e810b58f40921414f8c048fbba0a0fa935859a77 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 17 Aug 2009 14:11:29 -0600 Subject: [PATCH] IGN:calibredb export now supports using templates to control output directory structure/filenames. Fix pop-up menuon search box being colored. --- src/calibre/__init__.py | 2 +- src/calibre/devices/usbms/device.py | 3 +- src/calibre/ebooks/metadata/__init__.py | 9 + src/calibre/gui2/library.py | 7 +- src/calibre/library/cli.py | 36 +++- src/calibre/library/save_to_disk.py | 213 ++++++++++++++++++++++++ src/calibre/utils/config.py | 10 ++ 7 files changed, 269 insertions(+), 11 deletions(-) create mode 100644 src/calibre/library/save_to_disk.py diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index ad8f10ae06..2932d71064 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -75,7 +75,7 @@ def sanitize_file_name(name, substitute='_', as_unicode=False): ''' Sanitize the filename `name`. All invalid characters are replaced by `substitute`. The set of invalid characters is the union of the invalid characters in Windows, - OS X and Linux. Also removes leading an trailing whitespace. + OS X and Linux. Also removes leading and trailing whitespace. **WARNING:** This function also replaces path separators, so only pass file names and not full paths to it. *NOTE:* This function always returns byte strings, not unicode objects. The byte strings diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py index 17b02119c4..980378b928 100644 --- a/src/calibre/devices/usbms/device.py +++ b/src/calibre/devices/usbms/device.py @@ -520,7 +520,8 @@ class Device(DeviceConfig, DevicePlugin): main, carda, cardb = self.find_device_nodes() if main is None: - raise DeviceError(_('Unable to detect the %s disk drive.') + raise DeviceError(_('Unable to detect the %s disk drive. Your ' + ' kernel is probably exporting a deprecated version of SYSFS.') %self.__class__.__name__) self._linux_mount_map = {} diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py index 332ae79afb..1a33bacf1a 100644 --- a/src/calibre/ebooks/metadata/__init__.py +++ b/src/calibre/ebooks/metadata/__init__.py @@ -301,6 +301,15 @@ class MetaInformation(object): def authors_from_string(self, raw): self.authors = string_to_authors(raw) + def format_authors(self): + return authors_to_string(self.authors) + + def format_tags(self): + return u', '.join([unicode(t) for t in self.tags]) + + def format_rating(self): + return unicode(self.rating) + def __unicode__(self): ans = [] def fmt(x, y): diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index fca324fe4c..f3bb4c21d0 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -1121,13 +1121,14 @@ class SearchBox(QLineEdit): def normalize_state(self): self.setText('') self.setPalette(self.default_palette) + self.setStyleSheet('QLineEdit { background-color: white; }') def clear_to_help(self): self.setPalette(self.gray) self.setText(self.help_text) self.home(False) self.initial_state = True - self.setStyleSheet("background-color: white") + self.setStyleSheet('QLineEdit { background-color: white; }') self.emit(SIGNAL('cleared()')) def clear(self): @@ -1135,8 +1136,8 @@ class SearchBox(QLineEdit): self.emit(SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), '', False) def search_done(self, ok): - col = 'rgba(0,255,0,25%)' if ok else 'rgb(255,0,0,25%)' - self.setStyleSheet('background-color: '+col) + col = 'rgba(0,255,0,20%)' if ok else 'rgb(255,0,0,20%)' + self.setStyleSheet('QLineEdit { background-color: %s; }' % col) def keyPressEvent(self, event): if self.initial_state: diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py index 1ac82b2edb..14896a28f4 100644 --- a/src/calibre/library/cli.py +++ b/src/calibre/library/cli.py @@ -11,7 +11,7 @@ import sys, os, cStringIO from textwrap import TextWrapper from urllib import quote -from calibre import terminal_controller, preferred_encoding +from calibre import terminal_controller, preferred_encoding, prints from calibre.utils.config import OptionParser, prefs try: from calibre.utils.single_qt_application import send_message @@ -488,10 +488,21 @@ show_metadata command. do_set_metadata(get_db(dbpath, opts), id, opf) return 0 -def do_export(db, ids, dir, single_dir, by_author): +def do_export(db, ids, dir, opts): if ids is None: ids = list(db.all_ids()) - db.export_to_dir(dir, ids, byauthor=by_author, single_dir=single_dir, index_is_id=True) + from calibre.library.save_to_disk import save_to_disk + failures = save_to_disk(db, ids, dir, opts=opts) + + if failures: + prints('Failed to save the following books:') + for id, title, tb in failures: + prints(str(id)+':', title) + if tb: + prints('\t'+'\n\t'.join(tb.splitlines())) + else: + prints('\tRequested formats not available') + prints(' ') def command_export(args, dbpath): parser = get_parser(_('''\ @@ -507,8 +518,21 @@ an opf file). You can get id numbers from the list command. help=(_('Export books to the specified directory. Default is')+' %default')) parser.add_option('--single-dir', default=False, action='store_true', help=_('Export all books into a single directory')) - parser.add_option('--by-author', default=False, action='store_true', - help=_('Create file names as author - title instead of title - author')) + from calibre.library.save_to_disk import config + c = config() + for pref in ['asciiize', 'update_metadata', 'write_opf', 'save_cover']: + opt = c.get_option(pref) + switch = '--dont-'+pref.replace('_', '-') + parser.add_option(switch, default=True, action='store_false', + help=opt.help+' '+_('Specifying this switch will turn ' + 'this behavior off.'), dest=pref) + + for pref in ['timefmt', 'template', 'formats']: + opt = c.get_option(pref) + switch = '--'+pref + parser.add_option(switch, default=opt.default, + help=opt.help, dest=pref) + opts, args = parser.parse_args(sys.argv[1:]+args) if (len(args) < 2 and not opts.all): parser.print_help() @@ -517,7 +541,7 @@ an opf file). You can get id numbers from the list command. return 1 ids = None if opts.all else map(int, args[1].split(',')) dir = os.path.abspath(os.path.expanduser(opts.to_dir)) - do_export(get_db(dbpath, opts), ids, dir, opts.single_dir, opts.by_author) + do_export(get_db(dbpath, opts), ids, dir, opts) return 0 def main(args=sys.argv): diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py new file mode 100644 index 0000000000..3df5bc8ab1 --- /dev/null +++ b/src/calibre/library/save_to_disk.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import with_statement + +__license__ = 'GPL v3' +__copyright__ = '2009, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os, traceback, sys, cStringIO + +from calibre.utils.config import Config, StringConfig +from calibre.utils.filenames import shorten_components_to, supports_long_names, \ + ascii_filename, sanitize_file_name +from calibre.ebooks.metadata.opf2 import metadata_to_opf +from calibre.ebooks.metadata.meta import set_metadata + +from calibre import strftime + +DEFAULT_TEMPLATE = '{author_sort}/{title} - {authors}' +FORMAT_ARGS = dict( + title='', + authors='', + author_sort='', + tags='', + series='', + series_index='', + rating='', + isbn='', + publisher='', + timestamp='', + pubdate='', + id='' + ) + + +def config(defaults=None): + if defaults is None: + c = Config('save_to_disk', _('Options to control saving to disk')) + else: + c = StringConfig(defaults) + + x = c.add_opt + x('update_metadata', default=True, + help=_('Normally, calibre will update the metadata in the saved files from what is' + ' in the calibre library. Makes saving to disk slower.')) + x('write_opf', default=True, + help=_('Normally, calibre will write the metadata into a separate OPF file along with the' + ' actual e-book files.')) + x('save_cover', default=True, + help=_('Normally, calibre will save the cover in a separate file along with the ' + 'actual e-book file(s).')) + x('formats', default='all', + help=_('Comma separated list of formats to save for each book.' + ' By default all available books are saved.')) + x('template', default=DEFAULT_TEMPLATE, + help=_('The template to control the filename and directory structure of the saved files. ' + 'Default is "%s" which will save books into a per-author ' + 'subdirectory with filenames containing title and author. ' + 'Available controls are: {%s}')%(DEFAULT_TEMPLATE, ', '.join(FORMAT_ARGS))) + x('asciiize', default=True, + help=_('Normally, calibre will convert all non English characters into English equivalents ' + 'for the file names. ' + 'WARNING: If you turn this off, you may experience errors when ' + 'saving, depending on how well the filesystem you are saving ' + 'to supports unicode.')) + x('timefmt', default='%b, %Y', + help=_('The format in which to display dates. %d - day, %b - month, ' + '%Y - year. Default is: %b, %Y')) + return c + +def preprocess_template(template): + template = template.replace('//', '/') + template = template.replace('{author}', '{authors}') + template = template.replace('{tag}', '{tags}') + return template + +def get_components(template, mi, id, timefmt='%b %Y', length=250, sanitize_func=ascii_filename): + format_args = dict(**FORMAT_ARGS) + if mi.title: + format_args['title'] = mi.title + if mi.authors: + format_args['authors'] = mi.format_authors() + if mi.author_sort: + format_args['author_sort'] = mi.author_sort + if mi.tags: + format_args['tags'] = mi.format_tags() + if mi.series: + format_args['series'] = mi.series + if mi.series_index is not None: + format_args['series_index'] = mi.format_series_index() + if mi.rating is not None: + format_args['rating'] = mi.format_rating() + if mi.isbn: + format_args['isbn'] = mi.isbn + if mi.publisher: + format_args['publisher'] = mi.publisher + if hasattr(mi.timestamp, 'timetuple'): + format_args['timestamp'] = strftime(timefmt, mi.timestamp.timetuple()) + if hasattr(mi.pubdate, 'timetuple'): + format_args['timestamp'] = strftime(timefmt, mi.pubdate.timetuple()) + format_args['id'] = str(id) + components = [x.strip() for x in template.split('/') if x.strip()] + components = [x.format(**format_args).strip() for x in components] + components = [sanitize_func(x) for x in components if x] + if not components: + components = [str(id)] + return shorten_components_to(length, components) + + +def save_book_to_disk(id, db, root, opts, length): + mi = db.get_metadata(id, index_is_id=True) + + available_formats = db.formats(id, index_is_id=True) + if not available_formats: + available_formats = [] + else: + available_formats = [x.lower().strip() for x in + available_formats.split(',')] + if opts.formats == 'all': + asked_formats = available_formats + else: + asked_formats = [x.lower().strip() for x in opts.formats.split(',')] + formats = set(available_formats).intersection(set(asked_formats)) + if not formats: + return True, id, mi.title + + components = get_components(opts.template, mi, id, opts.timefmt, length, + ascii_filename if opts.asciiize else sanitize_file_name) + base_path = os.path.join(root, *components) + base_name = os.path.basename(base_path) + dirpath = os.path.dirname(base_path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + cdata = db.cover(id, index_is_id=True) + if opts.save_cover: + if cdata is not None: + with open(base_path+'.jpg', 'wb') as f: + f.write(cdata) + mi.cover = base_name+'.jpg' + else: + mi.cover = None + + if opts.write_opf: + opf = metadata_to_opf(mi) + with open(base_path+'.opf', 'wb') as f: + f.write(opf) + + if cdata is not None: + mi.cover_data = ('jpg', cdata) + mi.cover = None + + written = False + for fmt in formats: + data = db.format(id, fmt, index_is_id=True) + if data is None: + continue + else: + written = True + if opts.update_metadata: + stream = cStringIO.StringIO() + stream.write(data) + stream.seek(0) + try: + set_metadata(stream, mi, fmt) + except: + traceback.print_exc() + stream.seek(0) + data = stream.read() + with open(base_path+'.'+fmt, 'wb') as f: + f.write(data) + + return not written, id, mi.title + + + +def save_to_disk(db, ids, root, opts=None, callback=None): + ''' + Save books from the database ``db`` to the path specified by ``root``. + + :param:`ids` iterable of book ids to save from the database. + :param:`callback` is an optional callable that is called on after each + book is processed with the arguments: id, title and failed + :return: A list of failures. Each element of the list is a tuple + (id, title, traceback) + ''' + if opts is None: + opts = config().parse() + if isinstance(root, unicode): + root = root.encode(sys.getfilesystemencoding()) + root = os.path.abspath(root) + + opts.template = preprocess_template(opts.template) + length = 1000 if supports_long_names(root) else 250 + length -= len(root) + if length < 5: + raise ValueError('%r is too long.'%root) + failures = [] + for x in ids: + tb = '' + try: + failed, id, title = save_book_to_disk(x, db, root, opts, length) + except: + failed, id, title = True, x, db.title(x, index_is_id=True) + tb = traceback.format_exc() + if failed: + failures.append((id, title, tb)) + if callable(callback): + if not callback(int(id), title, failed): + break + return failures + + diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py index 06af68dad6..f4befc6052 100644 --- a/src/calibre/utils/config.py +++ b/src/calibre/utils/config.py @@ -216,6 +216,14 @@ class OptionSet(object): return True return False + def get_option(self, name_or_option_object): + idx = self.preferences.index(name_or_option_object) + if idx > -1: + return self.preferences[idx] + for p in self.preferences: + if p.name == name_or_option_object: + return p + def add_group(self, name, description=''): if name in self.group_list: raise ValueError('A group by the name %s already exists in this set'%name) @@ -370,6 +378,7 @@ class ConfigInterface(object): self.add_group = self.option_set.add_group self.remove_opt = self.remove = self.option_set.remove_opt self.parse_string = self.option_set.parse_string + self.get_option = self.option_set.get_option def update(self, other): self.option_set.update(other.option_set) @@ -381,6 +390,7 @@ class ConfigInterface(object): def smart_update(self, opts1, opts2): self.option_set.smart_update(opts1, opts2) + class Config(ConfigInterface): ''' A file based configuration.