'
'''
import os
-import re
import time
-from calibre.ebooks.metadata import MetaInformation
-from calibre.constants import filesystem_encoding, preferred_encoding
-from calibre import isbytestring
+from calibre.devices.usbms.books import Book as Book_
-class Book(MetaInformation):
+class Book(Book_):
- BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections', '_new_book']
-
- JSON_ATTRS = [
- 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
- 'title_sort', 'comments', 'category', 'publisher', 'series',
- 'series_index', 'rating', 'isbn', 'language', 'application_id',
- 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
- 'uuid', 'device_collections',
- ]
-
- def __init__(self, prefix, lpath, title, authors, mime, date, ContentType, thumbnail_name, size=None, other=None):
-
- MetaInformation.__init__(self, '')
- self.device_collections = []
- self._new_book = False
-
- self.path = os.path.join(prefix, lpath)
- if os.sep == '\\':
- self.path = self.path.replace('/', '\\')
- self.lpath = lpath.replace('\\', '/')
- else:
- self.lpath = lpath
+ def __init__(self, prefix, lpath, title, authors, mime, date, ContentType,
+ thumbnail_name, size=None, other=None):
+ Book_.__init__(self, prefix, lpath)
self.title = title
if not authors:
@@ -63,57 +41,7 @@ class Book(MetaInformation):
if other:
self.smart_update(other)
- def __eq__(self, other):
- return self.path == getattr(other, 'path', None)
-
- @dynamic_property
- def db_id(self):
- doc = '''The database id in the application database that this file corresponds to'''
- def fget(self):
- match = re.search(r'_(\d+)$', self.lpath.rpartition('.')[0])
- if match:
- return int(match.group(1))
- return None
- return property(fget=fget, doc=doc)
-
- @dynamic_property
- def title_sorter(self):
- doc = '''String to sort the title. If absent, title is returned'''
- def fget(self):
- return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip()
- return property(doc=doc, fget=fget)
-
- @dynamic_property
- def thumbnail(self):
- return None
-
- def smart_update(self, other, replace_metadata=False):
- '''
- Merge the information in C{other} into self. In case of conflicts, the information
- in C{other} takes precedence, unless the information in C{other} is NULL.
- '''
-
- MetaInformation.smart_update(self, other)
-
- for attr in self.BOOK_ATTRS:
- if hasattr(other, attr):
- val = getattr(other, attr, None)
- setattr(self, attr, val)
-
- def to_json(self):
- json = {}
- for attr in self.JSON_ATTRS:
- val = getattr(self, attr)
- if isbytestring(val):
- enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
- val = val.decode(enc, 'replace')
- elif isinstance(val, (list, tuple)):
- val = [x.decode(preferred_encoding, 'replace') if
- isbytestring(x) else x for x in val]
- json[attr] = val
- return json
-
class ImageWrapper(object):
def __init__(self, image_path):
- self.image_path = image_path
+ self.image_path = image_path
diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py
index 104553b675..b8516aab4f 100644
--- a/src/calibre/devices/kobo/driver.py
+++ b/src/calibre/devices/kobo/driver.py
@@ -30,7 +30,7 @@ class KOBO(USBMS):
# Ordered list of supported formats
FORMATS = ['epub', 'pdf']
- CAN_SET_METADATA = True
+ CAN_SET_METADATA = ['collections']
VENDOR_ID = [0x2237]
PRODUCT_ID = [0x4161]
@@ -126,7 +126,7 @@ class KOBO(USBMS):
book = self.book_from_path(prefix, lpath, title, authors, mime, date, ContentType, ImageID)
# print 'Update booklist'
book.device_collections = [playlist_map[lpath]] if lpath in playlist_map else []
-
+
if bl.add_book(book, replace_metadata=False):
changed = True
except: # Probably a path encoding error
@@ -150,7 +150,7 @@ class KOBO(USBMS):
changed = False
for i, row in enumerate(cursor):
- # self.report_progress((i+1) / float(numrows), _('Getting list of books on device...'))
+ # self.report_progress((i+1) / float(numrows), _('Getting list of books on device...'))
path = self.path_from_contentid(row[3], row[5], oncard)
mime = mime_type_ext(path_to_ext(row[3]))
@@ -250,7 +250,7 @@ class KOBO(USBMS):
# print "Delete file normalized path: " + path
extension = os.path.splitext(path)[1]
ContentType = self.get_content_type_from_extension(extension)
-
+
ContentID = self.contentid_from_path(path, ContentType)
ImageID = self.delete_via_sql(ContentID, ContentType)
@@ -453,7 +453,7 @@ class KOBO(USBMS):
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ReadStatus = 1 and ContentID like \'file:///mnt/sd/%\''
elif oncard != 'carda' and oncard != 'cardb':
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ReadStatus = 1 and ContentID not like \'file:///mnt/sd/%\''
-
+
try:
cursor.execute (query)
except:
@@ -489,7 +489,7 @@ class KOBO(USBMS):
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ReadStatus = 2 and ContentID like \'file:///mnt/sd/%\''
elif oncard != 'carda' and oncard != 'cardb':
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ReadStatus = 2 and ContentID not like \'file:///mnt/sd/%\''
-
+
try:
cursor.execute (query)
except:
@@ -519,7 +519,7 @@ class KOBO(USBMS):
else:
connection.commit()
# debug_print('Database: Commit set ReadStatus as Finished')
- else: # No collections
+ else: # No collections
# Since no collections exist the ReadStatus needs to be reset to 0 (Unread)
print "Reseting ReadStatus to 0"
# Reset Im_Reading list in the database
@@ -527,7 +527,7 @@ class KOBO(USBMS):
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ContentID like \'file:///mnt/sd/%\''
elif oncard != 'carda' and oncard != 'cardb':
query= 'update content set ReadStatus=0, FirstTimeReading = \'true\' where BookID is Null and ContentID not like \'file:///mnt/sd/%\''
-
+
try:
cursor.execute (query)
except:
@@ -541,7 +541,7 @@ class KOBO(USBMS):
connection.close()
# debug_print('Finished update_device_database_collections', collections_attributes)
-
+
def sync_booklists(self, booklists, end_session=True):
# debug_print('KOBO: started sync_booklists')
paths = self.get_device_paths()
diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py
index f90a8ab263..7952660c21 100644
--- a/src/calibre/devices/prs505/driver.py
+++ b/src/calibre/devices/prs505/driver.py
@@ -27,7 +27,7 @@ class PRS505(USBMS):
FORMATS = ['epub', 'lrf', 'lrx', 'rtf', 'pdf', 'txt']
- CAN_SET_METADATA = True
+ CAN_SET_METADATA = ['title', 'authors', 'collections']
VENDOR_ID = [0x054c] #: SONY Vendor Id
PRODUCT_ID = [0x031e]
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index 959f26199c..915d937379 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -6,29 +6,18 @@ __docformat__ = 'restructuredtext en'
import os, re, time, sys
-from calibre.ebooks.metadata import MetaInformation
+from calibre.ebooks.metadata.book.base import Metadata
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
-from calibre.constants import filesystem_encoding, preferred_encoding
+from calibre.constants import preferred_encoding
from calibre import isbytestring
-from calibre.utils.config import prefs
-
-class Book(MetaInformation):
-
- BOOK_ATTRS = ['lpath', 'size', 'mime', 'device_collections', '_new_book']
-
- JSON_ATTRS = [
- 'lpath', 'title', 'authors', 'mime', 'size', 'tags', 'author_sort',
- 'title_sort', 'comments', 'category', 'publisher', 'series',
- 'series_index', 'rating', 'isbn', 'language', 'application_id',
- 'book_producer', 'lccn', 'lcc', 'ddc', 'rights', 'publication_type',
- 'uuid',
- ]
+from calibre.utils.config import prefs, tweaks
+class Book(Metadata):
def __init__(self, prefix, lpath, size=None, other=None):
from calibre.ebooks.metadata.meta import path_to_ext
- MetaInformation.__init__(self, '')
+ Metadata.__init__(self, '')
self._new_book = False
self.device_collections = []
@@ -72,32 +61,6 @@ class Book(MetaInformation):
def thumbnail(self):
return None
- def smart_update(self, other, replace_metadata=False):
- '''
- Merge the information in C{other} into self. In case of conflicts, the information
- in C{other} takes precedence, unless the information in C{other} is NULL.
- '''
-
- MetaInformation.smart_update(self, other, replace_metadata)
-
- for attr in self.BOOK_ATTRS:
- if hasattr(other, attr):
- val = getattr(other, attr, None)
- setattr(self, attr, val)
-
- def to_json(self):
- json = {}
- for attr in self.JSON_ATTRS:
- val = getattr(self, attr)
- if isbytestring(val):
- enc = filesystem_encoding if attr == 'lpath' else preferred_encoding
- val = val.decode(enc, 'replace')
- elif isinstance(val, (list, tuple)):
- val = [x.decode(preferred_encoding, 'replace') if
- isbytestring(x) else x for x in val]
- json[attr] = val
- return json
-
class BookList(_BookList):
def __init__(self, oncard, prefix, settings):
@@ -131,11 +94,30 @@ class CollectionsBookList(BookList):
def supports_collections(self):
return True
+ def compute_category_name(self, attr, category, field_meta):
+ renames = tweaks['sony_collection_renaming_rules']
+ attr_name = renames.get(attr, None)
+ if attr_name is None:
+ if field_meta['is_custom']:
+ attr_name = '(%s)'%field_meta['name']
+ else:
+ attr_name = ''
+ elif attr_name != '':
+ attr_name = '(%s)'%attr_name
+ cat_name = '%s %s'%(category, attr_name)
+ return cat_name.strip()
+
def get_collections(self, collection_attributes):
from calibre.devices.usbms.driver import debug_print
debug_print('Starting get_collections:', prefs['manage_device_metadata'])
+ debug_print('Renaming rules:', tweaks['sony_collection_renaming_rules'])
+
+ # Complexity: we can use renaming rules only when using automatic
+ # management. Otherwise we don't always have the metadata to make the
+ # right decisions
+ use_renaming_rules = prefs['manage_device_metadata'] == 'on_connect'
+
collections = {}
- series_categories = set([])
# This map of sets is used to avoid linear searches when testing for
# book equality
collections_lpaths = {}
@@ -163,39 +145,72 @@ class CollectionsBookList(BookList):
attrs = collection_attributes
for attr in attrs:
attr = attr.strip()
- val = getattr(book, attr, None)
+ # If attr is device_collections, then we cannot use
+ # format_field, because we don't know the fields where the
+ # values came from.
+ if attr == 'device_collections':
+ doing_dc = True
+ val = book.device_collections # is a list
+ else:
+ doing_dc = False
+ ign, val, orig_val, fm = book.format_field_extended(attr)
+
if not val: continue
if isbytestring(val):
val = val.decode(preferred_encoding, 'replace')
if isinstance(val, (list, tuple)):
val = list(val)
- elif isinstance(val, unicode):
+ elif fm['datatype'] == 'series':
+ val = [orig_val]
+ elif fm['datatype'] == 'text' and fm['is_multiple']:
+ val = orig_val
+ else:
val = [val]
+
for category in val:
- if attr == 'tags' and len(category) > 1 and \
- category[0] == '[' and category[-1] == ']':
+ is_series = False
+ if doing_dc:
+ # Attempt to determine if this value is a series by
+ # comparing it to the series name.
+ if category == book.series:
+ is_series = True
+ elif fm['is_custom']: # is a custom field
+ if fm['datatype'] == 'text' and len(category) > 1 and \
+ category[0] == '[' and category[-1] == ']':
+ continue
+ if fm['datatype'] == 'series':
+ is_series = True
+ else: # is a standard field
+ if attr == 'tags' and len(category) > 1 and \
+ category[0] == '[' and category[-1] == ']':
+ continue
+ if attr == 'series' or \
+ ('series' in collection_attributes and
+ book.get('series', None) == category):
+ is_series = True
+ if use_renaming_rules:
+ cat_name = self.compute_category_name(attr, category, fm)
+ else:
+ cat_name = category
+
+ if cat_name not in collections:
+ collections[cat_name] = []
+ collections_lpaths[cat_name] = set()
+ if lpath in collections_lpaths[cat_name]:
continue
- if category not in collections:
- collections[category] = []
- collections_lpaths[category] = set()
- if lpath not in collections_lpaths[category]:
- collections_lpaths[category].add(lpath)
- collections[category].append(book)
- if attr == 'series' or \
- ('series' in collection_attributes and
- getattr(book, 'series', None) == category):
- series_categories.add(category)
+ collections_lpaths[cat_name].add(lpath)
+ if is_series:
+ collections[cat_name].append(
+ (book, book.get(attr+'_index', sys.maxint)))
+ else:
+ collections[cat_name].append(
+ (book, book.get('title_sort', 'zzzz')))
# Sort collections
+ result = {}
for category, books in collections.items():
- def tgetter(x):
- return getattr(x, 'title_sort', 'zzzz')
- books.sort(cmp=lambda x,y:cmp(tgetter(x), tgetter(y)))
- if category in series_categories:
- # Ensures books are sub sorted by title
- def getter(x):
- return getattr(x, 'series_index', sys.maxint)
- books.sort(cmp=lambda x,y:cmp(getter(x), getter(y)))
- return collections
+ books.sort(cmp=lambda x,y:cmp(x[1], y[1]))
+ result[category] = [x[0] for x in books]
+ return result
def rebuild_collections(self, booklist, oncard):
'''
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index b954911242..928d00ad4a 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -829,12 +829,14 @@ class Device(DeviceConfig, DevicePlugin):
ext = os.path.splitext(fname)[1]
from calibre.library.save_to_disk import get_components
+ from calibre.library.save_to_disk import config
+ opts = config().parse()
if not isinstance(template, unicode):
template = template.decode('utf-8')
app_id = str(getattr(mdata, 'application_id', ''))
# The db id will be in the created filename
extra_components = get_components(template, mdata, fname,
- length=250-len(app_id)-1)
+ timefmt=opts.send_timefmt, length=250-len(app_id)-1)
if not extra_components:
extra_components.append(sanitize(self.filename_callback(fname,
mdata)))
diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py
index 0d28f06f49..b4fe5d25fc 100644
--- a/src/calibre/devices/usbms/driver.py
+++ b/src/calibre/devices/usbms/driver.py
@@ -13,7 +13,6 @@ for a particular device.
import os
import re
import time
-import json
from itertools import cycle
from calibre import prints, isbytestring
@@ -21,6 +20,7 @@ from calibre.constants import filesystem_encoding, DEBUG
from calibre.devices.usbms.cli import CLI
from calibre.devices.usbms.device import Device
from calibre.devices.usbms.books import BookList, Book
+from calibre.ebooks.metadata.book.json_codec import JsonCodec
BASE_TIME = None
def debug_print(*args):
@@ -50,7 +50,7 @@ class USBMS(CLI, Device):
book_class = Book
FORMATS = []
- CAN_SET_METADATA = False
+ CAN_SET_METADATA = []
METADATA_CACHE = 'metadata.calibre'
def get_device_information(self, end_session=True):
@@ -288,6 +288,7 @@ class USBMS(CLI, Device):
# at the end just before the return
def sync_booklists(self, booklists, end_session=True):
debug_print('USBMS: starting sync_booklists')
+ json_codec = JsonCodec()
if not os.path.exists(self.normalize_path(self._main_prefix)):
os.makedirs(self.normalize_path(self._main_prefix))
@@ -296,10 +297,8 @@ class USBMS(CLI, Device):
if prefix is not None and isinstance(booklists[listid], self.booklist_class):
if not os.path.exists(prefix):
os.makedirs(self.normalize_path(prefix))
- js = [item.to_json() for item in booklists[listid] if
- hasattr(item, 'to_json')]
with open(self.normalize_path(os.path.join(prefix, self.METADATA_CACHE)), 'wb') as f:
- f.write(json.dumps(js, indent=2, encoding='utf-8'))
+ json_codec.encode_to_file(f, booklists[listid])
write_prefix(self._main_prefix, 0)
write_prefix(self._card_a_prefix, 1)
write_prefix(self._card_b_prefix, 2)
@@ -345,19 +344,13 @@ class USBMS(CLI, Device):
@classmethod
def parse_metadata_cache(cls, bl, prefix, name):
- # bl = cls.booklist_class()
- js = []
+ json_codec = JsonCodec()
need_sync = False
cache_file = cls.normalize_path(os.path.join(prefix, name))
if os.access(cache_file, os.R_OK):
try:
with open(cache_file, 'rb') as f:
- js = json.load(f, encoding='utf-8')
- for item in js:
- book = cls.book_class(prefix, item.get('lpath', None))
- for key in item.keys():
- setattr(book, key, item[key])
- bl.append(book)
+ json_codec.decode_from_file(f, bl, cls.book_class, prefix)
except:
import traceback
traceback.print_exc()
@@ -392,7 +385,7 @@ class USBMS(CLI, Device):
@classmethod
def book_from_path(cls, prefix, lpath):
- from calibre.ebooks.metadata import MetaInformation
+ from calibre.ebooks.metadata.book.base import Metadata
if cls.settings().read_metadata or cls.MUST_READ_METADATA:
mi = cls.metadata_from_path(cls.normalize_path(os.path.join(prefix, lpath)))
@@ -401,7 +394,7 @@ class USBMS(CLI, Device):
mi = metadata_from_filename(cls.normalize_path(os.path.basename(lpath)),
cls.build_template_regexp())
if mi is None:
- mi = MetaInformation(os.path.splitext(os.path.basename(lpath))[0],
+ mi = Metadata(os.path.splitext(os.path.basename(lpath))[0],
[_('Unknown')])
size = os.stat(cls.normalize_path(os.path.join(prefix, lpath))).st_size
book = cls.book_class(prefix, lpath, other=mi, size=size)
diff --git a/src/calibre/ebooks/conversion/plumber.py b/src/calibre/ebooks/conversion/plumber.py
index 395447edba..25b6d1aaae 100644
--- a/src/calibre/ebooks/conversion/plumber.py
+++ b/src/calibre/ebooks/conversion/plumber.py
@@ -701,7 +701,7 @@ OptionRecommendation(name='timestamp',
self.opts.read_metadata_from_opf)
opf = OPF(open(self.opts.read_metadata_from_opf, 'rb'),
os.path.dirname(self.opts.read_metadata_from_opf))
- mi = MetaInformation(opf)
+ mi = opf.to_book_metadata()
self.opts_to_mi(mi)
if mi.cover:
if mi.cover.startswith('http:') or mi.cover.startswith('https:'):
diff --git a/src/calibre/ebooks/conversion/preprocess.py b/src/calibre/ebooks/conversion/preprocess.py
index bb5c26a50c..c5ebae4bba 100644
--- a/src/calibre/ebooks/conversion/preprocess.py
+++ b/src/calibre/ebooks/conversion/preprocess.py
@@ -184,14 +184,14 @@ class Dehyphenator(object):
wraptags = match.group('wraptags')
except:
wraptags = ''
- hyphenated = str(firsthalf) + "-" + str(secondhalf)
- dehyphenated = str(firsthalf) + str(secondhalf)
+ hyphenated = unicode(firsthalf) + "-" + unicode(secondhalf)
+ dehyphenated = unicode(firsthalf) + unicode(secondhalf)
lookupword = self.removesuffixes.sub('', dehyphenated)
if self.prefixes.match(firsthalf) is None:
lookupword = self.removeprefix.sub('', lookupword)
#print "lookup word is: "+str(lookupword)+", orig is: " + str(hyphenated)
try:
- searchresult = self.html.find(str.lower(lookupword))
+ searchresult = self.html.find(lookupword.lower())
except:
return hyphenated
if self.format == 'html_cleanup':
diff --git a/src/calibre/ebooks/conversion/utils.py b/src/calibre/ebooks/conversion/utils.py
index 5f5c12a703..2faec27b68 100644
--- a/src/calibre/ebooks/conversion/utils.py
+++ b/src/calibre/ebooks/conversion/utils.py
@@ -22,18 +22,21 @@ class PreProcessor(object):
title = match.group('title')
if not title:
self.html_preprocess_sections = self.html_preprocess_sections + 1
- self.log("found " + str(self.html_preprocess_sections) + " chapters. - " + str(chap))
+ self.log("found " + unicode(self.html_preprocess_sections) +
+ " chapters. - " + unicode(chap))
return ''+chap+'
\n'
else:
self.html_preprocess_sections = self.html_preprocess_sections + 1
- self.log("found " + str(self.html_preprocess_sections) + " chapters & titles. - " + str(chap) + ", " + str(title))
+ self.log("found " + unicode(self.html_preprocess_sections) +
+ " chapters & titles. - " + unicode(chap) + ", " + unicode(title))
return ''+chap+'
\n'+title+'
\n'
def chapter_break(self, match):
chap = match.group('section')
styles = match.group('styles')
self.html_preprocess_sections = self.html_preprocess_sections + 1
- self.log("marked " + str(self.html_preprocess_sections) + " section markers based on punctuation. - " + str(chap))
+ self.log("marked " + unicode(self.html_preprocess_sections) +
+ " section markers based on punctuation. - " + unicode(chap))
return '<'+styles+' style="page-break-before:always">'+chap
def insert_indent(self, match):
@@ -63,7 +66,8 @@ class PreProcessor(object):
line_end = line_end_ere.findall(raw)
tot_htm_ends = len(htm_end)
tot_ln_fds = len(line_end)
- self.log("There are " + str(tot_ln_fds) + " total Line feeds, and " + str(tot_htm_ends) + " marked up endings")
+ self.log("There are " + unicode(tot_ln_fds) + " total Line feeds, and " +
+ unicode(tot_htm_ends) + " marked up endings")
if percent > 1:
percent = 1
@@ -71,7 +75,7 @@ class PreProcessor(object):
percent = 0
min_lns = tot_ln_fds * percent
- self.log("There must be fewer than " + str(min_lns) + " unmarked lines to add markup")
+ self.log("There must be fewer than " + unicode(min_lns) + " unmarked lines to add markup")
if min_lns > tot_htm_ends:
return True
@@ -112,7 +116,7 @@ class PreProcessor(object):
txtindent = re.compile(ur'[^>]*)>\s*(?P(]*>\s*)+)?\s*(\u00a0){2,}', re.IGNORECASE)
html = txtindent.sub(self.insert_indent, html)
if self.found_indents > 1:
- self.log("replaced "+str(self.found_indents)+ " nbsp indents with inline styles")
+ self.log("replaced "+unicode(self.found_indents)+ " nbsp indents with inline styles")
# remove remaining non-breaking spaces
html = re.sub(ur'\u00a0', ' ', html)
# Get rid of empty tags to simplify other processing
@@ -131,7 +135,8 @@ class PreProcessor(object):
lines = linereg.findall(html)
blanks_between_paragraphs = False
if len(lines) > 1:
- self.log("There are " + str(len(blanklines)) + " blank lines. " + str(float(len(blanklines)) / float(len(lines))) + " percent blank")
+ self.log("There are " + unicode(len(blanklines)) + " blank lines. " +
+ unicode(float(len(blanklines)) / float(len(lines))) + " percent blank")
if float(len(blanklines)) / float(len(lines)) > 0.40 and getattr(self.extra_opts,
'remove_paragraph_spacing', False):
self.log("deleting blank lines")
@@ -170,20 +175,20 @@ class PreProcessor(object):
#print chapter_marker
heading = re.compile(']*>', re.IGNORECASE)
self.html_preprocess_sections = len(heading.findall(html))
- self.log("found " + str(self.html_preprocess_sections) + " pre-existing headings")
+ self.log("found " + unicode(self.html_preprocess_sections) + " pre-existing headings")
#
# Start with most typical chapter headings, get more aggressive until one works
if self.html_preprocess_sections < 10:
chapdetect = re.compile(r'%s' % chapter_marker, re.IGNORECASE)
html = chapdetect.sub(self.chapter_head, html)
if self.html_preprocess_sections < 10:
- self.log("not enough chapters, only " + str(self.html_preprocess_sections) + ", trying numeric chapters")
+ self.log("not enough chapters, only " + unicode(self.html_preprocess_sections) + ", trying numeric chapters")
chapter_marker = lookahead+chapter_line_open+chapter_header_open+numeric_chapters+chapter_header_close+chapter_line_close+blank_lines+opt_title_open+title_line_open+title_header_open+default_title+title_header_close+title_line_close+opt_title_close
chapdetect2 = re.compile(r'%s' % chapter_marker, re.IGNORECASE)
html = chapdetect2.sub(self.chapter_head, html)
if self.html_preprocess_sections < 10:
- self.log("not enough chapters, only " + str(self.html_preprocess_sections) + ", trying with uppercase words")
+ self.log("not enough chapters, only " + unicode(self.html_preprocess_sections) + ", trying with uppercase words")
chapter_marker = lookahead+chapter_line_open+chapter_header_open+uppercase_chapters+chapter_header_close+chapter_line_close+blank_lines+opt_title_open+title_line_open+title_header_open+default_title+title_header_close+title_line_close+opt_title_close
chapdetect2 = re.compile(r'%s' % chapter_marker, re.UNICODE)
html = chapdetect2.sub(self.chapter_head, html)
@@ -207,11 +212,11 @@ class PreProcessor(object):
# more of the lines break in the same region of the document then unwrapping is required
docanalysis = DocAnalysis(format, html)
hardbreaks = docanalysis.line_histogram(.50)
- self.log("Hard line breaks check returned "+str(hardbreaks))
+ self.log("Hard line breaks check returned "+unicode(hardbreaks))
# Calculate Length
unwrap_factor = getattr(self.extra_opts, 'html_unwrap_factor', 0.4)
length = docanalysis.line_length(unwrap_factor)
- self.log("*** Median line length is " + str(length) + ", calculated with " + format + " format ***")
+ self.log("*** Median line length is " + unicode(length) + ", calculated with " + format + " format ***")
# only go through unwrapping code if the histogram shows unwrapping is required or if the user decreased the default unwrap_factor
if hardbreaks or unwrap_factor < 0.4:
self.log("Unwrapping required, unwrapping Lines")
@@ -240,7 +245,8 @@ class PreProcessor(object):
# If still no sections after unwrapping mark split points on lines with no punctuation
if self.html_preprocess_sections < 10:
- self.log("Looking for more split points based on punctuation, currently have " + str(self.html_preprocess_sections))
+ self.log("Looking for more split points based on punctuation,"
+ " currently have " + unicode(self.html_preprocess_sections))
chapdetect3 = re.compile(r'<(?P(p|div)[^>]*)>\s*(?P(]*>)?\s*(<[ibu][^>]*>){0,2}\s*(]*>)?\s*(<[ibu][^>]*>){0,2}\s*(]*>)?\s*.?(?=[a-z#\-*\s]+<)([a-z#-*]+\s*){1,5}\s*\s*()?([ibu]>){0,2}\s*()?\s*([ibu]>){0,2}\s*()?\s*(p|div)>)', re.IGNORECASE)
html = chapdetect3.sub(self.chapter_break, html)
# search for places where a first or second level heading is immediately followed by another
diff --git a/src/calibre/ebooks/epub/fix/epubcheck.py b/src/calibre/ebooks/epub/fix/epubcheck.py
index fd913a654b..81f4ce4d80 100644
--- a/src/calibre/ebooks/epub/fix/epubcheck.py
+++ b/src/calibre/ebooks/epub/fix/epubcheck.py
@@ -43,7 +43,11 @@ class Epubcheck(ePubFixer):
default=default)
except:
raise InvalidEpub('Invalid date set in OPF', raw)
- sval = ts.strftime('%Y-%m-%d')
+ try:
+ sval = ts.strftime('%Y-%m-%d')
+ except:
+ from calibre import strftime
+ sval = strftime('%Y-%m-%d', ts.timetuple())
if sval != raw:
self.log.error(
'OPF contains date', raw, 'that epubcheck does not like')
diff --git a/src/calibre/ebooks/epub/input.py b/src/calibre/ebooks/epub/input.py
index 214511ae14..cdd69ea50f 100644
--- a/src/calibre/ebooks/epub/input.py
+++ b/src/calibre/ebooks/epub/input.py
@@ -117,7 +117,8 @@ class EPUBInput(InputFormatPlugin):
encfile = os.path.abspath(os.path.join('META-INF', 'encryption.xml'))
opf = None
for f in walk(u'.'):
- if f.lower().endswith('.opf') and '__MACOSX' not in f:
+ if f.lower().endswith('.opf') and '__MACOSX' not in f and \
+ not os.path.basename(f).startswith('.'):
opf = os.path.abspath(f)
break
path = getattr(stream, 'name', 'stream')
diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py
index d4a21e2c8c..429ba06c6e 100644
--- a/src/calibre/ebooks/metadata/__init__.py
+++ b/src/calibre/ebooks/metadata/__init__.py
@@ -10,10 +10,9 @@ import os, mimetypes, sys, re
from urllib import unquote, quote
from urlparse import urlparse
-from calibre import relpath, prints
+from calibre import relpath
from calibre.utils.config import tweaks
-from calibre.utils.date import isoformat
_author_pat = re.compile(',?\s+(and|with)\s+', re.IGNORECASE)
def string_to_authors(raw):
@@ -221,214 +220,18 @@ class ResourceCollection(object):
-class MetaInformation(object):
- '''Convenient encapsulation of book metadata'''
-
- @staticmethod
- def copy(mi):
- ans = MetaInformation(mi.title, mi.authors)
- for attr in ('author_sort', 'title_sort', 'comments', 'category',
- 'publisher', 'series', 'series_index', 'rating',
- 'isbn', 'tags', 'cover_data', 'application_id', 'guide',
- 'manifest', 'spine', 'toc', 'cover', 'language',
- 'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc',
- 'author_sort_map',
- 'pubdate', 'rights', 'publication_type', 'uuid'):
- if hasattr(mi, attr):
- setattr(ans, attr, getattr(mi, attr))
-
- def __init__(self, title, authors=(_('Unknown'),)):
- '''
+def MetaInformation(title, authors=(_('Unknown'),)):
+ ''' Convenient encapsulation of book metadata, needed for compatibility
@param title: title or ``_('Unknown')`` or a MetaInformation object
@param authors: List of strings or []
- '''
- mi = None
- if hasattr(title, 'title') and hasattr(title, 'authors'):
- mi = title
- title = mi.title
- authors = mi.authors
- self.title = title
- self.author = list(authors) if authors else []# Needed for backward compatibility
- #: List of strings or []
- self.authors = list(authors) if authors else []
- self.tags = getattr(mi, 'tags', [])
- #: mi.cover_data = (ext, data)
- self.cover_data = getattr(mi, 'cover_data', (None, None))
- self.author_sort_map = getattr(mi, 'author_sort_map', {})
-
- for x in ('author_sort', 'title_sort', 'comments', 'category', 'publisher',
- 'series', 'series_index', 'rating', 'isbn', 'language',
- 'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
- 'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
- 'rights', 'publication_type', 'uuid',
- ):
- setattr(self, x, getattr(mi, x, None))
-
- def print_all_attributes(self):
- for x in ('title','author', 'author_sort', 'title_sort', 'comments', 'category', 'publisher',
- 'series', 'series_index', 'tags', 'rating', 'isbn', 'language',
- 'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover',
- 'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate',
- 'rights', 'publication_type', 'uuid', 'author_sort_map'
- ):
- prints(x, getattr(self, x, 'None'))
-
- def smart_update(self, mi, replace_metadata=False):
- '''
- Merge the information in C{mi} into self. In case of conflicts, the
- information in C{mi} takes precedence, unless the information in mi is
- NULL. If replace_metadata is True, then the information in mi always
- takes precedence.
- '''
- if mi.title and mi.title != _('Unknown'):
- self.title = mi.title
-
- if mi.authors and mi.authors[0] != _('Unknown'):
- self.authors = mi.authors
-
- for attr in ('author_sort', 'title_sort', 'category',
- 'publisher', 'series', 'series_index', 'rating',
- 'isbn', 'application_id', 'manifest', 'spine', 'toc',
- 'cover', 'guide', 'book_producer',
- 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate', 'rights',
- 'publication_type', 'uuid'):
- if replace_metadata:
- setattr(self, attr, getattr(mi, attr, 1.0 if \
- attr == 'series_index' else None))
- elif hasattr(mi, attr):
- val = getattr(mi, attr)
- if val is not None:
- setattr(self, attr, val)
-
- if replace_metadata:
- self.tags = mi.tags
- elif mi.tags:
- self.tags += mi.tags
- self.tags = list(set(self.tags))
-
- if mi.author_sort_map:
- self.author_sort_map.update(mi.author_sort_map)
-
- if getattr(mi, 'cover_data', False):
- other_cover = mi.cover_data[-1]
- self_cover = self.cover_data[-1] if self.cover_data else ''
- if not self_cover: self_cover = ''
- if not other_cover: other_cover = ''
- if len(other_cover) > len(self_cover):
- self.cover_data = mi.cover_data
-
- if replace_metadata:
- self.comments = getattr(mi, 'comments', '')
- else:
- my_comments = getattr(self, 'comments', '')
- other_comments = getattr(mi, 'comments', '')
- if not my_comments:
- my_comments = ''
- if not other_comments:
- other_comments = ''
- if len(other_comments.strip()) > len(my_comments.strip()):
- self.comments = other_comments
-
- other_lang = getattr(mi, 'language', None)
- if other_lang and other_lang.lower() != 'und':
- self.language = other_lang
-
-
- def format_series_index(self):
- try:
- x = float(self.series_index)
- except ValueError:
- x = 1
- return fmt_sidx(x)
-
- 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):
- ans.append(u'%-20s: %s'%(unicode(x), unicode(y)))
-
- fmt('Title', self.title)
- if self.title_sort:
- fmt('Title sort', self.title_sort)
- if self.authors:
- fmt('Author(s)', authors_to_string(self.authors) + \
- ((' [' + self.author_sort + ']') if self.author_sort else ''))
- if self.publisher:
- fmt('Publisher', self.publisher)
- if getattr(self, 'book_producer', False):
- fmt('Book Producer', self.book_producer)
- if self.category:
- fmt('Category', self.category)
- if self.comments:
- fmt('Comments', self.comments)
- if self.isbn:
- fmt('ISBN', self.isbn)
- if self.tags:
- fmt('Tags', u', '.join([unicode(t) for t in self.tags]))
- if self.series:
- fmt('Series', self.series + ' #%s'%self.format_series_index())
- if self.language:
- fmt('Language', self.language)
- if self.rating is not None:
- fmt('Rating', self.rating)
- if self.timestamp is not None:
- fmt('Timestamp', isoformat(self.timestamp))
- if self.pubdate is not None:
- fmt('Published', isoformat(self.pubdate))
- if self.rights is not None:
- fmt('Rights', unicode(self.rights))
- if self.lccn:
- fmt('LCCN', unicode(self.lccn))
- if self.lcc:
- fmt('LCC', unicode(self.lcc))
- if self.ddc:
- fmt('DDC', unicode(self.ddc))
-
- return u'\n'.join(ans)
-
- def to_html(self):
- ans = [(_('Title'), unicode(self.title))]
- ans += [(_('Author(s)'), (authors_to_string(self.authors) if self.authors else _('Unknown')))]
- ans += [(_('Publisher'), unicode(self.publisher))]
- ans += [(_('Producer'), unicode(self.book_producer))]
- ans += [(_('Comments'), unicode(self.comments))]
- ans += [('ISBN', unicode(self.isbn))]
- if self.lccn:
- ans += [('LCCN', unicode(self.lccn))]
- if self.lcc:
- ans += [('LCC', unicode(self.lcc))]
- if self.ddc:
- ans += [('DDC', unicode(self.ddc))]
- ans += [(_('Tags'), u', '.join([unicode(t) for t in self.tags]))]
- if self.series:
- ans += [(_('Series'), unicode(self.series)+ ' #%s'%self.format_series_index())]
- ans += [(_('Language'), unicode(self.language))]
- if self.timestamp is not None:
- ans += [(_('Timestamp'), unicode(self.timestamp.isoformat(' ')))]
- if self.pubdate is not None:
- ans += [(_('Published'), unicode(self.pubdate.isoformat(' ')))]
- if self.rights is not None:
- ans += [(_('Rights'), unicode(self.rights))]
- for i, x in enumerate(ans):
- ans[i] = u'%s | %s |
'%x
- return u''%u'\n'.join(ans)
-
- def __str__(self):
- return self.__unicode__().encode('utf-8')
-
- def __nonzero__(self):
- return bool(self.title or self.author or self.comments or self.tags)
+ '''
+ from calibre.ebooks.metadata.book.base import Metadata
+ mi = None
+ if hasattr(title, 'title') and hasattr(title, 'authors'):
+ mi = title
+ title = mi.title
+ authors = mi.authors
+ return Metadata(title, authors, other=mi)
def check_isbn10(isbn):
try:
diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py
index c3b95f1188..e6dff9110b 100644
--- a/src/calibre/ebooks/metadata/book/__init__.py
+++ b/src/calibre/ebooks/metadata/book/__init__.py
@@ -11,48 +11,45 @@ an empty list/dictionary for complex types and (None, None) for cover_data
'''
SOCIAL_METADATA_FIELDS = frozenset([
- 'tags', # Ordered list
- # A floating point number between 0 and 10
- 'rating',
- # A simple HTML enabled string
- 'comments',
- # A simple string
- 'series',
- # A floating point number
- 'series_index',
+ 'tags', # Ordered list
+ 'rating', # A floating point number between 0 and 10
+ 'comments', # A simple HTML enabled string
+ 'series', # A simple string
+ 'series_index', # A floating point number
# Of the form { scheme1:value1, scheme2:value2}
# For example: {'isbn':'123456789', 'doi':'xxxx', ... }
'classifiers',
- 'isbn', # Pseudo field for convenience, should get/set isbn classifier
+])
+'''
+The list of names that convert to classifiers when in get and set.
+'''
+
+TOP_LEVEL_CLASSIFIERS = frozenset([
+ 'isbn',
])
PUBLICATION_METADATA_FIELDS = frozenset([
- # title must never be None. Should be _('Unknown')
- 'title',
+ 'title', # title must never be None. Should be _('Unknown')
# Pseudo field that can be set, but if not set is auto generated
# from title and languages
'title_sort',
- # Ordered list of authors. Must never be None, can be [_('Unknown')]
- 'authors',
- # Map of sort strings for each author
- 'author_sort_map',
+ 'authors', # Ordered list. Must never be None, can be [_('Unknown')]
+ 'author_sort_map', # Map of sort strings for each author
# Pseudo field that can be set, but if not set is auto generated
# from authors and languages
'author_sort',
'book_producer',
- # Dates and times must be timezone aware
- 'timestamp',
+ 'timestamp', # Dates and times must be timezone aware
'pubdate',
'rights',
# So far only known publication type is periodical:calibre
# If None, means book
'publication_type',
- # A UUID usually of type 4
- 'uuid',
- 'languages', # ordered list
- # Simple string, no special semantics
- 'publisher',
+ 'uuid', # A UUID usually of type 4
+ 'language', # the primary language of this book
+ 'languages', # ordered list
+ 'publisher', # Simple string, no special semantics
# Absolute path to image file encoded in filesystem_encoding
'cover',
# Of the form (format, data) where format is, for e.g. 'jpeg', 'png', 'gif'...
@@ -69,33 +66,62 @@ BOOK_STRUCTURE_FIELDS = frozenset([
])
USER_METADATA_FIELDS = frozenset([
- # A dict of a form to be specified
+ # A dict of dicts similar to field_metadata. Each field description dict
+ # also contains a value field with the key #value#.
'user_metadata',
])
DEVICE_METADATA_FIELDS = frozenset([
- # Ordered list of strings
- 'device_collections',
- 'lpath', # Unicode, / separated
- # In bytes
- 'size',
- # Mimetype of the book file being represented
- 'mime',
+ 'device_collections', # Ordered list of strings
+ 'lpath', # Unicode, / separated
+ 'size', # In bytes
+ 'mime', # Mimetype of the book file being represented
+
])
CALIBRE_METADATA_FIELDS = frozenset([
- # An application id
- # Semantics to be defined. Is it a db key? a db name + key? A uuid?
- 'application_id',
+ 'application_id', # An application id, currently set to the db_id.
+ 'db_id', # the calibre primary key of the item.
+ 'formats', # list of formats (extensions) for this book
]
)
+ALL_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ PUBLICATION_METADATA_FIELDS).union(
+ BOOK_STRUCTURE_FIELDS).union(
+ USER_METADATA_FIELDS).union(
+ DEVICE_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS)
-SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
- USER_METADATA_FIELDS).union(
- PUBLICATION_METADATA_FIELDS).union(
- CALIBRE_METADATA_FIELDS).union(
- frozenset(['lpath'])) # I don't think we need device_collections
+# All fields except custom fields
+STANDARD_METADATA_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ PUBLICATION_METADATA_FIELDS).union(
+ BOOK_STRUCTURE_FIELDS).union(
+ DEVICE_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS)
-# Serialization of covers/thumbnails will have to be handled carefully, maybe
-# as an option to the serializer class
+# Metadata fields that smart update must do special processing to copy.
+
+SC_FIELDS_NOT_COPIED = frozenset(['title', 'title_sort', 'authors',
+ 'author_sort', 'author_sort_map',
+ 'cover_data', 'tags', 'language'])
+
+# Metadata fields that smart update should copy only if the source is not None
+SC_FIELDS_COPY_NOT_NULL = frozenset(['lpath', 'size', 'comments', 'thumbnail'])
+
+# Metadata fields that smart update should copy without special handling
+SC_COPYABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ PUBLICATION_METADATA_FIELDS).union(
+ BOOK_STRUCTURE_FIELDS).union(
+ DEVICE_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS) - \
+ SC_FIELDS_NOT_COPIED.union(
+ SC_FIELDS_COPY_NOT_NULL)
+
+SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(
+ USER_METADATA_FIELDS).union(
+ PUBLICATION_METADATA_FIELDS).union(
+ CALIBRE_METADATA_FIELDS).union(
+ DEVICE_METADATA_FIELDS) - \
+ 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 3fed47091f..17611875f8 100644
--- a/src/calibre/ebooks/metadata/book/base.py
+++ b/src/calibre/ebooks/metadata/book/base.py
@@ -5,9 +5,18 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import copy
+import copy, traceback
+
+from calibre import prints
+from calibre.ebooks.metadata.book import SC_COPYABLE_FIELDS
+from calibre.ebooks.metadata.book import SC_FIELDS_COPY_NOT_NULL
+from calibre.ebooks.metadata.book import STANDARD_METADATA_FIELDS
+from calibre.ebooks.metadata.book import TOP_LEVEL_CLASSIFIERS
+from calibre.ebooks.metadata.book import ALL_METADATA_FIELDS
+from calibre.library.field_metadata import FieldMetadata
+from calibre.utils.date import isoformat, format_date
+from calibre.utils.formatter import TemplateFormatter
-from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
NULL_VALUES = {
'user_metadata': {},
@@ -19,103 +28,555 @@ NULL_VALUES = {
'author_sort_map': {},
'authors' : [_('Unknown')],
'title' : _('Unknown'),
+ 'language' : 'und'
}
+field_metadata = FieldMetadata()
+
+class SafeFormat(TemplateFormatter):
+
+ def get_value(self, key, args, kwargs):
+ try:
+ b = self.book.get_user_metadata(key, False)
+ if b and b['datatype'] == 'int' and self.book.get(key, 0) == 0:
+ v = ''
+ elif b and b['datatype'] == 'float' and b.get(key, 0.0) == 0.0:
+ v = ''
+ else:
+ ign, v = self.book.format_field(key.lower(), series_with_index=False)
+ if v is None:
+ return ''
+ if v == '':
+ return ''
+ return v
+ except:
+ return key
+
+composite_formatter = SafeFormat()
+
class Metadata(object):
'''
- This class must expose a superset of the API of MetaInformation in terms
- of attribute access and methods. Only the __init__ method is different.
- MetaInformation will simply become a function that creates and fills in
- the attributes of this class.
+ A class representing all the metadata for a book.
Please keep the method based API of this class to a minimum. Every method
becomes a reserved field name.
'''
- def __init__(self):
- object.__setattr__(self, '_data', copy.deepcopy(NULL_VALUES))
+ def __init__(self, title, authors=(_('Unknown'),), other=None):
+ '''
+ @param title: title or ``_('Unknown')``
+ @param authors: List of strings or []
+ @param other: None or a metadata object
+ '''
+ _data = copy.deepcopy(NULL_VALUES)
+ object.__setattr__(self, '_data', _data)
+ if other is not None:
+ self.smart_update(other)
+ else:
+ if title:
+ self.title = title
+ if authors:
+ #: List of strings or []
+ self.author = list(authors) if authors else []# Needed for backward compatibility
+ self.authors = list(authors) if authors else []
+
+ def is_null(self, field):
+ null_val = NULL_VALUES.get(field, None)
+ val = getattr(self, field, None)
+ return not val or val == null_val
def __getattribute__(self, field):
_data = object.__getattribute__(self, '_data')
- if field in RESERVED_METADATA_FIELDS:
+ if field in TOP_LEVEL_CLASSIFIERS:
+ return _data.get('classifiers').get(field, None)
+ if field in STANDARD_METADATA_FIELDS:
return _data.get(field, None)
try:
return object.__getattribute__(self, field)
except AttributeError:
pass
if field in _data['user_metadata'].iterkeys():
- # TODO: getting user metadata values
- pass
+ d = _data['user_metadata'][field]
+ val = d['#value#']
+ if d['datatype'] != 'composite':
+ return val
+ if val is None:
+ d['#value#'] = 'RECURSIVE_COMPOSITE FIELD (Metadata) ' + field
+ val = d['#value#'] = composite_formatter.safe_format(
+ d['display']['composite_template'],
+ self,
+ _('TEMPLATE ERROR'),
+ self).strip()
+ return val
+
raise AttributeError(
'Metadata object has no attribute named: '+ repr(field))
-
- def __setattr__(self, field, val):
+ def __setattr__(self, field, val, extra=None):
_data = object.__getattribute__(self, '_data')
- if field in RESERVED_METADATA_FIELDS:
- if field != 'user_metadata':
- if not val:
- val = NULL_VALUES[field]
- _data[field] = val
- else:
- raise AttributeError('You cannot set user_metadata directly.')
+ if field in TOP_LEVEL_CLASSIFIERS:
+ _data['classifiers'].update({field: val})
+ elif field in STANDARD_METADATA_FIELDS:
+ if val is None:
+ val = NULL_VALUES.get(field, None)
+ _data[field] = val
elif field in _data['user_metadata'].iterkeys():
- # TODO: Setting custom column values
- pass
+ if _data['user_metadata'][field]['datatype'] == 'composite':
+ _data['user_metadata'][field]['#value#'] = None
+ else:
+ _data['user_metadata'][field]['#value#'] = val
+ _data['user_metadata'][field]['#extra#'] = extra
else:
# You are allowed to stick arbitrary attributes onto this object as
- # long as they dont conflict with global or user metadata names
+ # long as they don't conflict with global or user metadata names
# Don't abuse this privilege
self.__dict__[field] = val
- @property
- def user_metadata_names(self):
- 'The set of user metadata names this object knows about'
+ def __iter__(self):
+ return object.__getattribute__(self, '_data').iterkeys()
+
+ def has_key(self, key):
+ return key in object.__getattribute__(self, '_data')
+
+ def deepcopy(self):
+ m = Metadata(None)
+ m.__dict__ = copy.deepcopy(self.__dict__)
+ object.__setattr__(m, '_data', copy.deepcopy(object.__getattribute__(self, '_data')))
+ return m
+
+ def get(self, field, default=None):
+ try:
+ return self.__getattribute__(field)
+ except AttributeError:
+ return default
+
+ def get_extra(self, field):
_data = object.__getattribute__(self, '_data')
- return frozenset(_data['user_metadata'].iterkeys())
+ if field in _data['user_metadata'].iterkeys():
+ return _data['user_metadata'][field]['#extra#']
+ raise AttributeError(
+ 'Metadata object has no attribute named: '+ repr(field))
- # Old MetaInformation API {{{
- def copy(self):
- pass
+ def set(self, field, val, extra=None):
+ self.__setattr__(field, val, extra)
+ # field-oriented interface. Intended to be the same as in LibraryDatabase
+
+ def standard_field_keys(self):
+ '''
+ return a list of all possible keys, even if this book doesn't have them
+ '''
+ return STANDARD_METADATA_FIELDS
+
+ def custom_field_keys(self):
+ '''
+ return a list of the custom fields in this book
+ '''
+ return object.__getattribute__(self, '_data')['user_metadata'].iterkeys()
+
+ def all_field_keys(self):
+ '''
+ All field keys known by this instance, even if their value is None
+ '''
+ _data = object.__getattribute__(self, '_data')
+ return frozenset(ALL_METADATA_FIELDS.union(_data['user_metadata'].iterkeys()))
+
+ def metadata_for_field(self, key):
+ '''
+ return metadata describing a standard or custom field.
+ '''
+ if key not in self.custom_field_keys():
+ return self.get_standard_metadata(key, make_copy=False)
+ return self.get_user_metadata(key, make_copy=False)
+
+ def all_non_none_fields(self):
+ '''
+ Return a dictionary containing all non-None metadata fields, including
+ the custom ones.
+ '''
+ result = {}
+ _data = object.__getattribute__(self, '_data')
+ for attr in STANDARD_METADATA_FIELDS:
+ v = _data.get(attr, None)
+ if v is not None:
+ result[attr] = v
+ for attr in _data['user_metadata'].iterkeys():
+ v = self.get(attr, None)
+ if v is not None:
+ result[attr] = v
+ if _data['user_metadata'][attr]['datatype'] == 'series':
+ result[attr+'_index'] = _data['user_metadata'][attr]['#extra#']
+ return result
+
+ # End of field-oriented interface
+
+ # Extended interfaces. These permit one to get copies of metadata dictionaries, and to
+ # get and set custom field metadata
+
+ def get_standard_metadata(self, field, make_copy):
+ '''
+ return field metadata from the field if it is there. Otherwise return
+ None. field is the key name, not the label. Return a copy if requested,
+ just in case the user wants to change values in the dict.
+ '''
+ if field in field_metadata and field_metadata[field]['kind'] == 'field':
+ if make_copy:
+ return copy.deepcopy(field_metadata[field])
+ return field_metadata[field]
+ return None
+
+ def get_all_standard_metadata(self, make_copy):
+ '''
+ return a dict containing all the standard field metadata associated with
+ the book.
+ '''
+ if not make_copy:
+ return field_metadata
+ res = {}
+ for k in field_metadata:
+ if field_metadata[k]['kind'] == 'field':
+ res[k] = copy.deepcopy(field_metadata[k])
+ return res
+
+ def get_all_user_metadata(self, make_copy):
+ '''
+ return a dict containing all the custom field metadata associated with
+ the book.
+ '''
+ _data = object.__getattribute__(self, '_data')
+ user_metadata = _data['user_metadata']
+ if not make_copy:
+ return user_metadata
+ res = {}
+ for k in user_metadata:
+ res[k] = copy.deepcopy(user_metadata[k])
+ return res
+
+ def get_user_metadata(self, field, make_copy):
+ '''
+ return field metadata from the object if it is there. Otherwise return
+ None. field is the key name, not the label. Return a copy if requested,
+ just in case the user wants to change values in the dict.
+ '''
+ _data = object.__getattribute__(self, '_data')
+ _data = _data['user_metadata']
+ if field in _data:
+ if make_copy:
+ return copy.deepcopy(_data[field])
+ return _data[field]
+ return None
+
+ def set_all_user_metadata(self, metadata):
+ '''
+ store custom field metadata into the object. Field is the key name
+ not the label
+ '''
+ if metadata is None:
+ traceback.print_stack()
+ else:
+ for key in metadata:
+ self.set_user_metadata(key, metadata[key])
+
+ def set_user_metadata(self, field, metadata):
+ '''
+ store custom field metadata for one column into the object. Field is
+ the key name not the label
+ '''
+ if field is not None:
+ if not field.startswith('#'):
+ raise AttributeError(
+ 'Custom field name %s must begin with \'#\''%repr(field))
+ if metadata is None:
+ traceback.print_stack()
+ return
+ metadata = copy.deepcopy(metadata)
+ if '#value#' not in metadata:
+ if metadata['datatype'] == 'text' and metadata['is_multiple']:
+ metadata['#value#'] = []
+ else:
+ metadata['#value#'] = None
+ _data = object.__getattribute__(self, '_data')
+ _data['user_metadata'][field] = metadata
+
+ def template_to_attribute(self, other, ops):
+ '''
+ Takes a list [(src,dest), (src,dest)], evaluates the template in the
+ context of other, then copies the result to self[dest]. This is on a
+ best-efforts basis. Some assignments can make no sense.
+ '''
+ if not ops:
+ return
+ for op in ops:
+ try:
+ src = op[0]
+ dest = op[1]
+ val = composite_formatter.safe_format\
+ (src, other, 'PLUGBOARD TEMPLATE ERROR', other)
+ if dest == 'tags':
+ self.set(dest, [f.strip() for f in val.split(',') if f.strip()])
+ elif dest == 'authors':
+ self.set(dest, [f.strip() for f in val.split('&') if f.strip()])
+ else:
+ self.set(dest, val)
+ except:
+ traceback.print_exc()
+ pass
+
+ # Old Metadata API {{{
def print_all_attributes(self):
- pass
+ for x in STANDARD_METADATA_FIELDS:
+ prints('%s:'%x, getattr(self, x, 'None'))
+ for x in self.custom_field_keys():
+ meta = self.get_user_metadata(x, make_copy=False)
+ if meta is not None:
+ prints(x, meta)
+ prints('--------------')
def smart_update(self, other, replace_metadata=False):
- pass
+ '''
+ Merge the information in `other` into self. In case of conflicts, the information
+ in `other` takes precedence, unless the information in `other` is NULL.
+ '''
+ def copy_not_none(dest, src, attr):
+ v = getattr(src, attr, None)
+ if v not in (None, NULL_VALUES.get(attr, None)):
+ setattr(dest, attr, copy.deepcopy(v))
- def format_series_index(self):
- pass
+ if other.title and other.title != _('Unknown'):
+ self.title = other.title
+ if hasattr(other, 'title_sort'):
+ self.title_sort = other.title_sort
+
+ if other.authors and other.authors[0] != _('Unknown'):
+ self.authors = list(other.authors)
+ if hasattr(other, 'author_sort_map'):
+ self.author_sort_map = dict(other.author_sort_map)
+ if hasattr(other, 'author_sort'):
+ self.author_sort = other.author_sort
+
+ if replace_metadata:
+ # SPECIAL_FIELDS = frozenset(['lpath', 'size', 'comments', 'thumbnail'])
+ for attr in SC_COPYABLE_FIELDS:
+ setattr(self, attr, getattr(other, attr, 1.0 if \
+ attr == 'series_index' else None))
+ self.tags = other.tags
+ self.cover_data = getattr(other, 'cover_data',
+ NULL_VALUES['cover_data'])
+ 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)
+ # language is handled below
+ else:
+ for attr in SC_COPYABLE_FIELDS:
+ copy_not_none(self, other, attr)
+ for x in SC_FIELDS_COPY_NOT_NULL:
+ copy_not_none(self, other, x)
+
+ if other.tags:
+ # Case-insensitive but case preserving merging
+ lotags = [t.lower() for t in other.tags]
+ lstags = [t.lower() for t in self.tags]
+ ot, st = map(frozenset, (lotags, lstags))
+ for t in st.intersection(ot):
+ sidx = lstags.index(t)
+ oidx = lotags.index(t)
+ self.tags[sidx] = other.tags[oidx]
+ self.tags += [t for t in other.tags if t.lower() in ot-st]
+
+ if getattr(other, 'cover_data', False):
+ other_cover = other.cover_data[-1]
+ self_cover = self.cover_data[-1] if self.cover_data else ''
+ if not self_cover: self_cover = ''
+ if not other_cover: other_cover = ''
+ if len(other_cover) > len(self_cover):
+ self.cover_data = other.cover_data
+
+ if callable(getattr(other, 'custom_field_keys', None)):
+ for x in other.custom_field_keys():
+ meta = other.get_user_metadata(x, make_copy=True)
+ if meta is not None:
+ self_tags = self.get(x, [])
+ self.set_user_metadata(x, meta) # get... did the deepcopy
+ other_tags = other.get(x, [])
+ if meta['is_multiple']:
+ # Case-insensitive but case preserving merging
+ lotags = [t.lower() for t in other_tags]
+ lstags = [t.lower() for t in self_tags]
+ ot, st = map(frozenset, (lotags, lstags))
+ for t in st.intersection(ot):
+ sidx = lstags.index(t)
+ oidx = lotags.index(t)
+ self_tags[sidx] = other.tags[oidx]
+ self_tags += [t for t in other.tags if t.lower() in ot-st]
+ setattr(self, x, self_tags)
+
+ my_comments = getattr(self, 'comments', '')
+ other_comments = getattr(other, 'comments', '')
+ if not my_comments:
+ my_comments = ''
+ if not other_comments:
+ other_comments = ''
+ if len(other_comments.strip()) > len(my_comments.strip()):
+ self.comments = other_comments
+
+ other_lang = getattr(other, 'language', None)
+ if other_lang and other_lang.lower() != 'und':
+ self.language = other_lang
+
+ def format_series_index(self, val=None):
+ from calibre.ebooks.metadata import fmt_sidx
+ v = self.series_index if val is None else val
+ try:
+ x = float(v)
+ except ValueError:
+ x = 1
+ return fmt_sidx(x)
def authors_from_string(self, raw):
- pass
+ from calibre.ebooks.metadata import string_to_authors
+ self.authors = string_to_authors(raw)
def format_authors(self):
- pass
+ from calibre.ebooks.metadata import authors_to_string
+ return authors_to_string(self.authors)
def format_tags(self):
- pass
+ return u', '.join([unicode(t) for t in self.tags])
def format_rating(self):
return unicode(self.rating)
+ 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, series_with_index=True):
+ from calibre.ebooks.metadata import authors_to_string
+ '''
+ returns the tuple (field_name, formatted_value)
+ '''
+ if key in self.custom_field_keys():
+ res = self.get(key, None)
+ cmeta = self.get_user_metadata(key, make_copy=False)
+ name = unicode(cmeta['name'])
+ if cmeta['datatype'] != 'composite' and (res is None or res == ''):
+ return (name, res, None, None)
+ orig_res = res
+ cmeta = self.get_user_metadata(key, make_copy=False)
+ if res is None or res == '':
+ return (name, res, None, None)
+ orig_res = res
+ datatype = cmeta['datatype']
+ 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))
+ 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 == 'float' and key.endswith('_index'):
+ res = self.format_series_index(res)
+ return (name, unicode(res), orig_res, cmeta)
+
+ if key in field_metadata and field_metadata[key]['kind'] == 'field':
+ res = self.get(key, None)
+ fmeta = field_metadata[key]
+ name = unicode(fmeta['name'])
+ if res is None or res == '':
+ return (name, res, None, None)
+ orig_res = res
+ name = unicode(fmeta['name'])
+ datatype = fmeta['datatype']
+ if key == 'authors':
+ res = authors_to_string(res)
+ elif key == 'series_index':
+ res = self.format_series_index(res)
+ elif datatype == 'text' and fmeta['is_multiple']:
+ res = u', '.join(res)
+ 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'))
+ return (name, unicode(res), orig_res, fmeta)
+
+ return (None, None, None, None)
+
def __unicode__(self):
- pass
+ from calibre.ebooks.metadata import authors_to_string
+ ans = []
+ def fmt(x, y):
+ ans.append(u'%-20s: %s'%(unicode(x), unicode(y)))
+
+ fmt('Title', self.title)
+ if self.title_sort:
+ fmt('Title sort', self.title_sort)
+ if self.authors:
+ fmt('Author(s)', authors_to_string(self.authors) + \
+ ((' [' + self.author_sort + ']') if self.author_sort else ''))
+ if self.publisher:
+ fmt('Publisher', self.publisher)
+ if getattr(self, 'book_producer', False):
+ fmt('Book Producer', self.book_producer)
+ if self.comments:
+ fmt('Comments', self.comments)
+ if self.isbn:
+ fmt('ISBN', self.isbn)
+ if self.tags:
+ fmt('Tags', u', '.join([unicode(t) for t in self.tags]))
+ if self.series:
+ fmt('Series', self.series + ' #%s'%self.format_series_index())
+ if self.language:
+ fmt('Language', self.language)
+ if self.rating is not None:
+ fmt('Rating', self.rating)
+ if self.timestamp is not None:
+ fmt('Timestamp', isoformat(self.timestamp))
+ if self.pubdate is not None:
+ fmt('Published', isoformat(self.pubdate))
+ if self.rights is not None:
+ fmt('Rights', unicode(self.rights))
+ for key in self.custom_field_keys():
+ val = self.get(key, None)
+ if val:
+ (name, val) = self.format_field(key)
+ fmt(name, unicode(val))
+ return u'\n'.join(ans)
def to_html(self):
- pass
+ from calibre.ebooks.metadata import authors_to_string
+ ans = [(_('Title'), unicode(self.title))]
+ ans += [(_('Author(s)'), (authors_to_string(self.authors) if self.authors else _('Unknown')))]
+ ans += [(_('Publisher'), unicode(self.publisher))]
+ ans += [(_('Producer'), unicode(self.book_producer))]
+ ans += [(_('Comments'), unicode(self.comments))]
+ ans += [('ISBN', unicode(self.isbn))]
+ ans += [(_('Tags'), u', '.join([unicode(t) for t in self.tags]))]
+ if self.series:
+ ans += [(_('Series'), unicode(self.series)+ ' #%s'%self.format_series_index())]
+ ans += [(_('Language'), unicode(self.language))]
+ if self.timestamp is not None:
+ ans += [(_('Timestamp'), unicode(self.timestamp.isoformat(' ')))]
+ if self.pubdate is not None:
+ ans += [(_('Published'), unicode(self.pubdate.isoformat(' ')))]
+ if self.rights is not None:
+ ans += [(_('Rights'), unicode(self.rights))]
+ for key in self.custom_field_keys():
+ val = self.get(key, None)
+ if val:
+ (name, val) = self.format_field(key)
+ ans += [(name, val)]
+ for i, x in enumerate(ans):
+ ans[i] = u'%s | %s |
'%x
+ return u''%u'\n'.join(ans)
def __str__(self):
return self.__unicode__().encode('utf-8')
def __nonzero__(self):
- return True
+ return bool(self.title or self.author or self.comments or self.tags)
# }}}
-# We don't need reserved field names for this object any more. Lets just use a
-# protocol like the last char of a user field label should be _ when using this
-# object
-# So mi.tags returns the builtin tags and mi.tags_ returns the user tags
-
diff --git a/src/calibre/ebooks/metadata/book/json_codec.py b/src/calibre/ebooks/metadata/book/json_codec.py
new file mode 100644
index 0000000000..c02d4e953d
--- /dev/null
+++ b/src/calibre/ebooks/metadata/book/json_codec.py
@@ -0,0 +1,143 @@
+'''
+Created on 4 Jun 2010
+
+@author: charles
+'''
+
+from base64 import b64encode, b64decode
+import json
+import traceback
+
+from calibre.ebooks.metadata.book import SERIALIZABLE_FIELDS
+from calibre.constants import filesystem_encoding, preferred_encoding
+from calibre.library.field_metadata import FieldMetadata
+from calibre.utils.date import parse_date, isoformat, UNDEFINED_DATE
+from calibre.utils.magick import Image
+from calibre import isbytestring
+
+# Translate datetimes to and from strings. The string form is the datetime in
+# UTC. The returned date is also UTC
+def string_to_datetime(src):
+ if src == "None":
+ return None
+ return parse_date(src)
+
+def datetime_to_string(dateval):
+ if dateval is None or dateval == UNDEFINED_DATE:
+ return "None"
+ return isoformat(dateval)
+
+def encode_thumbnail(thumbnail):
+ '''
+ Encode the image part of a thumbnail, then return the 3 part tuple
+ '''
+ if thumbnail is None:
+ return None
+ if not isinstance(thumbnail, (tuple, list)):
+ try:
+ img = Image()
+ img.load(thumbnail)
+ width, height = img.size
+ thumbnail = (width, height, thumbnail)
+ except:
+ return None
+ return (thumbnail[0], thumbnail[1], b64encode(str(thumbnail[2])))
+
+def decode_thumbnail(tup):
+ '''
+ Decode an encoded thumbnail into its 3 component parts
+ '''
+ if tup is None:
+ return None
+ return (tup[0], tup[1], b64decode(tup[2]))
+
+def object_to_unicode(obj, enc=preferred_encoding):
+
+ def dec(x):
+ return x.decode(enc, 'replace')
+
+ if isbytestring(obj):
+ return dec(obj)
+ if isinstance(obj, (list, tuple)):
+ return [dec(x) if isbytestring(x) else x for x in obj]
+ if isinstance(obj, dict):
+ ans = {}
+ for k, v in obj.items():
+ k = object_to_unicode(k)
+ v = object_to_unicode(v)
+ ans[k] = v
+ return ans
+ return obj
+
+class JsonCodec(object):
+
+ def __init__(self):
+ self.field_metadata = FieldMetadata()
+
+ def encode_to_file(self, file, booklist):
+ file.write(json.dumps(self.encode_booklist_metadata(booklist),
+ indent=2, encoding='utf-8'))
+
+ def encode_booklist_metadata(self, booklist):
+ result = []
+ for book in booklist:
+ result.append(self.encode_book_metadata(book))
+ return result
+
+ def encode_book_metadata(self, book):
+ result = {}
+ for key in SERIALIZABLE_FIELDS:
+ result[key] = self.encode_metadata_attr(book, key)
+ return result
+
+ def encode_metadata_attr(self, book, key):
+ if key == 'user_metadata':
+ meta = book.get_all_user_metadata(make_copy=True)
+ for k in meta:
+ if meta[k]['datatype'] == 'datetime':
+ meta[k]['#value#'] = datetime_to_string(meta[k]['#value#'])
+ return meta
+ if key in self.field_metadata:
+ datatype = self.field_metadata[key]['datatype']
+ else:
+ datatype = None
+ value = book.get(key)
+ if key == 'thumbnail':
+ return encode_thumbnail(value)
+ elif isbytestring(value): # str includes bytes
+ enc = filesystem_encoding if key == 'lpath' else preferred_encoding
+ return object_to_unicode(value, enc=enc)
+ elif datatype == 'datetime':
+ return datetime_to_string(value)
+ else:
+ return object_to_unicode(value)
+
+ def decode_from_file(self, file, booklist, book_class, prefix):
+ js = []
+ try:
+ js = json.load(file, encoding='utf-8')
+ for item in js:
+ book = book_class(prefix, item.get('lpath', None))
+ for key in item.keys():
+ meta = self.decode_metadata(key, item[key])
+ if key == 'user_metadata':
+ book.set_all_user_metadata(meta)
+ else:
+ setattr(book, key, meta)
+ booklist.append(book)
+ except:
+ print 'exception during JSON decoding'
+ traceback.print_exc()
+
+ def decode_metadata(self, key, value):
+ if key == 'user_metadata':
+ for k in value:
+ if value[k]['datatype'] == 'datetime':
+ value[k]['#value#'] = string_to_datetime(value[k]['#value#'])
+ return value
+ elif key in self.field_metadata:
+ if self.field_metadata[key]['datatype'] == 'datetime':
+ return string_to_datetime(value)
+ if key == 'thumbnail':
+ return decode_thumbnail(value)
+ return value
diff --git a/src/calibre/ebooks/metadata/cli.py b/src/calibre/ebooks/metadata/cli.py
index 780d3febcf..a0be187512 100644
--- a/src/calibre/ebooks/metadata/cli.py
+++ b/src/calibre/ebooks/metadata/cli.py
@@ -109,7 +109,7 @@ def do_set_metadata(opts, mi, stream, stream_type):
from_opf = getattr(opts, 'from_opf', None)
if from_opf is not None:
from calibre.ebooks.metadata.opf2 import OPF
- opf_mi = MetaInformation(OPF(open(from_opf, 'rb')))
+ opf_mi = OPF(open(from_opf, 'rb')).to_book_metadata()
mi.smart_update(opf_mi)
for pref in config().option_set.preferences:
diff --git a/src/calibre/ebooks/metadata/epub.py b/src/calibre/ebooks/metadata/epub.py
index df9a394258..e60837a553 100644
--- a/src/calibre/ebooks/metadata/epub.py
+++ b/src/calibre/ebooks/metadata/epub.py
@@ -164,10 +164,10 @@ def get_cover(opf, opf_path, stream, reader=None):
return render_html_svg_workaround(cpage, default_log)
def get_metadata(stream, extract_cover=True):
- """ Return metadata as a :class:`MetaInformation` object """
+ """ Return metadata as a :class:`Metadata` object """
stream.seek(0)
reader = OCFZipReader(stream)
- mi = MetaInformation(reader.opf)
+ mi = reader.opf.to_book_metadata()
if extract_cover:
try:
cdata = get_cover(reader.opf, reader.opf_path, stream, reader=reader)
diff --git a/src/calibre/ebooks/metadata/fetch.py b/src/calibre/ebooks/metadata/fetch.py
index 96807c06ae..9b8a42e482 100644
--- a/src/calibre/ebooks/metadata/fetch.py
+++ b/src/calibre/ebooks/metadata/fetch.py
@@ -29,7 +29,7 @@ class MetadataSource(Plugin): # {{{
future use.
The fetch method must store the results in `self.results` as a list of
- :class:`MetaInformation` objects. If there is an error, it should be stored
+ :class:`Metadata` objects. If there is an error, it should be stored
in `self.exception` and `self.tb` (for the traceback).
'''
diff --git a/src/calibre/ebooks/metadata/isbndb.py b/src/calibre/ebooks/metadata/isbndb.py
index 356cc3f1b1..6416dcdc39 100644
--- a/src/calibre/ebooks/metadata/isbndb.py
+++ b/src/calibre/ebooks/metadata/isbndb.py
@@ -8,7 +8,7 @@ import sys, re
from urllib import quote
from calibre.utils.config import OptionParser
-from calibre.ebooks.metadata import MetaInformation
+from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup
from calibre import browser
@@ -42,33 +42,47 @@ def fetch_metadata(url, max=100, timeout=5.):
return books
-class ISBNDBMetadata(MetaInformation):
+class ISBNDBMetadata(Metadata):
def __init__(self, book):
- MetaInformation.__init__(self, None, [])
+ Metadata.__init__(self, None, [])
- self.isbn = book.get('isbn13', book.get('isbn'))
- self.title = book.find('titlelong').string
+ def tostring(e):
+ if not hasattr(e, 'string'):
+ return None
+ ans = e.string
+ if ans is not None:
+ ans = unicode(ans).strip()
+ if not ans:
+ ans = None
+ return ans
+
+ self.isbn = unicode(book.get('isbn13', book.get('isbn')))
+ self.title = tostring(book.find('titlelong'))
if not self.title:
- self.title = book.find('title').string
+ self.title = tostring(book.find('title'))
+ if not self.title:
+ self.title = _('Unknown')
self.title = unicode(self.title).strip()
- au = unicode(book.find('authorstext').string).strip()
- temp = au.split(',')
self.authors = []
- for au in temp:
- if not au: continue
- self.authors.extend([a.strip() for a in au.split('&')])
+ au = tostring(book.find('authorstext'))
+ if au:
+ au = au.strip()
+ temp = au.split(',')
+ for au in temp:
+ if not au: continue
+ self.authors.extend([a.strip() for a in au.split('&')])
try:
- self.author_sort = book.find('authors').find('person').string
+ self.author_sort = tostring(book.find('authors').find('person'))
if self.authors and self.author_sort == self.authors[0]:
self.author_sort = None
except:
pass
- self.publisher = book.find('publishertext').string
+ self.publisher = tostring(book.find('publishertext'))
- summ = book.find('summary')
- if summ and hasattr(summ, 'string') and summ.string:
+ summ = tostring(book.find('summary'))
+ if summ:
self.comments = 'SUMMARY:\n'+summ.string
diff --git a/src/calibre/ebooks/metadata/library_thing.py b/src/calibre/ebooks/metadata/library_thing.py
index 669d9478a3..7f312da1d9 100644
--- a/src/calibre/ebooks/metadata/library_thing.py
+++ b/src/calibre/ebooks/metadata/library_thing.py
@@ -12,6 +12,7 @@ import mechanize
from calibre import browser, prints
from calibre.utils.config import OptionParser
from calibre.ebooks.BeautifulSoup import BeautifulSoup
+from calibre.ebooks.chardet import strip_encoding_declarations
OPENLIBRARY = 'http://covers.openlibrary.org/b/isbn/%s-L.jpg?default=false'
@@ -110,6 +111,8 @@ def get_social_metadata(title, authors, publisher, isbn, username=None,
+isbn).read()
if not raw:
return mi
+ raw = raw.decode('utf-8', 'replace')
+ raw = strip_encoding_declarations(raw)
root = html.fromstring(raw)
h1 = root.xpath('//div[@class="headsummary"]/h1')
if h1 and not mi.title:
diff --git a/src/calibre/ebooks/metadata/lit.py b/src/calibre/ebooks/metadata/lit.py
index 1a267b6858..3be1f22632 100644
--- a/src/calibre/ebooks/metadata/lit.py
+++ b/src/calibre/ebooks/metadata/lit.py
@@ -6,7 +6,6 @@ Support for reading the metadata from a LIT file.
import cStringIO, os
-from calibre.ebooks.metadata import MetaInformation
from calibre.ebooks.metadata.opf2 import OPF
def get_metadata(stream):
@@ -16,7 +15,7 @@ def get_metadata(stream):
src = litfile.get_metadata().encode('utf-8')
litfile = litfile._litfile
opf = OPF(cStringIO.StringIO(src), os.getcwd())
- mi = MetaInformation(opf)
+ mi = opf.to_book_metadata()
covers = []
for item in opf.iterguide():
if 'cover' not in item.get('type', '').lower():
diff --git a/src/calibre/ebooks/metadata/meta.py b/src/calibre/ebooks/metadata/meta.py
index eae8171362..b02ae2dbff 100644
--- a/src/calibre/ebooks/metadata/meta.py
+++ b/src/calibre/ebooks/metadata/meta.py
@@ -181,7 +181,7 @@ def metadata_from_filename(name, pat=None):
mi.isbn = si
except (IndexError, ValueError):
pass
- if not mi.title:
+ if mi.is_null('title'):
mi.title = name
return mi
@@ -194,7 +194,7 @@ def opf_metadata(opfpath):
try:
opf = OPF(f, os.path.dirname(opfpath))
if opf.application_id is not None:
- mi = MetaInformation(opf)
+ mi = opf.to_book_metadata()
if hasattr(opf, 'cover') and opf.cover:
cpath = os.path.join(os.path.dirname(opfpath), opf.cover)
if os.access(cpath, os.R_OK):
diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py
index 2d9de7f780..5c2477c3dc 100644
--- a/src/calibre/ebooks/metadata/opf2.py
+++ b/src/calibre/ebooks/metadata/opf2.py
@@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en'
lxml based OPF parser.
'''
-import re, sys, unittest, functools, os, mimetypes, uuid, glob, cStringIO
+import re, sys, unittest, functools, os, mimetypes, uuid, glob, cStringIO, json
from urllib import unquote
from urlparse import urlparse
@@ -16,11 +16,13 @@ from lxml import etree
from calibre.ebooks.chardet import xml_to_unicode
from calibre.constants import __appname__, __version__, filesystem_encoding
from calibre.ebooks.metadata.toc import TOC
-from calibre.ebooks.metadata import MetaInformation, string_to_authors
+from calibre.ebooks.metadata import string_to_authors, MetaInformation
+from calibre.ebooks.metadata.book.base import Metadata
from calibre.utils.date import parse_date, isoformat
from calibre.utils.localization import get_lang
+from calibre import prints
-class Resource(object):
+class Resource(object): # {{{
'''
Represents a resource (usually a file on the filesystem or a URL pointing
to the web. Such resources are commonly referred to in OPF files.
@@ -101,8 +103,9 @@ class Resource(object):
def __repr__(self):
return 'Resource(%s, %s)'%(repr(self.path), repr(self.href()))
+# }}}
-class ResourceCollection(object):
+class ResourceCollection(object): # {{{
def __init__(self):
self._resources = []
@@ -153,10 +156,9 @@ class ResourceCollection(object):
for res in self:
res.set_basedir(path)
+# }}}
-
-
-class ManifestItem(Resource):
+class ManifestItem(Resource): # {{{
@staticmethod
def from_opf_manifest_item(item, basedir):
@@ -194,8 +196,9 @@ class ManifestItem(Resource):
return self.media_type
raise IndexError('%d out of bounds.'%index)
+# }}}
-class Manifest(ResourceCollection):
+class Manifest(ResourceCollection): # {{{
@staticmethod
def from_opf_manifest_element(items, dir):
@@ -262,7 +265,9 @@ class Manifest(ResourceCollection):
if i.id == id:
return i.mime_type
-class Spine(ResourceCollection):
+# }}}
+
+class Spine(ResourceCollection): # {{{
class Item(Resource):
@@ -334,7 +339,9 @@ class Spine(ResourceCollection):
for i in self:
yield i.path
-class Guide(ResourceCollection):
+# }}}
+
+class Guide(ResourceCollection): # {{{
class Reference(Resource):
@@ -371,6 +378,7 @@ class Guide(ResourceCollection):
self[-1].type = type
self[-1].title = ''
+# }}}
class MetadataField(object):
@@ -412,7 +420,29 @@ class MetadataField(object):
elem = obj.create_metadata_element(self.name, is_dc=self.is_dc)
obj.set_text(elem, unicode(val))
-class OPF(object):
+
+def serialize_user_metadata(metadata_elem, all_user_metadata, tail='\n'+(' '*8)):
+ from calibre.utils.config import to_json
+ from calibre.ebooks.metadata.book.json_codec import object_to_unicode
+
+ for name, fm in all_user_metadata.items():
+ try:
+ fm = object_to_unicode(fm)
+ fm = json.dumps(fm, default=to_json, ensure_ascii=False)
+ except:
+ prints('Failed to write user metadata:', name)
+ import traceback
+ traceback.print_exc()
+ continue
+ meta = metadata_elem.makeelement('meta')
+ meta.set('name', 'calibre:user_metadata:'+name)
+ meta.set('content', fm)
+ meta.tail = tail
+ metadata_elem.append(meta)
+
+
+class OPF(object): # {{{
+
MIMETYPE = 'application/oebps-package+xml'
PARSER = etree.XMLParser(recover=True)
NAMESPACES = {
@@ -497,6 +527,43 @@ class OPF(object):
self.guide = Guide.from_opf_guide(guide, basedir) if guide else None
self.cover_data = (None, None)
self.find_toc()
+ self.read_user_metadata()
+
+ def read_user_metadata(self):
+ self._user_metadata_ = {}
+ temp = Metadata('x', ['x'])
+ from calibre.utils.config import from_json
+ elems = self.root.xpath('//*[name() = "meta" and starts-with(@name,'
+ '"calibre:user_metadata:") and @content]')
+ for elem in elems:
+ name = elem.get('name')
+ name = ':'.join(name.split(':')[2:])
+ if not name or not name.startswith('#'):
+ continue
+ fm = elem.get('content')
+ try:
+ fm = json.loads(fm, object_hook=from_json)
+ temp.set_user_metadata(name, fm)
+ except:
+ prints('Failed to read user metadata:', name)
+ import traceback
+ traceback.print_exc()
+ continue
+ self._user_metadata_ = temp.get_all_user_metadata(True)
+
+ def to_book_metadata(self):
+ ans = MetaInformation(self)
+ for n, v in self._user_metadata_.items():
+ ans.set_user_metadata(n, v)
+ return ans
+
+ def write_user_metadata(self):
+ elems = self.root.xpath('//*[name() = "meta" and starts-with(@name,'
+ '"calibre:user_metadata:") and @content]')
+ for elem in elems:
+ elem.getparent().remove(elem)
+ serialize_user_metadata(self.metadata,
+ self._user_metadata_)
def find_toc(self):
self.toc = None
@@ -911,6 +978,7 @@ class OPF(object):
return elem
def render(self, encoding='utf-8'):
+ self.write_user_metadata()
raw = etree.tostring(self.root, encoding=encoding, pretty_print=True)
if not raw.lstrip().startswith('\n'%encoding.upper()+raw
@@ -924,18 +992,22 @@ class OPF(object):
val = getattr(mi, attr, None)
if val is not None and val != [] and val != (None, None):
setattr(self, attr, val)
+ temp = self.to_book_metadata()
+ temp.smart_update(mi, replace_metadata=replace_metadata)
+ self._user_metadata_ = temp.get_all_user_metadata(True)
+# }}}
-class OPFCreator(MetaInformation):
+class OPFCreator(Metadata):
- def __init__(self, base_path, *args, **kwargs):
+ def __init__(self, base_path, other):
'''
Initialize.
@param base_path: An absolute path to the directory in which this OPF file
will eventually be. This is used by the L{create_manifest} method
to convert paths to files into relative paths.
'''
- MetaInformation.__init__(self, *args, **kwargs)
+ Metadata.__init__(self, title='', other=other)
self.base_path = os.path.abspath(base_path)
if self.application_id is None:
self.application_id = str(uuid.uuid4())
@@ -1115,6 +1187,8 @@ class OPFCreator(MetaInformation):
item.set('title', ref.title)
guide.append(item)
+ serialize_user_metadata(metadata, self.get_all_user_metadata(False))
+
root = E.package(
metadata,
manifest,
@@ -1156,7 +1230,7 @@ def metadata_to_opf(mi, as_string=True):
%(id)s
%(uuid)s
-
+
'''%dict(a=__appname__, id=mi.application_id, uuid=mi.uuid)))
@@ -1188,7 +1262,7 @@ def metadata_to_opf(mi, as_string=True):
factory(DC('contributor'), mi.book_producer, __appname__, 'bkp')
if hasattr(mi.pubdate, 'isoformat'):
factory(DC('date'), isoformat(mi.pubdate))
- if mi.category:
+ if hasattr(mi, 'category') and mi.category:
factory(DC('type'), mi.category)
if mi.comments:
factory(DC('description'), mi.comments)
@@ -1217,6 +1291,8 @@ def metadata_to_opf(mi, as_string=True):
if mi.title_sort:
meta('title_sort', mi.title_sort)
+ serialize_user_metadata(metadata, mi.get_all_user_metadata(False))
+
metadata[-1].tail = '\n' +(' '*4)
if mi.cover:
@@ -1334,5 +1410,30 @@ def suite():
def test():
unittest.TextTestRunner(verbosity=2).run(suite())
+def test_user_metadata():
+ from cStringIO import StringIO
+ mi = Metadata('Test title', ['test author1', 'test author2'])
+ um = {
+ '#myseries': { '#value#': u'test series\xe4', 'datatype':'text',
+ 'is_multiple': None, 'name': u'My Series'},
+ '#myseries_index': { '#value#': 2.45, 'datatype': 'float',
+ 'is_multiple': None},
+ '#mytags': {'#value#':['t1','t2','t3'], 'datatype':'text',
+ 'is_multiple': '|', 'name': u'My Tags'}
+ }
+ mi.set_all_user_metadata(um)
+ raw = metadata_to_opf(mi)
+ opfc = OPFCreator(os.getcwd(), other=mi)
+ out = StringIO()
+ opfc.render(out)
+ raw2 = out.getvalue()
+ f = StringIO(raw)
+ opf = OPF(f)
+ f2 = StringIO(raw2)
+ opf2 = OPF(f2)
+ assert um == opf._user_metadata_
+ assert um == opf2._user_metadata_
+ print opf.render()
+
if __name__ == '__main__':
- test()
+ test_user_metadata()
diff --git a/src/calibre/ebooks/metadata/rtf.py b/src/calibre/ebooks/metadata/rtf.py
index d116ec30fb..ad41125575 100644
--- a/src/calibre/ebooks/metadata/rtf.py
+++ b/src/calibre/ebooks/metadata/rtf.py
@@ -125,7 +125,7 @@ def create_metadata(stream, options):
au = u', '.join(au)
author = au.encode('ascii', 'ignore')
md += r'{\author %s}'%(author,)
- if options.category:
+ if options.get('category', None):
category = options.category.encode('ascii', 'ignore')
md += r'{\category %s}'%(category,)
comp = options.comment if hasattr(options, 'comment') else options.comments
@@ -180,7 +180,7 @@ def set_metadata(stream, options):
src = pat.sub(r'{\\author ' + author + r'}', src)
else:
src = add_metadata_item(src, 'author', author)
- category = options.category
+ category = options.get('category', None)
if category != None:
category = category.encode('ascii', 'replace')
pat = re.compile(base_pat.replace('name', 'category'), re.DOTALL)
diff --git a/src/calibre/ebooks/mobi/reader.py b/src/calibre/ebooks/mobi/reader.py
index 2a35c7cb45..0cf31d64ec 100644
--- a/src/calibre/ebooks/mobi/reader.py
+++ b/src/calibre/ebooks/mobi/reader.py
@@ -234,7 +234,7 @@ class MobiReader(object):
self.debug = debug
self.embedded_mi = None
self.base_css_rules = textwrap.dedent('''
- blockquote { margin: 0em 0em 0em 1.25em; text-align: justify }
+ blockquote { margin: 0em 0em 0em 1em; text-align: justify }
p { margin: 0em; text-align: justify }
@@ -441,7 +441,7 @@ class MobiReader(object):
html.tostring(elem, encoding='utf-8') + ''
stream = cStringIO.StringIO(raw)
opf = OPF(stream)
- self.embedded_mi = MetaInformation(opf)
+ self.embedded_mi = opf.to_book_metadata()
if guide is not None:
for ref in guide.xpath('descendant::reference'):
if 'cover' in ref.get('type', '').lower():
diff --git a/src/calibre/ebooks/mobi/writer.py b/src/calibre/ebooks/mobi/writer.py
index 37936d6016..51639ac757 100644
--- a/src/calibre/ebooks/mobi/writer.py
+++ b/src/calibre/ebooks/mobi/writer.py
@@ -1796,12 +1796,13 @@ class MobiWriter(object):
self._oeb.log.debug('Index records dumped to', t)
def _clean_text_value(self, text):
- if not text:
- text = u'(none)'
- text = text.strip()
- if not isinstance(text, unicode):
- text = text.decode('utf-8', 'replace')
- text = text.encode('ascii','replace')
+ if text is not None and text.strip() :
+ text = text.strip()
+ if not isinstance(text, unicode):
+ text = text.decode('utf-8', 'replace')
+ text = text.encode('utf-8')
+ else :
+ text = "(none)".encode('utf-8')
return text
def _add_to_ctoc(self, ctoc_str, record_offset):
diff --git a/src/calibre/ebooks/oeb/reader.py b/src/calibre/ebooks/oeb/reader.py
index d7d7bbf725..559421326c 100644
--- a/src/calibre/ebooks/oeb/reader.py
+++ b/src/calibre/ebooks/oeb/reader.py
@@ -126,10 +126,9 @@ class OEBReader(object):
def _metadata_from_opf(self, opf):
from calibre.ebooks.metadata.opf2 import OPF
- from calibre.ebooks.metadata import MetaInformation
from calibre.ebooks.oeb.transforms.metadata import meta_info_to_oeb_metadata
stream = cStringIO.StringIO(etree.tostring(opf))
- mi = MetaInformation(OPF(stream))
+ mi = OPF(stream).to_book_metadata()
if not mi.language:
mi.language = get_lang().replace('_', '-')
self.oeb.metadata.add('language', mi.language)
diff --git a/src/calibre/ebooks/oeb/transforms/metadata.py b/src/calibre/ebooks/oeb/transforms/metadata.py
index 22a89f5a47..4bb25f650e 100644
--- a/src/calibre/ebooks/oeb/transforms/metadata.py
+++ b/src/calibre/ebooks/oeb/transforms/metadata.py
@@ -12,33 +12,33 @@ from calibre import guess_type
def meta_info_to_oeb_metadata(mi, m, log):
from calibre.ebooks.oeb.base import OPF
- if mi.title:
+ if not mi.is_null('title'):
m.clear('title')
m.add('title', mi.title)
if mi.title_sort:
if not m.title:
m.add('title', mi.title_sort)
m.title[0].file_as = mi.title_sort
- if mi.authors:
+ if not mi.is_null('authors'):
m.filter('creator', lambda x : x.role.lower() in ['aut', ''])
for a in mi.authors:
attrib = {'role':'aut'}
if mi.author_sort:
attrib[OPF('file-as')] = mi.author_sort
m.add('creator', a, attrib=attrib)
- if mi.book_producer:
+ if not mi.is_null('book_producer'):
m.filter('contributor', lambda x : x.role.lower() == 'bkp')
m.add('contributor', mi.book_producer, role='bkp')
- if mi.comments:
+ if not mi.is_null('comments'):
m.clear('description')
m.add('description', mi.comments)
- if mi.publisher:
+ if not mi.is_null('publisher'):
m.clear('publisher')
m.add('publisher', mi.publisher)
- if mi.series:
+ if not mi.is_null('series'):
m.clear('series')
m.add('series', mi.series)
- if mi.isbn:
+ if not mi.is_null('isbn'):
has = False
for x in m.identifier:
if x.scheme.lower() == 'isbn':
@@ -46,29 +46,29 @@ def meta_info_to_oeb_metadata(mi, m, log):
has = True
if not has:
m.add('identifier', mi.isbn, scheme='ISBN')
- if mi.language:
+ if not mi.is_null('language'):
m.clear('language')
m.add('language', mi.language)
- if mi.series_index is not None:
+ if not mi.is_null('series_index'):
m.clear('series_index')
m.add('series_index', mi.format_series_index())
- if mi.rating is not None:
+ if not mi.is_null('rating'):
m.clear('rating')
m.add('rating', '%.2f'%mi.rating)
- if mi.tags:
+ if not mi.is_null('tags'):
m.clear('subject')
for t in mi.tags:
m.add('subject', t)
- if mi.pubdate is not None:
+ if not mi.is_null('pubdate'):
m.clear('date')
m.add('date', isoformat(mi.pubdate))
- if mi.timestamp is not None:
+ if not mi.is_null('timestamp'):
m.clear('timestamp')
m.add('timestamp', isoformat(mi.timestamp))
- if mi.rights is not None:
+ if not mi.is_null('rights'):
m.clear('rights')
m.add('rights', mi.rights)
- if mi.publication_type is not None:
+ if not mi.is_null('publication_type'):
m.clear('publication_type')
m.add('publication_type', mi.publication_type)
if not m.timestamp:
diff --git a/src/calibre/ebooks/rtf/rtfml.py b/src/calibre/ebooks/rtf/rtfml.py
index c3d8cb38fc..3869c05152 100644
--- a/src/calibre/ebooks/rtf/rtfml.py
+++ b/src/calibre/ebooks/rtf/rtfml.py
@@ -10,13 +10,6 @@ Transform OEB content into RTF markup
import os
import re
-
-try:
- from PIL import Image
- Image
-except ImportError:
- import Image
-
import cStringIO
from lxml import etree
@@ -26,6 +19,7 @@ from calibre.ebooks.oeb.base import XHTML, XHTML_NS, barename, namespace, \
from calibre.ebooks.oeb.stylizer import Stylizer
from calibre.ebooks.metadata import authors_to_string
from calibre.utils.filenames import ascii_text
+from calibre.utils.magick.draw import save_cover_data_to, identify_data
TAGS = {
'b': '\\b',
@@ -153,10 +147,8 @@ class RTFMLizer(object):
return text
def image_to_hexstring(self, data):
- im = Image.open(cStringIO.StringIO(data))
- data = cStringIO.StringIO()
- im.convert('RGB').save(data, 'JPEG')
- data = data.getvalue()
+ data = save_cover_data_to(data, 'cover.jpg', return_data=True)
+ width, height = identify_data(data)[:2]
raw_hex = ''
for char in data:
@@ -173,7 +165,7 @@ class RTFMLizer(object):
col += 1
hex_string += char
- return (hex_string, im.size[0], im.size[1])
+ return (hex_string, width, height)
def clean_text(self, text):
# Remove excess spaces at beginning and end of lines
diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py
index c0c7b0a9ed..1b0027dcc2 100644
--- a/src/calibre/gui2/__init__.py
+++ b/src/calibre/gui2/__init__.py
@@ -1,7 +1,7 @@
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal '
""" The GUI """
-import os, sys, Queue
+import os, sys, Queue, threading
from threading import RLock
from PyQt4.Qt import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, \
@@ -311,11 +311,14 @@ class FunctionDispatcher(QObject):
if not queued:
typ = Qt.AutoConnection if queued is None else Qt.DirectConnection
self.dispatch_signal.connect(self.dispatch, type=typ)
+ self.q = Queue.Queue()
+ self.lock = threading.Lock()
def __call__(self, *args, **kwargs):
- q = Queue.Queue()
- self.dispatch_signal.emit(q, args, kwargs)
- return q.get()
+ with self.lock:
+ self.dispatch_signal.emit(self.q, args, kwargs)
+ res = self.q.get()
+ return res
def dispatch(self, q, args, kwargs):
try:
diff --git a/src/calibre/gui2/actions/add.py b/src/calibre/gui2/actions/add.py
index add7bf1d5b..e0a7b5647e 100644
--- a/src/calibre/gui2/actions/add.py
+++ b/src/calibre/gui2/actions/add.py
@@ -230,9 +230,9 @@ class AddAction(InterfaceAction):
self._files_added(paths, names, infos, on_card=on_card)
# set the in-library flags, and as a consequence send the library's
# metadata for this book to the device. This sets the uuid to the
- # correct value.
+ # correct value. Note that set_books_in_library might sync_booklists
self.gui.set_books_in_library(booklists=[model.db], reset=True)
- model.reset()
+ self.gui.refresh_ondevice()
def add_books_from_device(self, view):
rows = view.selectionModel().selectedRows()
diff --git a/src/calibre/gui2/actions/choose_library.py b/src/calibre/gui2/actions/choose_library.py
index 79406da40c..2f8beab976 100644
--- a/src/calibre/gui2/actions/choose_library.py
+++ b/src/calibre/gui2/actions/choose_library.py
@@ -14,7 +14,7 @@ from calibre import isbytestring
from calibre.constants import filesystem_encoding
from calibre.utils.config import prefs
from calibre.gui2 import gprefs, warning_dialog, Dispatcher, error_dialog, \
- question_dialog
+ question_dialog, info_dialog
from calibre.gui2.actions import InterfaceAction
class LibraryUsageStats(object):
@@ -115,6 +115,14 @@ class ChooseLibraryAction(InterfaceAction):
type=Qt.QueuedConnection)
self.choose_menu.addAction(ac)
+ self.rename_separator = self.choose_menu.addSeparator()
+
+ self.create_action(spec=(_('Library backup status...'), 'lt.png', None,
+ None), attr='action_backup_status')
+ self.action_backup_status.triggered.connect(self.backup_status,
+ type=Qt.QueuedConnection)
+ self.choose_menu.addAction(self.action_backup_status)
+
def library_name(self):
db = self.gui.library_view.model().db
path = db.library_path
@@ -206,6 +214,16 @@ class ChooseLibraryAction(InterfaceAction):
self.stats.remove(location)
self.build_menus()
+ def backup_status(self, location):
+ dirty_text = 'no'
+ try:
+ dirty_text = \
+ unicode(self.gui.library_view.model().db.dirty_queue_length())
+ except:
+ dirty_text = _('none')
+ info_dialog(self.gui, _('Backup status'), ''+
+ _('Book metadata files remaining to be written: %s') % dirty_text,
+ show=True)
def switch_requested(self, location):
if not self.change_library_allowed():
diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py
index bd9728989b..2a81a1500d 100644
--- a/src/calibre/gui2/actions/edit_metadata.py
+++ b/src/calibre/gui2/actions/edit_metadata.py
@@ -8,15 +8,14 @@ __docformat__ = 'restructuredtext en'
import os
from functools import partial
-from PyQt4.Qt import Qt, QTimer, QMenu
+from PyQt4.Qt import Qt, QMenu
-from calibre.gui2 import error_dialog, config, warning_dialog
+from calibre.gui2 import error_dialog, config
from calibre.gui2.dialogs.metadata_single import MetadataSingleDialog
from calibre.gui2.dialogs.metadata_bulk import MetadataBulkDialog
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.tag_list_editor import TagListEditor
from calibre.gui2.actions import InterfaceAction
-from calibre.gui2.dialogs.progress import BlockingBusy
class EditMetadataAction(InterfaceAction):
@@ -84,52 +83,33 @@ class EditMetadataAction(InterfaceAction):
def do_download_metadata(self, ids, covers=True, set_metadata=True,
set_social_metadata=None):
- db = self.gui.library_view.model().db
+ m = self.gui.library_view.model()
+ db = m.db
if set_social_metadata is None:
get_social_metadata = config['get_social_metadata']
else:
get_social_metadata = set_social_metadata
- from calibre.gui2.metadata import DownloadMetadata
- self._download_book_metadata = DownloadMetadata(db, ids,
- get_covers=covers, set_metadata=set_metadata,
- get_social_metadata=get_social_metadata)
- self._download_book_metadata.start()
+ from calibre.gui2.metadata import DoDownload
if set_social_metadata is not None and set_social_metadata:
x = _('social metadata')
else:
x = _('covers') if covers and not set_metadata else _('metadata')
- self._book_metadata_download_check = QTimer(self.gui)
- self._book_metadata_download_check.timeout.connect(self.book_metadata_download_check,
- type=Qt.QueuedConnection)
- self._book_metadata_download_check.start(100)
- self._bb_dialog = BlockingBusy(_('Downloading %s for %d book(s)')%(x,
- len(ids)), parent=self.gui)
- self._bb_dialog.exec_()
-
- def book_metadata_download_check(self):
- if self._download_book_metadata.is_alive():
- return
- self._book_metadata_download_check.stop()
- self._bb_dialog.accept()
+ title = _('Downloading %s for %d book(s)')%(x, len(ids))
+ self._download_book_metadata = DoDownload(self.gui, title, db, ids,
+ get_covers=covers, set_metadata=set_metadata,
+ get_social_metadata=get_social_metadata)
+ m.stop_metadata_backup()
+ try:
+ self._download_book_metadata.exec_()
+ finally:
+ m.start_metadata_backup()
cr = self.gui.library_view.currentIndex().row()
x = self._download_book_metadata
- self._download_book_metadata = None
- if x.exception is None:
+ if x.updated:
self.gui.library_view.model().refresh_ids(
x.updated, cr)
if self.gui.cover_flow:
self.gui.cover_flow.dataChanged()
- if x.failures:
- details = ['%s: %s'%(title, reason) for title,
- reason in x.failures.values()]
- details = '%s\n'%('\n'.join(details))
- warning_dialog(self.gui, _('Failed to download some metadata'),
- _('Failed to download metadata for the following:'),
- det_msg=details).exec_()
- else:
- err = _('Failed to download metadata:')
- error_dialog(self.gui, _('Error'), err, det_msg=x.tb).exec_()
-
def edit_metadata(self, checked, bulk=None):
'''
@@ -184,12 +164,13 @@ class EditMetadataAction(InterfaceAction):
self.gui.tags_view.blockSignals(True)
try:
changed = MetadataBulkDialog(self.gui, rows,
- self.gui.library_view.model().db).changed
+ self.gui.library_view.model()).changed
finally:
self.gui.tags_view.blockSignals(False)
if changed:
- self.gui.library_view.model().resort(reset=False)
- self.gui.library_view.model().research()
+ m = self.gui.library_view.model()
+ m.resort(reset=False)
+ m.research()
self.gui.tags_view.recount()
if self.gui.cover_flow:
self.gui.cover_flow.dataChanged()
diff --git a/src/calibre/gui2/add.py b/src/calibre/gui2/add.py
index 5b9fb35be3..1339070446 100644
--- a/src/calibre/gui2/add.py
+++ b/src/calibre/gui2/add.py
@@ -138,7 +138,7 @@ class DBAdder(Thread): # {{{
self.critical[name] = open(opf, 'rb').read().decode('utf-8', 'replace')
else:
try:
- mi = MetaInformation(OPF(opf))
+ mi = OPF(opf).to_book_metadata()
except:
import traceback
mi = MetaInformation('', [_('Unknown')])
@@ -152,7 +152,8 @@ class DBAdder(Thread): # {{{
mi.application_id = None
if self.db is not None:
if cover:
- cover = open(cover, 'rb').read()
+ with open(cover, 'rb') as f:
+ cover = f.read()
orig_formats = formats
formats = [f for f in formats if not f.lower().endswith('.opf')]
if prefs['add_formats_to_existing']:
@@ -381,11 +382,7 @@ class Adder(QObject): # {{{
# }}}
-###############################################################################
-############################## END ADDER ######################################
-###############################################################################
-
-class Saver(QObject):
+class Saver(QObject): # {{{
def __init__(self, parent, db, callback, rows, path, opts,
spare_server=None):
@@ -446,4 +443,5 @@ class Saver(QObject):
self.pd.set_msg(_('Saved')+' '+title)
if not ok:
self.failures.add((title, tb))
+# }}}
diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py
index 6b8d4b1d3c..cfb582024d 100644
--- a/src/calibre/gui2/book_details.py
+++ b/src/calibre/gui2/book_details.py
@@ -28,6 +28,8 @@ WEIGHTS[_('Tags')] = 4
def render_rows(data):
keys = data.keys()
+ # First sort by name. The WEIGHTS sort will preserve this sub-order
+ keys.sort(cmp=lambda x, y: cmp(x.lower(), y.lower()))
keys.sort(cmp=lambda x, y: cmp(WEIGHTS[x], WEIGHTS[y]))
rows = []
for key in keys:
diff --git a/src/calibre/gui2/custom_column_widgets.py b/src/calibre/gui2/custom_column_widgets.py
index 67ab94d29a..1758116e7a 100644
--- a/src/calibre/gui2/custom_column_widgets.py
+++ b/src/calibre/gui2/custom_column_widgets.py
@@ -348,7 +348,11 @@ def populate_metadata_page(layout, db, book_id, bulk=False, two_column=False, pa
ans = []
column = row = comments_row = 0
for col in cols:
+ if not x[col]['editable']:
+ continue
dt = x[col]['datatype']
+ if dt == 'composite':
+ continue
if dt == 'comments':
continue
w = widget_factory(dt, col)
@@ -448,9 +452,25 @@ class BulkSeries(BulkBase):
self.name_widget = w
self.widgets = [QLabel('&'+self.col_metadata['name']+':', parent), w]
- self.widgets.append(QLabel(_('Automatically number books in this series'), parent))
- self.idx_widget=QCheckBox(parent)
- self.widgets.append(self.idx_widget)
+ self.widgets.append(QLabel('', parent))
+ w = QWidget(parent)
+ layout = QHBoxLayout(w)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.remove_series = QCheckBox(parent)
+ self.remove_series.setText(_('Remove series'))
+ layout.addWidget(self.remove_series)
+ self.idx_widget = QCheckBox(parent)
+ self.idx_widget.setText(_('Automatically number books'))
+ layout.addWidget(self.idx_widget)
+ self.force_number = QCheckBox(parent)
+ self.force_number.setText(_('Force numbers to start with '))
+ layout.addWidget(self.force_number)
+ self.series_start_number = QSpinBox(parent)
+ self.series_start_number.setMinimum(1)
+ self.series_start_number.setProperty("value", 1)
+ layout.addWidget(self.series_start_number)
+ layout.addItem(QSpacerItem(20, 10, QSizePolicy.Expanding, QSizePolicy.Minimum))
+ self.widgets.append(w)
def initialize(self, book_id):
self.idx_widget.setChecked(False)
@@ -461,17 +481,26 @@ class BulkSeries(BulkBase):
def getter(self):
n = unicode(self.name_widget.currentText()).strip()
i = self.idx_widget.checkState()
- return n, i
+ f = self.force_number.checkState()
+ s = self.series_start_number.value()
+ r = self.remove_series.checkState()
+ return n, i, f, s, r
def commit(self, book_ids, notify=False):
- val, update_indices = self.gui_val
- val = self.normalize_ui_val(val)
- if val != '':
+ val, update_indices, force_start, at_value, clear = self.gui_val
+ val = '' if clear else self.normalize_ui_val(val)
+ if clear or val != '':
extras = []
next_index = self.db.get_next_cc_series_num_for(val, num=self.col_id)
for book_id in book_ids:
+ if clear:
+ extras.append(None)
+ continue
if update_indices:
- if tweaks['series_index_auto_increment'] == 'next':
+ if force_start:
+ s_index = at_value
+ at_value += 1
+ elif tweaks['series_index_auto_increment'] == 'next':
s_index = next_index
next_index += 1
else:
@@ -479,6 +508,8 @@ class BulkSeries(BulkBase):
else:
s_index = self.db.get_custom_extra(book_id, num=self.col_id,
index_is_id=True)
+ if s_index is None:
+ s_index = 1.0
extras.append(s_index)
self.db.set_custom_bulk(book_ids, val, extras=extras,
num=self.col_id, notify=notify)
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index a7e55c4619..254c62e48c 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -34,6 +34,8 @@ from calibre.ebooks.metadata.meta import set_metadata
from calibre.constants import DEBUG
from calibre.utils.config import prefs, tweaks
from calibre.utils.magick.draw import thumbnail
+from calibre.library.save_to_disk import plugboard_any_device_value, \
+ plugboard_any_format_value
# }}}
class DeviceJob(BaseJob): # {{{
@@ -317,19 +319,40 @@ class DeviceManager(Thread): # {{{
args=[booklist, on_card],
description=_('Send collections to device'))
- def _upload_books(self, files, names, on_card=None, metadata=None):
+ def _upload_books(self, files, names, on_card=None, metadata=None, plugboards=None):
'''Upload books to device: '''
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()
+ 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('Using plugboard', ext, dev_name, cpb)
if ext:
try:
if DEBUG:
prints('Setting metadata in:', mi.title, 'at:',
f, file=sys.__stdout__)
with open(f, 'r+b') as stream:
- set_metadata(stream, mi, stream_type=ext)
+ if cpb:
+ newmi = mi.deepcopy()
+ newmi.template_to_attribute(mi, cpb)
+ else:
+ newmi = mi
+ set_metadata(stream, newmi, stream_type=ext)
except:
if DEBUG:
prints(traceback.format_exc(), file=sys.__stdout__)
@@ -338,12 +361,12 @@ class DeviceManager(Thread): # {{{
metadata=metadata, end_session=False)
def upload_books(self, done, files, names, on_card=None, titles=None,
- metadata=None):
+ metadata=None, plugboards=None):
desc = _('Upload %d books to device')%len(names)
if titles:
desc += u':' + u', '.join(titles)
return self.create_job(self._upload_books, done, args=[files, names],
- kwargs={'on_card':on_card,'metadata':metadata}, description=desc)
+ kwargs={'on_card':on_card,'metadata':metadata,'plugboards':plugboards}, description=desc)
def add_books_to_metadata(self, locations, metadata, booklists):
self.device.add_books_to_metadata(locations, metadata, booklists)
@@ -721,14 +744,16 @@ class DeviceMixin(object): # {{{
self.device_manager.device.__class__.get_gui_name()+\
_(' detected.'), 3000)
self.device_connected = device_kind
- self.refresh_ondevice_info (device_connected = True, reset_only = True)
+ self.library_view.set_device_connected(self.device_connected)
+ self.refresh_ondevice (reset_only = True)
else:
self.device_connected = None
self.status_bar.device_disconnected()
if self.current_view() != self.library_view:
self.book_details.reset_info()
self.location_manager.update_devices()
- self.refresh_ondevice_info(device_connected=False)
+ self.library_view.set_device_connected(self.device_connected)
+ self.refresh_ondevice()
def info_read(self, job):
'''
@@ -760,9 +785,9 @@ class DeviceMixin(object): # {{{
self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
self.sync_news()
self.sync_catalogs()
- self.refresh_ondevice_info(device_connected = True)
+ self.refresh_ondevice()
- def refresh_ondevice_info(self, device_connected, reset_only = False):
+ def refresh_ondevice(self, reset_only = False):
'''
Force the library view to refresh, taking into consideration new
device books information
@@ -770,7 +795,7 @@ class DeviceMixin(object): # {{{
self.book_on_device(None, reset=True)
if reset_only:
return
- self.library_view.set_device_connected(device_connected)
+ self.library_view.model().refresh_ondevice()
# }}}
@@ -803,7 +828,7 @@ class DeviceMixin(object): # {{{
self.book_on_device(None, reset=True)
# We need to reset the ondevice flags in the library. Use a big hammer,
# so we don't need to worry about whether some succeeded or not.
- self.refresh_ondevice_info(device_connected=True, reset_only=False)
+ self.refresh_ondevice(reset_only=False)
def dispatch_sync_event(self, dest, delete, specific):
rows = self.library_view.selectionModel().selectedRows()
@@ -1255,10 +1280,11 @@ class DeviceMixin(object): # {{{
:param files: List of either paths to files or file like objects
'''
titles = [i.title for i in metadata]
+ plugboards = self.library_view.model().db.prefs.get('plugboards', {})
job = self.device_manager.upload_books(
Dispatcher(self.books_uploaded),
files, names, on_card=on_card,
- metadata=metadata, titles=titles
+ metadata=metadata, titles=titles, plugboards=plugboards
)
self.upload_memory[job] = (metadata, on_card, memory, files)
@@ -1300,7 +1326,7 @@ class DeviceMixin(object): # {{{
if not self.set_books_in_library(self.booklists(), reset=True):
self.upload_booklists()
self.book_on_device(None, reset=True)
- self.refresh_ondevice_info(device_connected = True)
+ self.refresh_ondevice()
view = self.card_a_view if on_card == 'carda' else \
self.card_b_view if on_card == 'cardb' else self.memory_view
diff --git a/src/calibre/gui2/device_drivers/configwidget.py b/src/calibre/gui2/device_drivers/configwidget.py
index 3d9c9ab2ee..64321e1a46 100644
--- a/src/calibre/gui2/device_drivers/configwidget.py
+++ b/src/calibre/gui2/device_drivers/configwidget.py
@@ -6,7 +6,9 @@ __docformat__ = 'restructuredtext en'
from PyQt4.Qt import QWidget, QListWidgetItem, Qt, QVariant, SIGNAL
+from calibre.gui2 import error_dialog
from calibre.gui2.device_drivers.configwidget_ui import Ui_ConfigWidget
+from calibre.utils.formatter import validation_formatter
class ConfigWidget(QWidget, Ui_ConfigWidget):
@@ -77,3 +79,15 @@ class ConfigWidget(QWidget, Ui_ConfigWidget):
def use_author_sort(self):
return self.opt_use_author_sort.isChecked()
+
+ def validate(self):
+ tmpl = unicode(self.opt_save_template.text())
+ try:
+ validation_formatter.validate(tmpl)
+ return True
+ except Exception, err:
+ error_dialog(self, _('Invalid template'),
+ '
'+_('The template %s is invalid:')%tmpl + \
+ '
'+unicode(err), show=True)
+
+ return False
diff --git a/src/calibre/gui2/dialogs/edit_authors_dialog.py b/src/calibre/gui2/dialogs/edit_authors_dialog.py
index 7fe50181a3..2fdb8e28cc 100644
--- a/src/calibre/gui2/dialogs/edit_authors_dialog.py
+++ b/src/calibre/gui2/dialogs/edit_authors_dialog.py
@@ -6,6 +6,7 @@ __license__ = 'GPL v3'
from PyQt4.Qt import Qt, QDialog, QTableWidgetItem, QAbstractItemView
from calibre.ebooks.metadata import author_to_author_sort
+from calibre.gui2 import error_dialog
from calibre.gui2.dialogs.edit_authors_dialog_ui import Ui_EditAuthorsDialog
class tableItem(QTableWidgetItem):
@@ -109,6 +110,12 @@ class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog):
if col == 0:
item = self.table.item(row, 0)
aut = unicode(item.text()).strip()
+ amper = aut.find('&')
+ if amper >= 0:
+ error_dialog(self.parent(), _('Invalid author name'),
+ _('Author names cannot contain & characters.')).exec_()
+ aut = aut.replace('&', '%')
+ self.table.item(row, 0).setText(aut)
c = self.table.item(row, 1)
c.setText(author_to_author_sort(aut))
item = c
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py
index 2e5c7838ca..e27f4b5eab 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.py
+++ b/src/calibre/gui2/dialogs/metadata_bulk.py
@@ -3,57 +3,131 @@ __copyright__ = '2008, Kovid Goyal '
'''Dialog to edit metadata in bulk'''
-from threading import Thread
import re
-from PyQt4.Qt import QDialog, QGridLayout
+from PyQt4.Qt import Qt, QDialog, QGridLayout, QVBoxLayout, QFont, QLabel, \
+ pyqtSignal
from PyQt4 import QtGui
from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
-from calibre.ebooks.metadata import string_to_authors, \
- authors_to_string
+from calibre.ebooks.metadata import string_to_authors, authors_to_string
from calibre.gui2.custom_column_widgets import populate_metadata_page
-from calibre.gui2.dialogs.progress import BlockingBusy
-from calibre.gui2 import error_dialog, Dispatcher
+from calibre.gui2 import error_dialog
+from calibre.gui2.progress_indicator import ProgressIndicator
+from calibre.utils.config import dynamic
-class Worker(Thread):
+class MyBlockingBusy(QDialog):
+
+ do_one_signal = pyqtSignal()
+
+ phases = ['',
+ _('Title/Author'),
+ _('Standard metadata'),
+ _('Custom metadata'),
+ _('Search/Replace'),
+ ]
+
+ def __init__(self, msg, args, db, ids, cc_widgets, s_r_func,
+ parent=None, window_title=_('Working')):
+ QDialog.__init__(self, parent)
+
+ self._layout = QVBoxLayout()
+ self.setLayout(self._layout)
+ self.msg_text = msg
+ self.msg = QLabel(msg+' ') # Ensure dialog is wide enough
+ #self.msg.setWordWrap(True)
+ self.font = QFont()
+ self.font.setPointSize(self.font.pointSize() + 8)
+ self.msg.setFont(self.font)
+ self.pi = ProgressIndicator(self)
+ self.pi.setDisplaySize(100)
+ self._layout.addWidget(self.pi, 0, Qt.AlignHCenter)
+ self._layout.addSpacing(15)
+ self._layout.addWidget(self.msg, 0, Qt.AlignHCenter)
+ self.setWindowTitle(window_title)
+ self.resize(self.sizeHint())
+ self.start()
- def __init__(self, args, db, ids, cc_widgets, callback):
- Thread.__init__(self)
self.args = args
self.db = db
self.ids = ids
self.error = None
- self.callback = callback
self.cc_widgets = cc_widgets
+ self.s_r_func = s_r_func
+ self.do_one_signal.connect(self.do_one_safe, Qt.QueuedConnection)
- def doit(self):
+ def start(self):
+ self.pi.startAnimation()
+
+ def stop(self):
+ self.pi.stopAnimation()
+
+ def accept(self):
+ self.stop()
+ return QDialog.accept(self)
+
+ def exec_(self):
+ self.current_index = 0
+ self.current_phase = 1
+ self.do_one_signal.emit()
+ return QDialog.exec_(self)
+
+ def do_one_safe(self):
+ try:
+ if self.current_index >= len(self.ids):
+ self.current_phase += 1
+ self.current_index = 0
+ if self.current_phase > 4:
+ self.db.commit()
+ return self.accept()
+ id = self.ids[self.current_index]
+ percent = int((self.current_index*100)/float(len(self.ids)))
+ self.msg.setText(self.msg_text.format(self.phases[self.current_phase],
+ percent))
+ self.do_one(id)
+ except Exception, err:
+ import traceback
+ try:
+ err = unicode(err)
+ except:
+ err = repr(err)
+ self.error = (err, traceback.format_exc())
+ return self.accept()
+
+ def do_one(self, id):
remove, add, au, aus, do_aus, rating, pub, do_series, \
do_autonumber, do_remove_format, remove_format, do_swap_ta, \
- do_remove_conv, do_auto_author, series = self.args
+ do_remove_conv, do_auto_author, series, do_series_restart, \
+ series_start_value, do_title_case, clear_series = self.args
+
# first loop: do author and title. These will commit at the end of each
# operation, because each operation modifies the file system. We want to
# try hard to keep the DB and the file system in sync, even in the face
# of exceptions or forced exits.
- for id in self.ids:
+ if self.current_phase == 1:
+ title_set = False
if do_swap_ta:
title = self.db.title(id, index_is_id=True)
aum = self.db.authors(id, index_is_id=True)
if aum:
aum = [a.strip().replace('|', ',') for a in aum.split(',')]
new_title = authors_to_string(aum)
+ if do_title_case:
+ new_title = new_title.title()
self.db.set_title(id, new_title, notify=False)
+ title_set = True
if title:
new_authors = string_to_authors(title)
self.db.set_authors(id, new_authors, notify=False)
-
+ if do_title_case and not title_set:
+ title = self.db.title(id, index_is_id=True)
+ self.db.set_title(id, title.title(), notify=False)
if au:
self.db.set_authors(id, string_to_authors(au), notify=False)
-
- # All of these just affect the DB, so we can tolerate a total rollback
- for id in self.ids:
+ elif self.current_phase == 2:
+ # All of these just affect the DB, so we can tolerate a total rollback
if do_auto_author:
x = self.db.author_sort_from_book(id, index_is_id=True)
if x:
@@ -68,8 +142,15 @@ class Worker(Thread):
if pub:
self.db.set_publisher(id, pub, notify=False, commit=False)
+ if clear_series:
+ self.db.set_series(id, '', notify=False, commit=False)
+
if do_series:
- next = self.db.get_next_series_num_for(series)
+ if do_series_restart:
+ next = series_start_value
+ series_start_value += 1
+ else:
+ next = self.db.get_next_series_num_for(series)
self.db.set_series(id, series, notify=False, commit=False)
num = next if do_autonumber and series else 1.0
self.db.set_series_index(id, num, notify=False, commit=False)
@@ -79,42 +160,44 @@ class Worker(Thread):
if do_remove_conv:
self.db.delete_conversion_options(id, 'PIPE', commit=False)
- self.db.commit()
-
- for w in self.cc_widgets:
- w.commit(self.ids)
- self.db.bulk_modify_tags(self.ids, add=add, remove=remove,
- notify=False)
-
- def run(self):
- try:
- self.doit()
- except Exception, err:
- import traceback
- try:
- err = unicode(err)
- except:
- err = repr(err)
- self.error = (err, traceback.format_exc())
-
- self.callback()
+ elif self.current_phase == 3:
+ # both of these are fast enough to just do them all
+ for w in self.cc_widgets:
+ w.commit(self.ids)
+ self.db.bulk_modify_tags(self.ids, add=add, remove=remove,
+ notify=False)
+ self.current_index = len(self.ids)
+ elif self.current_phase == 4:
+ self.s_r_func(id)
+ # do the next one
+ self.current_index += 1
+ self.do_one_signal.emit()
class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
- s_r_functions = {
- '' : lambda x: x,
- _('Lower Case') : lambda x: x.lower(),
- _('Upper Case') : lambda x: x.upper(),
- _('Title Case') : lambda x: x.title(),
- }
+ s_r_functions = { '' : lambda x: x,
+ _('Lower Case') : lambda x: x.lower(),
+ _('Upper Case') : lambda x: x.upper(),
+ _('Title Case') : lambda x: x.title(),
+ }
- def __init__(self, window, rows, db):
+ s_r_match_modes = [ _('Character match'),
+ _('Regular Expression'),
+ ]
+
+ s_r_replace_modes = [ _('Replace field'),
+ _('Prepend to field'),
+ _('Append to field'),
+ ]
+
+ def __init__(self, window, rows, model):
QDialog.__init__(self, window)
Ui_MetadataBulkDialog.__init__(self)
self.setupUi(self)
- self.db = db
- self.ids = [db.id(r) for r in rows]
+ self.model = model
+ self.db = model.db
+ self.ids = [self.db.id(r) for r in rows]
self.box_title.setText('' +
_('Editing meta information for %d books') %
len(rows))
@@ -135,8 +218,9 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.series.currentIndexChanged[int].connect(self.series_changed)
self.series.editTextChanged.connect(self.series_changed)
self.tag_editor_button.clicked.connect(self.tag_editor)
+ self.autonumber_series.stateChanged[int].connect(self.auto_number_changed)
- if len(db.custom_column_label_map) == 0:
+ if len(self.db.custom_field_keys(include_composites=False)) == 0:
self.central_widget.removeTab(1)
else:
self.create_custom_column_editors()
@@ -148,86 +232,165 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.search_for.initialize('bulk_edit_search_for')
self.replace_with.initialize('bulk_edit_replace_with')
self.test_text.initialize('bulk_edit_test_test')
- fields = ['']
+ self.all_fields = ['']
+ self.writable_fields = ['']
fm = self.db.field_metadata
for f in fm:
if (f in ['author_sort'] or (
- fm[f]['datatype'] == 'text' or fm[f]['datatype'] == 'series')
+ fm[f]['datatype'] in ['text', 'series'])
and fm[f].get('search_terms', None)
and f not in ['formats', 'ondevice']):
- fields.append(f)
- fields.sort()
- self.search_field.addItems(fields)
- self.search_field.setMaxVisibleItems(min(len(fields), 20))
+ self.all_fields.append(f)
+ self.writable_fields.append(f)
+ if fm[f]['datatype'] == 'composite':
+ self.all_fields.append(f)
+ self.all_fields.sort()
+ self.writable_fields.sort()
+ self.search_field.setMaxVisibleItems(20)
+ self.destination_field.setMaxVisibleItems(20)
offset = 10
- self.s_r_number_of_books = min(7, len(self.ids))
+ self.s_r_number_of_books = min(10, len(self.ids))
for i in range(1,self.s_r_number_of_books+1):
w = QtGui.QLabel(self.tabWidgetPage3)
w.setText(_('Book %d:')%i)
- self.gridLayout1.addWidget(w, i+offset, 0, 1, 1)
+ self.testgrid.addWidget(w, i+offset, 0, 1, 1)
w = QtGui.QLineEdit(self.tabWidgetPage3)
w.setReadOnly(True)
name = 'book_%d_text'%i
setattr(self, name, w)
self.book_1_text.setObjectName(name)
- self.gridLayout1.addWidget(w, i+offset, 1, 1, 1)
+ self.testgrid.addWidget(w, i+offset, 1, 1, 1)
w = QtGui.QLineEdit(self.tabWidgetPage3)
w.setReadOnly(True)
name = 'book_%d_result'%i
setattr(self, name, w)
self.book_1_text.setObjectName(name)
- self.gridLayout1.addWidget(w, i+offset, 2, 1, 1)
+ self.testgrid.addWidget(w, i+offset, 2, 1, 1)
- self.s_r_heading.setText('
'+
- _('Search and replace in text fields using '
- 'regular expressions. The search text is an '
- 'arbitrary python-compatible regular expression. '
- 'The replacement text can contain backreferences '
- 'to parenthesized expressions in the pattern. '
- 'The search is not anchored, and can match and '
- 'replace multiple times on the same string. See '
- ' '
- 'this reference '
- 'for more information, and in particular the \'sub\' '
- 'function.') + '
' + _(
- 'Note: you can destroy your library '
- 'using this feature. Changes are permanent. There '
- 'is no undo function. You are strongly encouraged '
- 'to back up your library before proceeding.'))
+ self.main_heading = _(
+ 'You can destroy your library using this feature. '
+ 'Changes are permanent. There is no undo function. '
+ ' This feature is experimental, and there may be bugs. '
+ 'You are strongly encouraged to back up your library '
+ 'before proceeding.
'
+ 'Search and replace in text fields using character matching '
+ 'or regular expressions. ')
+
+ self.character_heading = _(
+ 'In character mode, the field is searched for the entered '
+ 'search text. The text is replaced by the specified replacement '
+ 'text everywhere it is found in the specified field. After '
+ 'replacement is finished, the text can be changed to '
+ 'upper-case, lower-case, or title-case. If the case-sensitive '
+ 'check box is checked, the search text must match exactly. If '
+ 'it is unchecked, the search text will match both upper- and '
+ 'lower-case letters'
+ )
+
+ self.regexp_heading = _(
+ 'In regular expression mode, the search text is an '
+ 'arbitrary python-compatible regular expression. The '
+ 'replacement text can contain backreferences to parenthesized '
+ 'expressions in the pattern. The search is not anchored, '
+ 'and can match and replace multiple times on the same string. '
+ 'The modification functions (lower-case etc) are applied to the '
+ 'matched text, not to the field as a whole. '
+ 'The destination box specifies the field where the result after '
+ 'matching and replacement is to be assigned. You can replace '
+ 'the text in the field, or prepend or append the matched text. '
+ 'See '
+ 'this reference for more information on python\'s regular '
+ 'expressions, and in particular the \'sub\' function.'
+ )
+
+ self.search_mode.addItems(self.s_r_match_modes)
+ self.search_mode.setCurrentIndex(dynamic.get('s_r_search_mode', 0))
+ self.replace_mode.addItems(self.s_r_replace_modes)
+ self.replace_mode.setCurrentIndex(0)
+
+ self.s_r_search_mode = 0
self.s_r_error = None
self.s_r_obj = None
self.replace_func.addItems(sorted(self.s_r_functions.keys()))
- self.search_field.currentIndexChanged[str].connect(self.s_r_field_changed)
+ self.search_mode.currentIndexChanged[int].connect(self.s_r_search_mode_changed)
+ self.search_field.currentIndexChanged[int].connect(self.s_r_search_field_changed)
+ self.destination_field.currentIndexChanged[str].connect(self.s_r_destination_field_changed)
+
+ self.replace_mode.currentIndexChanged[int].connect(self.s_r_paint_results)
self.replace_func.currentIndexChanged[str].connect(self.s_r_paint_results)
self.search_for.editTextChanged[str].connect(self.s_r_paint_results)
self.replace_with.editTextChanged[str].connect(self.s_r_paint_results)
self.test_text.editTextChanged[str].connect(self.s_r_paint_results)
+ self.comma_separated.stateChanged.connect(self.s_r_paint_results)
+ self.case_sensitive.stateChanged.connect(self.s_r_paint_results)
self.central_widget.setCurrentIndex(0)
- def s_r_field_changed(self, txt):
- txt = unicode(txt)
+ self.search_for.completer().setCaseSensitivity(Qt.CaseSensitive)
+ self.replace_with.completer().setCaseSensitivity(Qt.CaseSensitive)
+
+ self.s_r_search_mode_changed(self.search_mode.currentIndex())
+
+ def s_r_get_field(self, mi, field):
+ if field:
+ fm = self.db.metadata_for_field(field)
+ val = mi.get(field, None)
+ if val is None:
+ val = []
+ elif not fm['is_multiple']:
+ val = [val]
+ elif field == 'authors':
+ val = [v.replace(',', '|') for v in val]
+ else:
+ val = []
+ return val
+
+ def s_r_search_field_changed(self, idx):
for i in range(0, self.s_r_number_of_books):
- if txt:
- fm = self.db.field_metadata[txt]
- id = self.ids[i]
- val = self.db.get_property(id, index_is_id=True,
- loc=fm['rec_index'])
- if val is None:
- val = ''
- if fm['is_multiple']:
- val = [t.strip() for t in val.split(fm['is_multiple']) if t.strip()]
- if val:
- val.sort(cmp=lambda x,y: cmp(x.lower(), y.lower()))
- val = val[0]
- if txt == 'authors':
- val = val.replace('|', ',')
- else:
- val = ''
- else:
- val = ''
w = getattr(self, 'book_%d_text'%(i+1))
- w.setText(val)
+ mi = self.db.get_metadata(self.ids[i], index_is_id=True)
+ src = unicode(self.search_field.currentText())
+ t = self.s_r_get_field(mi, src)
+ w.setText(''.join(t[0:1]))
+
+ if self.search_mode.currentIndex() == 0:
+ self.destination_field.setCurrentIndex(idx)
+ else:
+ self.s_r_paint_results(None)
+
+ def s_r_destination_field_changed(self, txt):
+ txt = unicode(txt)
+ self.comma_separated.setEnabled(True)
+ if txt:
+ fm = self.db.metadata_for_field(txt)
+ if fm['is_multiple']:
+ self.comma_separated.setEnabled(False)
+ self.comma_separated.setChecked(True)
+ self.s_r_paint_results(None)
+
+ def s_r_search_mode_changed(self, val):
+ self.search_field.clear()
+ self.destination_field.clear()
+ if val == 0:
+ self.search_field.addItems(self.writable_fields)
+ self.destination_field.addItems(self.writable_fields)
+ self.destination_field.setCurrentIndex(0)
+ self.destination_field.setVisible(False)
+ self.destination_field_label.setVisible(False)
+ self.replace_mode.setCurrentIndex(0)
+ self.replace_mode.setVisible(False)
+ self.replace_mode_label.setVisible(False)
+ self.comma_separated.setVisible(False)
+ self.s_r_heading.setText('
'+self.main_heading + self.character_heading)
+ else:
+ self.search_field.addItems(self.all_fields)
+ self.destination_field.addItems(self.writable_fields)
+ self.destination_field.setVisible(True)
+ self.destination_field_label.setVisible(True)
+ self.replace_mode.setVisible(True)
+ self.replace_mode_label.setVisible(True)
+ self.comma_separated.setVisible(True)
+ self.s_r_heading.setText('
'+self.main_heading + self.regexp_heading)
self.s_r_paint_results(None)
def s_r_set_colors(self):
@@ -242,17 +405,75 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
getattr(self, 'book_%d_result'%(i+1)).setText('')
def s_r_func(self, match):
- rf = self.s_r_functions[unicode(self.replace_func.currentText())]
- rv = unicode(self.replace_with.text())
- val = match.expand(rv)
- return rf(val)
+ rfunc = self.s_r_functions[unicode(self.replace_func.currentText())]
+ rtext = unicode(self.replace_with.text())
+ rtext = match.expand(rtext)
+ return rfunc(rtext)
+
+ def s_r_do_regexp(self, mi):
+ src_field = unicode(self.search_field.currentText())
+ src = self.s_r_get_field(mi, src_field)
+ result = []
+ rfunc = self.s_r_functions[unicode(self.replace_func.currentText())]
+ for s in src:
+ t = self.s_r_obj.sub(self.s_r_func, s)
+ if self.search_mode.currentIndex() == 0:
+ t = rfunc(t)
+ result.append(t)
+ return result
+
+ def s_r_do_destination(self, mi, val):
+ src = unicode(self.search_field.currentText())
+ if src == '':
+ return ''
+ dest = unicode(self.destination_field.currentText())
+ if dest == '':
+ if self.db.metadata_for_field(src)['datatype'] == 'composite':
+ raise Exception(_('You must specify a destination when source is a composite field'))
+ dest = src
+ dest_mode = self.replace_mode.currentIndex()
+
+ if dest_mode != 0:
+ dest_val = mi.get(dest, '')
+ if dest_val is None:
+ dest_val = []
+ elif isinstance(dest_val, list):
+ if dest == 'authors':
+ dest_val = [v.replace(',', '|') for v in dest_val]
+ else:
+ dest_val = [dest_val]
+ else:
+ dest_val = []
+
+ if len(val) > 0:
+ if src == 'authors':
+ val = [v.replace(',', '|') for v in val]
+ if dest_mode == 1:
+ val.extend(dest_val)
+ elif dest_mode == 2:
+ val[0:0] = dest_val
+ return val
+
+ def s_r_replace_mode_separator(self):
+ if self.comma_separated.isChecked():
+ return ','
+ return ''
def s_r_paint_results(self, txt):
self.s_r_error = None
self.s_r_set_colors()
+
+ if self.case_sensitive.isChecked():
+ flags = 0
+ else:
+ flags = re.I
+
try:
- self.s_r_obj = re.compile(unicode(self.search_for.text()))
- except re.error as e:
+ if self.search_mode.currentIndex() == 0:
+ self.s_r_obj = re.compile(re.escape(unicode(self.search_for.text())), flags)
+ else:
+ self.s_r_obj = re.compile(unicode(self.search_for.text()), flags)
+ except Exception as e:
self.s_r_obj = None
self.s_r_error = e
self.s_r_set_colors()
@@ -261,66 +482,72 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
try:
self.test_result.setText(self.s_r_obj.sub(self.s_r_func,
unicode(self.test_text.text())))
- except re.error as e:
+ except Exception as e:
self.s_r_error = e
self.s_r_set_colors()
return
for i in range(0,self.s_r_number_of_books):
- wt = getattr(self, 'book_%d_text'%(i+1))
+ mi = self.db.get_metadata(self.ids[i], index_is_id=True)
wr = getattr(self, 'book_%d_result'%(i+1))
try:
- wr.setText(self.s_r_obj.sub(self.s_r_func, unicode(wt.text())))
- except re.error as e:
+ result = self.s_r_do_regexp(mi)
+ t = self.s_r_do_destination(mi, result[0:1])
+ t = self.s_r_replace_mode_separator().join(t)
+ wr.setText(t)
+ except Exception as e:
self.s_r_error = e
self.s_r_set_colors()
break
- def do_search_replace(self):
- field = unicode(self.search_field.currentText())
- if not field or not self.s_r_obj:
+ def do_search_replace(self, id):
+ source = unicode(self.search_field.currentText())
+ if not source or not self.s_r_obj:
return
+ dest = unicode(self.destination_field.currentText())
+ if not dest:
+ dest = source
+ dfm = self.db.field_metadata[dest]
- fm = self.db.field_metadata[field]
+ mi = self.db.get_metadata(id, index_is_id=True,)
+ val = mi.get(source)
+ if val is None:
+ return
+ val = self.s_r_do_regexp(mi)
+ val = self.s_r_do_destination(mi, val)
+ if dfm['is_multiple']:
+ if dfm['is_custom']:
+ # The standard tags and authors values want to be lists.
+ # All custom columns are to be strings
+ val = dfm['is_multiple'].join(val)
+ if dest == 'authors' and len(val) == 0:
+ error_dialog(self, _('Search/replace invalid'),
+ _('Authors cannot be set to the empty string. '
+ 'Book title %s not processed')%mi.title,
+ show=True)
+ return
+ else:
+ val = self.s_r_replace_mode_separator().join(val)
+ if dest == 'title' and len(val) == 0:
+ error_dialog(self, _('Search/replace invalid'),
+ _('Title cannot be set to the empty string. '
+ 'Book title %s not processed')%mi.title,
+ show=True)
+ return
- def apply_pattern(val):
- try:
- return self.s_r_obj.sub(self.s_r_func, val)
- except:
- return val
-
- for id in self.ids:
- val = self.db.get_property(id, index_is_id=True,
- loc=fm['rec_index'])
- if val is None:
- continue
- if fm['is_multiple']:
- res = []
- for val in [t.strip() for t in val.split(fm['is_multiple'])]:
- v = apply_pattern(val).strip()
- if v:
- res.append(v)
- val = res
- if fm['is_custom']:
- # The standard tags and authors values want to be lists.
- # All custom columns are to be strings
- val = fm['is_multiple'].join(val)
- elif field == 'authors':
- val = [v.replace('|', ',') for v in val]
+ if dfm['is_custom']:
+ extra = self.db.get_custom_extra(id, label=dfm['label'], index_is_id=True)
+ self.db.set_custom(id, val, label=dfm['label'], extra=extra,
+ commit=False)
+ else:
+ if dest == 'comments':
+ setter = self.db.set_comment
else:
- val = apply_pattern(val)
-
- if fm['is_custom']:
- extra = self.db.get_custom_extra(id, label=fm['label'], index_is_id=True)
- self.db.set_custom(id, val, label=fm['label'], extra=extra,
- commit=False)
+ setter = getattr(self.db, 'set_'+dest)
+ if dest in ['title', 'authors']:
+ setter(id, val, notify=False)
else:
- if field == 'comments':
- setter = self.db.set_comment
- else:
- setter = getattr(self.db, 'set_'+field)
setter(id, val, notify=False, commit=False)
- self.db.commit()
def create_custom_column_editors(self):
w = self.central_widget.widget(1)
@@ -343,11 +570,11 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
def initalize_authors(self):
all_authors = self.db.all_authors()
- all_authors.sort(cmp=lambda x, y : cmp(x[1], y[1]))
+ all_authors.sort(cmp=lambda x, y : cmp(x[1].lower(), y[1].lower()))
for i in all_authors:
id, name = i
- name = authors_to_string([name.strip().replace('|', ',') for n in name.split(',')])
+ name = name.strip().replace('|', ',')
self.authors.addItem(name)
self.authors.setEditText('')
@@ -378,6 +605,16 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
self.tags.update_tags_cache(self.db.all_tags())
self.remove_tags.update_tags_cache(self.db.all_tags())
+ def auto_number_changed(self, state):
+ if state:
+ self.series_numbering_restarts.setEnabled(True)
+ self.series_start_number.setEnabled(True)
+ else:
+ self.series_numbering_restarts.setEnabled(False)
+ self.series_numbering_restarts.setChecked(False)
+ self.series_start_number.setEnabled(False)
+ self.series_start_number.setValue(1)
+
def accept(self):
if len(self.ids) < 1:
return QDialog.accept(self)
@@ -404,33 +641,42 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
rating = self.rating.value()
pub = unicode(self.publisher.text())
do_series = self.write_series
+ clear_series = self.clear_series.isChecked()
series = unicode(self.series.currentText()).strip()
do_autonumber = self.autonumber_series.isChecked()
+ do_series_restart = self.series_numbering_restarts.isChecked()
+ series_start_value = self.series_start_number.value()
do_remove_format = self.remove_format.currentIndex() > -1
remove_format = unicode(self.remove_format.currentText())
do_swap_ta = self.swap_title_and_author.isChecked()
do_remove_conv = self.remove_conversion_settings.isChecked()
do_auto_author = self.auto_author_sort.isChecked()
+ do_title_case = self.change_title_to_title_case.isChecked()
args = (remove, add, au, aus, do_aus, rating, pub, do_series,
do_autonumber, do_remove_format, remove_format, do_swap_ta,
- do_remove_conv, do_auto_author, series)
+ do_remove_conv, do_auto_author, series, do_series_restart,
+ series_start_value, do_title_case, clear_series)
- bb = BlockingBusy(_('Applying changes to %d books. This may take a while.')
- %len(self.ids), parent=self)
- self.worker = Worker(args, self.db, self.ids,
+ bb = MyBlockingBusy(_('Applying changes to %d books.\nPhase {0} {1}%%.')
+ %len(self.ids), args, self.db, self.ids,
getattr(self, 'custom_column_widgets', []),
- Dispatcher(bb.accept, parent=bb))
- self.worker.start()
- bb.exec_()
+ self.do_search_replace, parent=self)
- if self.worker.error is not None:
+ # The metadata backup thread causes database commits
+ # which can slow down bulk editing of large numbers of books
+ self.model.stop_metadata_backup()
+ try:
+ bb.exec_()
+ finally:
+ self.model.start_metadata_backup()
+
+ if bb.error is not None:
return error_dialog(self, _('Failed'),
- self.worker.error[0], det_msg=self.worker.error[1],
+ bb.error[0], det_msg=bb.error[1],
show=True)
- self.do_search_replace()
-
+ dynamic['s_r_search_mode'] = self.search_mode.currentIndex()
self.db.clean()
return QDialog.accept(self)
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui
index aca7b0cb75..60e24dbceb 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.ui
+++ b/src/calibre/gui2/dialogs/metadata_bulk.ui
@@ -6,8 +6,8 @@
0
0
- 679
- 685
+ 752
+ 715
@@ -225,23 +225,108 @@
-
-
-
- List of known series. You can add new series.
-
-
- List of known series. You can add new series.
-
-
- true
-
-
- QComboBox::InsertAlphabetically
-
-
- QComboBox::AdjustToContents
-
-
+
+
-
+
+
+ List of known series. You can add new series.
+
+
+ List of known series. You can add new series.
+
+
+ true
+
+
+ QComboBox::InsertAlphabetically
+
+
+ QComboBox::AdjustToContents
+
+
+
+ -
+
+
+ If checked, the series will be cleared
+
+
+ Clear series
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 20
+ 00
+
+
+
+
+
+
+ -
+
+
-
+
+
+ If not checked, the series number for the books will be set to 1.
+If checked, selected books will be automatically numbered, in the order
+you selected them. So if you selected Book A and then Book B,
+Book A will have series number 1 and Book B series number 2.
+
+
+ Automatically number books in this series
+
+
+
+ -
+
+
+ false
+
+
+ Series will normally be renumbered from the highest number in the database
+for that series. Checking this box will tell calibre to start numbering
+from the value in the box
+
+
+ Force numbers to start with
+
+
+
+ -
+
+
+ false
+
+
+ 1
+
+
+ 1
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 20
+ 10
+
+
+
+
+
-
@@ -270,16 +355,14 @@
- -
-
-
- Selected books will be automatically numbered,
-in the order you selected them.
-So if you selected Book A and then Book B,
-Book A will have series number 1 and Book B series number 2.
-
+
-
+
- Automatically number books in this series
+ Change title to title case
+
+
+ Force the title to be in title case. If both this and swap authors are checked,
+title and author are swapped before the title case is set
@@ -295,7 +378,7 @@ Future conversion of these books will use the default settings.
- -
+
-
Qt::Vertical
@@ -319,7 +402,7 @@ Future conversion of these books will use the default settings.
&Search and replace (experimental)
-
+
QLayout::SetMinimumSize
@@ -351,6 +434,47 @@ Future conversion of these books will use the default settings.
-
+
+
+ The name of the field that you want to search
+
+
+
+ -
+
+
-
+
+
+ Search mode:
+
+
+ search_mode
+
+
+
+ -
+
+
+ Choose whether to use basic text matching or advanced regular expression matching
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 20
+ 10
+
+
+
+
+
+
+ -
&Search for:
@@ -360,7 +484,33 @@ Future conversion of these books will use the default settings.
- -
+
-
+
+
+ Enter the what you are looking for, either plain text or a regular expression, depending on the mode
+
+
+
+ 100
+ 0
+
+
+
+
+ -
+
+
+ Check this box if the search string must match exactly upper and lower case. Uncheck it if case is to be ignored
+
+
+ Case sensitive
+
+
+ true
+
+
+
+ -
&Replace with:
@@ -370,29 +520,114 @@ Future conversion of these books will use the default settings.
- -
-
-
- -
-
-
- -
-
-
-
-
-
- Apply function &after replace:
-
-
- replace_func
+
+
+ The replacement text. The matched search text will be replaced with this string
-
-
+
+
-
+
+
+ Apply function after replace:
+
+
+ replace_func
+
+
+
+ -
+
+
+ Specify how the text is to be processed after matching and replacement. In character mode, the entire
+field is processed. In regular expression mode, only the matched text is processed
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 20
+ 10
+
+
+
+
+
+
+ -
+
+
+ &Destination field:
+
+
+ destination_field
+
+
-
+
+
+ The field that the text will be put into after all replacements. If blank, the source field is used.
+
+
+
+ -
+
+
-
+
+
+ Mode:
+
+
+ replace_mode
+
+
+
+ -
+
+
+ Specify how the text should be copied into the destination.
+
+
+
+ -
+
+
+ If the replace mode is prepend or append, then this box indicates whether a comma or
+nothing should be put between the original text and the inserted text
+
+
+ use comma
+
+
+ true
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 20
+ 10
+
+
+
+
+
+
+ -
Test &text
@@ -402,8 +637,8 @@ Future conversion of these books will use the default settings.
- -
-
+
-
+
Test re&sult
@@ -412,19 +647,33 @@ Future conversion of these books will use the default settings.
- -
-
-
- Your test:
+
-
+
+
+ QFrame::NoFrame
+
+ true
+
+
+
+
-
+
+
+ Your test:
+
+
+
+ -
+
+
+ -
+
+
+
+
- -
-
-
- -
-
-
-
@@ -433,7 +682,7 @@ Future conversion of these books will use the default settings.
20
- 40
+ 0
diff --git a/src/calibre/gui2/dialogs/metadata_single.ui b/src/calibre/gui2/dialogs/metadata_single.ui
index dbf825e706..18bcf2dc4c 100644
--- a/src/calibre/gui2/dialogs/metadata_single.ui
+++ b/src/calibre/gui2/dialogs/metadata_single.ui
@@ -630,10 +630,16 @@ Using this button to create author sort will change author sort from red to gree
Remove border (if any) from cover
+
+ T&rim
+
:/images/trim.png:/images/trim.png
+
+ Qt::ToolButtonTextBesideIcon
+
-
diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py
index fd8184933f..30f4a2d8a2 100644
--- a/src/calibre/gui2/dialogs/scheduler.py
+++ b/src/calibre/gui2/dialogs/scheduler.py
@@ -57,6 +57,10 @@ class SchedulerDialog(QDialog, Ui_Dialog):
self.old_news.setValue(gconf['oldest_news'])
+ def keyPressEvent(self, ev):
+ if ev.key() not in (Qt.Key_Enter, Qt.Key_Return):
+ return QDialog.keyPressEvent(self, ev)
+
def break_cycles(self):
self.disconnect(self.recipe_model, SIGNAL('searched(PyQt_PyObject)'),
self.search_done)
diff --git a/src/calibre/gui2/dialogs/tweak_epub.ui b/src/calibre/gui2/dialogs/tweak_epub.ui
index ccd33f44ab..063460aaae 100644
--- a/src/calibre/gui2/dialogs/tweak_epub.ui
+++ b/src/calibre/gui2/dialogs/tweak_epub.ui
@@ -32,7 +32,7 @@
&Explode ePub
-
+
:/images/wizard.png:/images/wizard.png
@@ -49,7 +49,7 @@
&Rebuild ePub
-
+
:/images/exec.png:/images/exec.png
@@ -63,7 +63,7 @@
&Cancel
-
+
:/images/window-close.png:/images/window-close.png
@@ -71,7 +71,7 @@
-
- Explode the ePub to display contents in a file browser window. To tweak individual files, right-click, then 'Open with...' your editor of choice. When tweaks are complete, close the file browser window. Rebuild the ePub, updating your calibre library.
+ <p>Explode the ePub to display contents in a file browser window. To tweak individual files, right-click, then 'Open with...' your editor of choice. When tweaks are complete, close the file browser window <b>and the editor windows you used to edit files in the epub</b>.</p><p>Rebuild the ePub, updating your calibre library.</p>
true
diff --git a/src/calibre/gui2/layout.py b/src/calibre/gui2/layout.py
index ec7e023dc1..0d2f9bfd92 100644
--- a/src/calibre/gui2/layout.py
+++ b/src/calibre/gui2/layout.py
@@ -152,7 +152,7 @@ class SearchBar(QWidget): # {{{
l.addWidget(x)
x.setToolTip(_("Advanced search"))
- self.label = x = QLabel('&Search:')
+ self.label = x = QLabel(_('&Search:'))
l.addWidget(self.label)
x.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
diff --git a/src/calibre/gui2/library/delegates.py b/src/calibre/gui2/library/delegates.py
index bf233b1175..1156d1ace9 100644
--- a/src/calibre/gui2/library/delegates.py
+++ b/src/calibre/gui2/library/delegates.py
@@ -15,10 +15,11 @@ from PyQt4.Qt import QColor, Qt, QModelIndex, QSize, \
QStyledItemDelegate, QCompleter, \
QComboBox
-from calibre.gui2 import UNDEFINED_QDATE
+from calibre.gui2 import UNDEFINED_QDATE, error_dialog
from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
from calibre.utils.date import now, format_date
from calibre.utils.config import tweaks
+from calibre.utils.formatter import validation_formatter
from calibre.gui2.dialogs.comments_dialog import CommentsDialog
class RatingDelegate(QStyledItemDelegate): # {{{
@@ -303,6 +304,33 @@ class CcBoolDelegate(QStyledItemDelegate): # {{{
val = 2 if val is None else 1 if not val else 0
editor.setCurrentIndex(val)
+# }}}
+
+class CcTemplateDelegate(QStyledItemDelegate): # {{{
+ def __init__(self, parent):
+ '''
+ Delegate for custom_column bool data.
+ '''
+ QStyledItemDelegate.__init__(self, parent)
+
+ def createEditor(self, parent, option, index):
+ return EnLineEdit(parent)
+
+ def setModelData(self, editor, model, index):
+ val = unicode(editor.text())
+ try:
+ validation_formatter.validate(val)
+ except Exception, err:
+ error_dialog(self.parent(), _('Invalid template'),
+ '
'+_('The template %s is invalid:')%val + \
+ '
'+str(err), show=True)
+ model.setData(index, QVariant(val), Qt.EditRole)
+
+ def setEditorData(self, editor, index):
+ m = index.model()
+ val = m.custom_columns[m.column_map[index.column()]]['display']['composite_template']
+ editor.setText(val)
+
# }}}
diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py
index 53f701386b..a808fd9c43 100644
--- a/src/calibre/gui2/library/models.py
+++ b/src/calibre/gui2/library/models.py
@@ -21,7 +21,7 @@ from calibre.utils.date import dt_factory, qt_to_dt, isoformat
from calibre.ebooks.metadata.meta import set_metadata as _set_metadata
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, \
- REGEXP_MATCH, CoverCache
+ REGEXP_MATCH, CoverCache, MetadataBackup
from calibre.library.cli import parse_series_string
from calibre import strftime, isbytestring, prepare_string_for_xml
from calibre.constants import filesystem_encoding
@@ -72,7 +72,7 @@ class BooksModel(QAbstractTableModel): # {{{
'publisher' : _("Publisher"),
'tags' : _("Tags"),
'series' : _("Series"),
- }
+ }
def __init__(self, parent=None, buffer=40):
QAbstractTableModel.__init__(self, parent)
@@ -89,6 +89,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.alignment_map = {}
self.buffer_size = buffer
self.cover_cache = None
+ self.metadata_backup = None
self.bool_yes_icon = QIcon(I('ok.png'))
self.bool_no_icon = QIcon(I('list_remove.png'))
self.bool_blank_icon = QIcon(I('blank.png'))
@@ -120,6 +121,9 @@ class BooksModel(QAbstractTableModel): # {{{
def set_device_connected(self, is_connected):
self.device_connected = is_connected
+ self.refresh_ondevice()
+
+ def refresh_ondevice(self):
self.db.refresh_ondevice()
self.refresh() # does a resort()
self.research()
@@ -129,7 +133,7 @@ class BooksModel(QAbstractTableModel): # {{{
def set_database(self, db):
self.db = db
- self.custom_columns = self.db.field_metadata.get_custom_field_metadata()
+ self.custom_columns = self.db.field_metadata.custom_field_metadata()
self.column_map = list(self.orig_headers.keys()) + \
list(self.custom_columns)
def col_idx(name):
@@ -151,13 +155,28 @@ class BooksModel(QAbstractTableModel): # {{{
self.database_changed.emit(db)
if self.cover_cache is not None:
self.cover_cache.stop()
+ # Would like to to a join here, but the thread might be waiting to
+ # do something on the GUI thread. Deadlock.
self.cover_cache = CoverCache(db, FunctionDispatcher(self.db.cover))
self.cover_cache.start()
+ self.stop_metadata_backup()
+ self.start_metadata_backup()
def refresh_cover(event, ids):
if event == 'cover' and self.cover_cache is not None:
self.cover_cache.refresh(ids)
db.add_listener(refresh_cover)
+ def start_metadata_backup(self):
+ self.metadata_backup = MetadataBackup(self.db)
+ self.metadata_backup.start()
+
+ def stop_metadata_backup(self):
+ if getattr(self, 'metadata_backup', None) is not None:
+ self.metadata_backup.stop()
+ # Would like to to a join here, but the thread might be waiting to
+ # do something on the GUI thread. Deadlock.
+
+
def refresh_ids(self, ids, current_row=-1):
rows = self.db.refresh_ids(ids)
if rows:
@@ -318,7 +337,11 @@ class BooksModel(QAbstractTableModel): # {{{
data[_('Series')] = \
_('Book %s of %s.')%\
(sidx, prepare_string_for_xml(series))
-
+ mi = self.db.get_metadata(idx)
+ for key in mi.custom_field_keys():
+ name, val = mi.format_field(key)
+ if val:
+ data[name] = val
return data
def set_cache(self, idx):
@@ -367,7 +390,6 @@ class BooksModel(QAbstractTableModel): # {{{
return ans
def get_metadata(self, rows, rows_are_ids=False, full_metadata=False):
- # Should this add the custom columns? It doesn't at the moment
metadata, _full_metadata = [], []
if not rows_are_ids:
rows = [self.db.id(row.row()) for row in rows]
@@ -616,8 +638,9 @@ class BooksModel(QAbstractTableModel): # {{{
for col in self.custom_columns:
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'])
+ if datatype in ('text', 'comments', 'composite'):
+ 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':
@@ -625,8 +648,8 @@ 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':
@@ -692,7 +715,8 @@ class BooksModel(QAbstractTableModel): # {{{
return flags
def set_custom_column_data(self, row, colhead, value):
- typ = self.custom_columns[colhead]['datatype']
+ cc = self.custom_columns[colhead]
+ typ = cc['datatype']
label=self.db.field_metadata.key_to_label(colhead)
s_index = None
if typ in ('text', 'comments'):
@@ -718,8 +742,18 @@ class BooksModel(QAbstractTableModel): # {{{
val = qt_to_dt(val, as_utc=False)
elif typ == 'series':
val, s_index = parse_series_string(self.db, label, value.toString())
- self.db.set_custom(self.db.id(row), val, extra=s_index,
+ elif typ == 'composite':
+ tmpl = unicode(value.toString()).strip()
+ disp = cc['display']
+ disp['composite_template'] = tmpl
+ self.db.set_custom_column_metadata(cc['colnum'], display = disp)
+ self.refresh(reset=True)
+ return True
+
+ id = self.db.id(row)
+ self.db.set_custom(id, val, extra=s_index,
label=label, num=None, append=False, notify=True)
+ self.refresh_ids([id], current_row=row)
return True
def setData(self, index, value, role):
@@ -764,6 +798,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.dataChanged.emit(index, index)
return True
@@ -887,7 +922,7 @@ class DeviceBooksModel(BooksModel): # {{{
}
self.marked_for_deletion = {}
self.search_engine = OnDeviceSearch(self)
- self.editable = True
+ self.editable = ['title', 'authors', 'collections']
self.book_in_library = None
def mark_for_deletion(self, job, rows, rows_are_ids=False):
@@ -933,13 +968,13 @@ class DeviceBooksModel(BooksModel): # {{{
if self.map[index.row()] in self.indices_to_be_deleted():
return Qt.ItemIsUserCheckable # Can't figure out how to get the disabled flag in python
flags = QAbstractTableModel.flags(self, index)
- if index.isValid() and self.editable:
+ if index.isValid():
cname = self.column_map[index.column()]
- if cname in ('title', 'authors') or \
- (cname == 'collections' and \
- callable(getattr(self.db, 'supports_collections', None)) and \
- self.db.supports_collections() and \
- prefs['manage_device_metadata']=='manual'):
+ if cname in self.editable and \
+ (cname != 'collections' or \
+ (callable(getattr(self.db, 'supports_collections', None)) and \
+ self.db.supports_collections() and \
+ prefs['manage_device_metadata']=='manual')):
flags |= Qt.ItemIsEditable
return flags
@@ -1054,8 +1089,11 @@ class DeviceBooksModel(BooksModel): # {{{
img = QImage()
if hasattr(cdata, 'image_path'):
img.load(cdata.image_path)
- else:
- img.loadFromData(cdata)
+ elif cdata:
+ if isinstance(cdata, (tuple, list)):
+ img.loadFromData(cdata[-1])
+ else:
+ img.loadFromData(cdata)
if img.isNull():
img = self.default_image
data['cover'] = img
@@ -1220,7 +1258,14 @@ class DeviceBooksModel(BooksModel): # {{{
def set_editable(self, editable):
# Cannot edit if metadata is sent on connect. Reason: changes will
# revert to what is in the library on next connect.
- self.editable = editable and prefs['manage_device_metadata']!='on_connect'
+ if isinstance(editable, list):
+ self.editable = editable
+ elif editable:
+ self.editable = ['title', 'authors', 'collections']
+ else:
+ self.editable = []
+ if prefs['manage_device_metadata']=='on_connect':
+ self.editable = []
def set_search_restriction(self, s):
pass
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 724454dccf..4b6bda1d2a 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -9,11 +9,11 @@ import os
from functools import partial
from PyQt4.Qt import QTableView, Qt, QAbstractItemView, QMenu, pyqtSignal, \
- QModelIndex, QIcon
+ QModelIndex, QIcon, QItemSelection
from calibre.gui2.library.delegates import RatingDelegate, PubDateDelegate, \
TextDelegate, DateDelegate, TagsDelegate, CcTextDelegate, \
- CcBoolDelegate, CcCommentsDelegate, CcDateDelegate
+ CcBoolDelegate, CcCommentsDelegate, CcDateDelegate, CcTemplateDelegate
from calibre.gui2.library.models import BooksModel, DeviceBooksModel
from calibre.utils.config import tweaks, prefs
from calibre.gui2 import error_dialog, gprefs
@@ -47,6 +47,7 @@ class BooksView(QTableView): # {{{
self.cc_text_delegate = CcTextDelegate(self)
self.cc_bool_delegate = CcBoolDelegate(self)
self.cc_comments_delegate = CcCommentsDelegate(self)
+ self.cc_template_delegate = CcTemplateDelegate(self)
self.display_parent = parent
self._model = modelcls(self)
self.setModel(self._model)
@@ -391,6 +392,8 @@ class BooksView(QTableView): # {{{
self.setItemDelegateForColumn(cm.index(colhead), self.cc_bool_delegate)
elif cc['datatype'] == 'rating':
self.setItemDelegateForColumn(cm.index(colhead), self.rating_delegate)
+ elif cc['datatype'] == 'composite':
+ self.setItemDelegateForColumn(cm.index(colhead), self.cc_template_delegate)
else:
dattr = colhead+'_delegate'
delegate = colhead if hasattr(self, dattr) else 'text'
@@ -485,29 +488,29 @@ class BooksView(QTableView): # {{{
Select rows identified by identifiers. identifiers can be a set of ids,
row numbers or QModelIndexes.
'''
- selmode = self.selectionMode()
- self.setSelectionMode(QAbstractItemView.MultiSelection)
- try:
- rows = set([x.row() if hasattr(x, 'row') else x for x in
- identifiers])
- if using_ids:
- rows = set([])
- identifiers = set(identifiers)
- m = self.model()
- for row in range(m.rowCount(QModelIndex())):
- if m.id(row) in identifiers:
- rows.add(row)
- if rows:
- row = list(sorted(rows))[0]
- if change_current:
- self.set_current_row(row, select=False)
- if scroll:
- self.scroll_to_row(row)
- self.clearSelection()
- for r in rows:
- self.selectRow(r)
- finally:
- self.setSelectionMode(selmode)
+ rows = set([x.row() if hasattr(x, 'row') else x for x in
+ identifiers])
+ if using_ids:
+ rows = set([])
+ identifiers = set(identifiers)
+ m = self.model()
+ for row in xrange(m.rowCount(QModelIndex())):
+ if m.id(row) in identifiers:
+ rows.add(row)
+ rows = list(sorted(rows))
+ if rows:
+ row = rows[0]
+ if change_current:
+ self.set_current_row(row, select=False)
+ if scroll:
+ self.scroll_to_row(row)
+ sm = self.selectionModel()
+ sel = QItemSelection()
+ m = self.model()
+ max_col = m.columnCount(QModelIndex()) - 1
+ for row in rows:
+ sel.select(m.index(row, 0), m.index(row, max_col))
+ sm.select(sel, sm.ClearAndSelect)
def close(self):
self._model.close()
diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py
index 24ba7ef47c..d736835fd6 100644
--- a/src/calibre/gui2/main.py
+++ b/src/calibre/gui2/main.py
@@ -233,8 +233,7 @@ class GuiRunner(QObject):
def show_splash_screen(self):
self.splash_pixmap = QPixmap()
self.splash_pixmap.load(I('library.png'))
- self.splash_screen = QSplashScreen(self.splash_pixmap,
- Qt.SplashScreen)
+ self.splash_screen = QSplashScreen(self.splash_pixmap)
self.splash_screen.showMessage(_('Starting %s: Loading books...') %
__appname__)
self.splash_screen.show()
diff --git a/src/calibre/gui2/metadata.py b/src/calibre/gui2/metadata.py
index c71f82c654..b11e2ad28a 100644
--- a/src/calibre/gui2/metadata.py
+++ b/src/calibre/gui2/metadata.py
@@ -9,51 +9,59 @@ __docformat__ = 'restructuredtext en'
import traceback
from threading import Thread
from Queue import Queue, Empty
+from functools import partial
+from PyQt4.Qt import QObject, Qt, pyqtSignal, QTimer, QDialog, \
+ QVBoxLayout, QTextBrowser, QLabel, QGroupBox, QDialogButtonBox
from calibre.ebooks.metadata.fetch import search, get_social_metadata
-from calibre.gui2 import config
+from calibre.gui2 import config, error_dialog
+from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.ebooks.metadata.covers import download_cover
from calibre.customize.ui import get_isbndb_key
-from calibre import prints
-from calibre.constants import DEBUG
class Worker(Thread):
+ 'Cover downloader'
def __init__(self):
Thread.__init__(self)
- self.setDaemon(True)
+ self.daemon = True
self.jobs = Queue()
self.results = Queue()
def run(self):
while True:
- mi = self.jobs.get()
+ id, mi = self.jobs.get()
if not getattr(mi, 'isbn', False):
break
try:
cdata, errors = download_cover(mi)
if cdata:
- self.results.put((mi.isbn, cdata))
- elif DEBUG:
- prints('Cover download failed:', errors)
+ self.results.put((id, mi, True, cdata))
+ else:
+ msg = []
+ for e in errors:
+ if not e[0]:
+ msg.append(e[-1] + ' - ' + e[1])
+ self.results.put((id, mi, False, '\n'.join(msg)))
except:
- traceback.print_exc()
+ self.results.put((id, mi, False, traceback.format_exc()))
def __enter__(self):
self.start()
return self
def __exit__(self, *args):
- self.jobs.put(False)
+ self.jobs.put((False, False))
class DownloadMetadata(Thread):
+ 'Metadata downloader'
def __init__(self, db, ids, get_covers, set_metadata=True,
get_social_metadata=True):
Thread.__init__(self)
- self.setDaemon(True)
+ self.daemon = True
self.metadata = {}
self.covers = {}
self.set_metadata = set_metadata
@@ -63,34 +71,42 @@ class DownloadMetadata(Thread):
self.updated = set([])
self.get_covers = get_covers
self.worker = Worker()
+ self.results = Queue()
+ self.keep_going = True
for id in ids:
self.metadata[id] = db.get_metadata(id, index_is_id=True)
self.metadata[id].rating = None
+ self.total = len(ids)
+ if self.get_covers:
+ self.total += len(ids)
+ self.fetched_metadata = {}
+ self.fetched_covers = {}
+ self.failures = {}
+ self.cover_failures = {}
+ self.exception = self.tb = None
def run(self):
- self.exception = self.tb = None
try:
self._run()
except Exception, e:
self.exception = e
- import traceback
self.tb = traceback.format_exc()
def _run(self):
self.key = get_isbndb_key()
if not self.key:
self.key = None
- self.fetched_metadata = {}
- self.failures = {}
with self.worker:
for id, mi in self.metadata.items():
+ if not self.keep_going:
+ break
args = {}
if mi.isbn:
args['isbn'] = mi.isbn
else:
- if not mi.title or mi.title == _('Unknown'):
+ if mi.is_null('title'):
self.failures[id] = \
- (str(id), _('Book has neither title nor ISBN'))
+ _('Book has neither title nor ISBN')
continue
args['title'] = mi.title
if mi.authors and mi.authors[0] != _('Unknown'):
@@ -101,8 +117,11 @@ class DownloadMetadata(Thread):
if results:
fmi = results[0]
self.fetched_metadata[id] = fmi
- if fmi.isbn and self.get_covers:
- self.worker.jobs.put(fmi)
+ if self.get_covers:
+ if fmi.isbn:
+ self.worker.jobs.put((id, fmi))
+ else:
+ self.results.put((id, 'cover', False, mi.title))
if (not config['overwrite_author_title_metadata']):
fmi.authors = mi.authors
fmi.author_sort = mi.author_sort
@@ -114,42 +133,163 @@ class DownloadMetadata(Thread):
mi.rating *= 2
if not self.get_social_metadata:
mi.tags = []
+ self.results.put((id, 'metadata', True, mi.title))
else:
- self.failures[id] = (mi.title,
- _('No matches found for this book'))
+ self.failures[id] = _('No matches found for this book')
+ self.results.put((id, 'metadata', False, mi.title))
+ self.results.put((id, 'cover', False, mi.title))
self.commit_covers()
self.commit_covers(True)
- for id in self.fetched_metadata:
- mi = self.metadata[id]
- if self.set_metadata:
- self.db.set_metadata(id, mi)
- if not self.set_metadata and self.get_social_metadata:
- if mi.rating:
- self.db.set_rating(id, mi.rating)
- if mi.tags:
- self.db.set_tags(id, mi.tags)
- if mi.comments:
- self.db.set_comment(id, mi.comments)
- if mi.series:
- self.db.set_series(id, mi.series)
- if mi.series_index is not None:
- self.db.set_series_index(id, mi.series_index)
-
- self.updated = set(self.fetched_metadata)
-
def commit_covers(self, all=False):
if all:
self.worker.jobs.put(False)
while True:
try:
- isbn, cdata = self.worker.results.get(False)
- for id, mi in self.metadata.items():
- if mi.isbn == isbn:
- self.db.set_cover(id, cdata)
+ id, fmi, ok, cdata = self.worker.results.get(False)
+ if ok:
+ self.fetched_covers[id] = cdata
+ self.results.put((id, 'cover', ok, fmi.title))
+ else:
+ self.results.put((id, 'cover', ok, fmi.title))
+ try:
+ self.cover_failures[id] = unicode(cdata)
+ except:
+ self.cover_failures[id] = repr(cdata)
except Empty:
if not all or not self.worker.is_alive():
return
+class DoDownload(QObject):
+
+ idle_process = pyqtSignal()
+
+ def __init__(self, parent, title, db, ids, get_covers, set_metadata=True,
+ get_social_metadata=True):
+ QObject.__init__(self, parent)
+ self.pd = ProgressDialog(title, min=0, max=0, parent=parent)
+ self.pd.canceled_signal.connect(self.cancel)
+ self.idle_process.connect(self.do_one, type=Qt.QueuedConnection)
+ self.downloader = None
+ self.create = partial(DownloadMetadata, db, ids, get_covers,
+ set_metadata=set_metadata,
+ get_social_metadata=get_social_metadata)
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.do_one, type=Qt.QueuedConnection)
+ self.db = db
+ self.updated = set([])
+ self.total = len(ids)
+
+ def exec_(self):
+ self.timer.start(50)
+ ret = self.pd.exec_()
+ if getattr(self.downloader, 'exception', None) is not None and \
+ ret == self.pd.Accepted:
+ error_dialog(self.parent(), _('Failed'),
+ _('Failed to download metadata'), show=True)
+ else:
+ self.show_report()
+ return ret
+
+ def cancel(self, *args):
+ self.timer.stop()
+ self.downloader.keep_going = False
+ self.pd.reject()
+
+ def do_one(self):
+ if self.downloader is None:
+ self.downloader = self.create()
+ self.downloader.start()
+ self.pd.set_min(0)
+ self.pd.set_max(self.downloader.total)
+ try:
+ r = self.downloader.results.get_nowait()
+ self.handle_result(r)
+ except Empty:
+ pass
+ if not self.downloader.is_alive():
+ self.timer.stop()
+ self.pd.accept()
+
+ def handle_result(self, r):
+ id_, typ, ok, title = r
+ what = _('cover') if typ == 'cover' else _('metadata')
+ which = _('Downloaded') if ok else _('Failed to get')
+ self.pd.set_msg(_('%s %s for: %s') % (which, what, title))
+ self.pd.value += 1
+ if ok:
+ self.updated.add(id_)
+ if typ == 'cover':
+ try:
+ self.db.set_cover(id_,
+ self.downloader.fetched_covers.pop(id_))
+ except:
+ self.downloader.cover_failures[id_] = \
+ traceback.format_exc()
+ else:
+ try:
+ self.set_metadata(id_)
+ except:
+ self.downloader.failures[id_] = \
+ traceback.format_exc()
+
+ def set_metadata(self, id_):
+ mi = self.downloader.metadata[id_]
+ if self.downloader.set_metadata:
+ self.db.set_metadata(id_, mi)
+ if not self.downloader.set_metadata and self.downloader.get_social_metadata:
+ if mi.rating:
+ self.db.set_rating(id_, mi.rating)
+ if mi.tags:
+ self.db.set_tags(id_, mi.tags)
+ if mi.comments:
+ self.db.set_comment(id_, mi.comments)
+ if mi.series:
+ self.db.set_series(id_, mi.series)
+ if mi.series_index is not None:
+ self.db.set_series_index(id_, mi.series_index)
+
+ def show_report(self):
+ f, cf = self.downloader.failures, self.downloader.cover_failures
+ report = []
+ if f:
+ report.append(
+ '
Failed to download metadata for the following:
')
+ for id_, err in f.items():
+ mi = self.downloader.metadata[id_]
+ report.append('- %s
%s
' % (mi.title,
+ unicode(err)))
+ report.append('
')
+ if cf:
+ report.append(
+ 'Failed to download cover for the following:
')
+ for id_, err in cf.items():
+ mi = self.downloader.metadata[id_]
+ report.append('- %s
%s
' % (mi.title,
+ unicode(err)))
+ report.append('
')
+
+ if len(self.updated) != self.total or report:
+ d = QDialog(self.parent())
+ bb = QDialogButtonBox(QDialogButtonBox.Ok, parent=d)
+ v1 = QVBoxLayout()
+ d.setLayout(v1)
+ d.setWindowTitle(_('Done'))
+ v1.addWidget(QLabel(_('Successfully downloaded metadata for %d out of %d books') %
+ (len(self.updated), self.total)))
+ gb = QGroupBox(_('Details'), self.parent())
+ v2 = QVBoxLayout()
+ gb.setLayout(v2)
+ b = QTextBrowser(self.parent())
+ v2.addWidget(b)
+ b.setHtml('\n'.join(report))
+ v1.addWidget(gb)
+ v1.addWidget(bb)
+ bb.accepted.connect(d.accept)
+ d.resize(800, 600)
+ d.exec_()
+
+
+
diff --git a/src/calibre/gui2/preferences/columns.py b/src/calibre/gui2/preferences/columns.py
index c1b9230f42..03a50e6f3a 100644
--- a/src/calibre/gui2/preferences/columns.py
+++ b/src/calibre/gui2/preferences/columns.py
@@ -21,7 +21,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
def genesis(self, gui):
self.gui = gui
db = self.gui.library_view.model().db
- self.custcols = copy.deepcopy(db.field_metadata.get_custom_field_metadata())
+ self.custcols = copy.deepcopy(db.field_metadata.custom_field_metadata())
self.column_up.clicked.connect(self.up_column)
self.column_down.clicked.connect(self.down_column)
diff --git a/src/calibre/gui2/preferences/create_custom_column.py b/src/calibre/gui2/preferences/create_custom_column.py
index e8ab8707e2..ebf33784d4 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},
+ 9:{'datatype':'composite',
+ 'text':_('Column built from other columns'), 'is_multiple':False},
}
def __init__(self, parent, editing, standard_colheads, standard_colnames):
@@ -80,12 +82,15 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
ct = c['datatype'] if not c['is_multiple'] else '*text'
self.orig_column_number = c['colnum']
self.orig_column_name = col
- column_numbers = dict(map(lambda x:(self.column_types[x]['datatype'], x), self.column_types))
+ column_numbers = dict(map(lambda x:(self.column_types[x]['datatype'], x),
+ self.column_types))
self.column_type_box.setCurrentIndex(column_numbers[ct])
self.column_type_box.setEnabled(False)
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 +99,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):
@@ -104,9 +110,11 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
if not col:
return self.simple_error('', _('No lookup name was provided'))
if re.match('^\w*$', col) is None or not col[0].isalpha() or col.lower() != col:
- return self.simple_error('', _('The lookup name must contain only lower case letters, digits and underscores, and start with a letter'))
+ return self.simple_error('', _('The lookup name must contain only '
+ 'lower case letters, digits and underscores, and start with a letter'))
if col.endswith('_index'):
- return self.simple_error('', _('Lookup names cannot end with _index, because these names are reserved for the index of a series column.'))
+ return self.simple_error('', _('Lookup names cannot end with _index, '
+ 'because these names are reserved for the index of a series column.'))
col_heading = unicode(self.column_heading_box.text())
col_type = self.column_types[self.column_type_box.currentIndex()]['datatype']
if col_type == '*text':
@@ -118,14 +126,17 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
return self.simple_error('', _('No column heading was provided'))
bad_col = False
if col in self.parent.custcols:
- if not self.editing_col or self.parent.custcols[col]['colnum'] != self.orig_column_number:
+ if not self.editing_col or \
+ self.parent.custcols[col]['colnum'] != self.orig_column_number:
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:
- if not self.editing_col or self.parent.custcols[t]['colnum'] != self.orig_column_number:
+ if not self.editing_col or \
+ self.parent.custcols[t]['colnum'] != self.orig_column_number:
bad_head = True
for t in self.standard_colheads:
if self.standard_colheads[t] == col_heading:
@@ -133,12 +144,18 @@ 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 columns'))
+ display_dict = {'composite_template':unicode(self.composite_box.text())}
db = self.parent.gui.library_view.model().db
key = db.field_metadata.custom_field_prefix+col
@@ -148,8 +165,7 @@ class CreateCustomColumn(QDialog, Ui_QCreateCustomColumn):
'label':col,
'name':col_heading,
'datatype':col_type,
- 'editable':True,
- 'display':date_format,
+ 'display':display_dict,
'normalized':None,
'colnum':None,
'is_multiple':is_multiple,
@@ -164,7 +180,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/gui2/preferences/misc.py b/src/calibre/gui2/preferences/misc.py
index eae79fdfc0..582d110c6c 100644
--- a/src/calibre/gui2/preferences/misc.py
+++ b/src/calibre/gui2/preferences/misc.py
@@ -88,18 +88,31 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
r('enforce_cpu_limit', config, restart_required=True)
self.device_detection_button.clicked.connect(self.debug_device_detection)
self.compact_button.clicked.connect(self.compact)
+ self.button_all_books_dirty.clicked.connect(self.mark_dirty)
self.button_open_config_dir.clicked.connect(self.open_config_dir)
self.button_osx_symlinks.clicked.connect(self.create_symlinks)
self.button_osx_symlinks.setVisible(isosx)
+ def mark_dirty(self):
+ db = self.gui.library_view.model().db
+ db.dirtied(list(db.data.iterallids()))
+ info_dialog(self, _('Backup metadata'),
+ _('Metadata will be backed up while calibre is running, at the '
+ 'rate of 30 books per minute.'), show=True)
+
def debug_device_detection(self, *args):
from calibre.gui2.preferences.device_debug import DebugDevice
d = DebugDevice(self)
d.exec_()
def compact(self, *args):
- d = CheckIntegrity(self.gui.library_view.model().db, self)
- d.exec_()
+ m = self.gui.library_view.model()
+ m.stop_metadata_backup()
+ try:
+ d = CheckIntegrity(m.db, self)
+ d.exec_()
+ finally:
+ m.start_metadata_backup()
def open_config_dir(self, *args):
from calibre.utils.config import config_dir
diff --git a/src/calibre/gui2/preferences/misc.ui b/src/calibre/gui2/preferences/misc.ui
index f8582a3675..adf2a15c16 100644
--- a/src/calibre/gui2/preferences/misc.ui
+++ b/src/calibre/gui2/preferences/misc.ui
@@ -124,7 +124,14 @@
- -
+
-
+
+
+ Back up metadata of all books
+
+
+
+ -
Qt::Vertical
@@ -132,7 +139,7 @@
20
- 18
+ 1000
diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py
new file mode 100644
index 0000000000..59ef4cb246
--- /dev/null
+++ b/src/calibre/gui2/preferences/plugboard.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+from PyQt4 import QtGui
+from PyQt4.Qt import Qt
+
+from calibre.gui2 import error_dialog
+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
+from calibre.library.save_to_disk import plugboard_any_format_value, \
+ plugboard_any_device_value, plugboard_save_to_disk_value
+from calibre.utils.formatter import validation_formatter
+
+class ConfigWidget(ConfigWidgetBase, Ui_Form):
+
+ def genesis(self, gui):
+ self.gui = gui
+ self.db = gui.library_view.model().db
+ self.current_plugboards = self.db.prefs.get('plugboards',{})
+ self.current_device = None
+ self.current_format = None
+
+ def initialize(self):
+ def field_cmp(x, y):
+ if x.startswith('#'):
+ if y.startswith('#'):
+ return cmp(x.lower(), y.lower())
+ else:
+ return 1
+ elif y.startswith('#'):
+ return -1
+ else:
+ return cmp(x.lower(), y.lower())
+
+ ConfigWidgetBase.initialize(self)
+
+ self.devices = ['']
+ for device in device_plugins():
+ n = device.__class__.__name__
+ if n.startswith('FOLDER_DEVICE'):
+ n = 'FOLDER_DEVICE'
+ 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)
+ self.new_device.addItems(self.devices)
+
+ self.formats = ['']
+ for w in metadata_writers():
+ for f in w.file_types:
+ self.formats.append(f)
+ self.formats.sort()
+ self.formats.insert(1, plugboard_any_format_value)
+ self.new_format.addItems(self.formats)
+
+ self.dest_fields = ['',
+ 'authors', 'author_sort', 'language', 'publisher',
+ 'tags', 'title', 'title_sort']
+
+ self.source_widgets = []
+ self.dest_widgets = []
+ for i in range(0, len(self.dest_fields)-1):
+ w = QtGui.QLineEdit(self)
+ self.source_widgets.append(w)
+ self.fields_layout.addWidget(w, 5+i, 0, 1, 1)
+ w = QtGui.QComboBox(self)
+ self.dest_widgets.append(w)
+ self.fields_layout.addWidget(w, 5+i, 1, 1, 1)
+
+ self.edit_device.currentIndexChanged[str].connect(self.edit_device_changed)
+ self.edit_format.currentIndexChanged[str].connect(self.edit_format_changed)
+ self.new_device.currentIndexChanged[str].connect(self.new_device_changed)
+ self.new_format.currentIndexChanged[str].connect(self.new_format_changed)
+ self.existing_plugboards.itemClicked.connect(self.existing_pb_clicked)
+ self.ok_button.clicked.connect(self.ok_clicked)
+ self.del_button.clicked.connect(self.del_clicked)
+
+ self.refilling = False
+ self.refill_all_boxes()
+
+ def clear_fields(self, edit_boxes=False, new_boxes=False):
+ self.ok_button.setEnabled(False)
+ self.del_button.setEnabled(False)
+ for w in self.source_widgets:
+ w.clear()
+ for w in self.dest_widgets:
+ w.clear()
+ if edit_boxes:
+ self.edit_device.setCurrentIndex(0)
+ self.edit_format.setCurrentIndex(0)
+ if new_boxes:
+ self.new_device.setCurrentIndex(0)
+ self.new_format.setCurrentIndex(0)
+
+ def set_fields(self):
+ self.ok_button.setEnabled(True)
+ self.del_button.setEnabled(True)
+ for w in self.source_widgets:
+ w.clear()
+ for w in self.dest_widgets:
+ w.addItems(self.dest_fields)
+
+ def set_field(self, i, src, dst):
+ self.source_widgets[i].setText(src)
+ idx = self.dest_fields.index(dst)
+ self.dest_widgets[i].setCurrentIndex(idx)
+
+ def edit_device_changed(self, txt):
+ self.current_device = None
+ if txt == '':
+ self.clear_fields(new_boxes=False)
+ return
+ self.clear_fields(new_boxes=True)
+ self.current_device = unicode(txt)
+ fpb = self.current_plugboards.get(self.current_format, None)
+ if fpb is None:
+ print 'edit_device_changed: none format!'
+ return
+ dpb = fpb.get(self.current_device, None)
+ if dpb is None:
+ print 'edit_device_changed: none device!'
+ return
+ self.set_fields()
+ for i,op in enumerate(dpb):
+ self.set_field(i, op[0], op[1])
+ self.ok_button.setEnabled(True)
+ self.del_button.setEnabled(True)
+
+ def edit_format_changed(self, txt):
+ self.edit_device.setCurrentIndex(0)
+ self.current_device = None
+ self.current_format = None
+ if txt == '':
+ self.clear_fields(new_boxes=False)
+ return
+ self.clear_fields(new_boxes=True)
+ txt = unicode(txt)
+ fpb = self.current_plugboards.get(txt, None)
+ if fpb is None:
+ print 'edit_format_changed: none editable format!'
+ return
+ self.current_format = txt
+ devices = ['']
+ for d in fpb:
+ devices.append(d)
+ self.edit_device.clear()
+ self.edit_device.addItems(devices)
+
+ def new_device_changed(self, txt):
+ self.current_device = None
+ if txt == '':
+ self.clear_fields(edit_boxes=False)
+ return
+ self.clear_fields(edit_boxes=True)
+ self.current_device = unicode(txt)
+ error = False
+ if self.current_format == plugboard_any_format_value:
+ # user specified any format.
+ for f in self.current_plugboards:
+ devs = set(self.current_plugboards[f])
+ if self.current_device != plugboard_save_to_disk_value and \
+ plugboard_any_device_value in devs:
+ # specific format/any device in list. conflict.
+ # note: any device does not match save_to_disk
+ error = True
+ break
+ if self.current_device in devs:
+ # specific format/current device in list. conflict
+ error = True
+ break
+ if self.current_device == plugboard_any_device_value:
+ # any device and a specific device already there. conflict
+ error = True
+ break
+ else:
+ # user specified specific format.
+ for f in self.current_plugboards:
+ devs = set(self.current_plugboards[f])
+ if f == plugboard_any_format_value and \
+ self.current_device in devs:
+ # any format/same device in list. conflict.
+ error = True
+ break
+ if f == self.current_format and self.current_device in devs:
+ # current format/current device in list. conflict
+ error = True
+ break
+ if f == self.current_format and plugboard_any_device_value in devs:
+ # current format/any device in list. conflict
+ error = True
+ break
+
+ if error:
+ error_dialog(self, '',
+ _('That format and device already has a plugboard or '
+ 'conflicts with another plugboard.'),
+ show=True)
+ self.new_device.setCurrentIndex(0)
+ return
+ self.set_fields()
+
+ def new_format_changed(self, txt):
+ self.current_format = None
+ self.current_device = None
+ self.new_device.setCurrentIndex(0)
+ if txt:
+ self.clear_fields(edit_boxes=True)
+ self.current_format = unicode(txt)
+ else:
+ self.clear_fields(edit_boxes=False)
+
+ def ok_clicked(self):
+ pb = []
+ for i in range(0, len(self.source_widgets)):
+ s = unicode(self.source_widgets[i].text())
+ if s:
+ d = self.dest_widgets[i].currentIndex()
+ if d != 0:
+ try:
+ validation_formatter.validate(s)
+ except Exception, err:
+ error_dialog(self, _('Invalid template'),
+ '
'+_('The template %s is invalid:')%s + \
+ '
'+str(err), show=True)
+ return
+ pb.append((s, self.dest_fields[d]))
+ else:
+ error_dialog(self, _('Invalid destination'),
+ '
'+_('The destination field cannot be blank'),
+ show=True)
+ return
+ if len(pb) == 0:
+ if self.current_format in self.current_plugboards:
+ fpb = self.current_plugboards[self.current_format]
+ if self.current_device in fpb:
+ del fpb[self.current_device]
+ if len(fpb) == 0:
+ del self.current_plugboards[self.current_format]
+ else:
+ if self.current_format not in self.current_plugboards:
+ self.current_plugboards[self.current_format] = {}
+ fpb = self.current_plugboards[self.current_format]
+ fpb[self.current_device] = pb
+ self.changed_signal.emit()
+ self.refill_all_boxes()
+
+ def del_clicked(self):
+ if self.current_format in self.current_plugboards:
+ fpb = self.current_plugboards[self.current_format]
+ if self.current_device in fpb:
+ del fpb[self.current_device]
+ if len(fpb) == 0:
+ del self.current_plugboards[self.current_format]
+ self.changed_signal.emit()
+ self.refill_all_boxes()
+
+ def existing_pb_clicked(self, Qitem):
+ item = Qitem.data(Qt.UserRole).toPyObject()
+ self.edit_format.setCurrentIndex(self.edit_format.findText(item[0]))
+ self.edit_device.setCurrentIndex(self.edit_device.findText(item[1]))
+
+ def refill_all_boxes(self):
+ if self.refilling:
+ return
+ self.refilling = True
+ self.current_device = None
+ self.current_format = None
+ self.clear_fields(new_boxes=True)
+ self.edit_format.clear()
+ self.edit_format.addItem('')
+ for format in self.current_plugboards:
+ self.edit_format.addItem(format)
+ self.edit_format.setCurrentIndex(0)
+ self.edit_device.clear()
+ self.ok_button.setEnabled(False)
+ self.del_button.setEnabled(False)
+ self.existing_plugboards.clear()
+ for f in self.formats:
+ if f not in self.current_plugboards:
+ continue
+ for d in self.devices:
+ if d not in self.current_plugboards[f]:
+ continue
+ ops = []
+ for op in self.current_plugboards[f][d]:
+ ops.append('([' + op[0] + '] -> ' + op[1] + ')')
+ txt = '%s:%s = %s\n'%(f, d, ', '.join(ops))
+ item = QtGui.QListWidgetItem(txt)
+ item.setData(Qt.UserRole, (f, d))
+ self.existing_plugboards.addItem(item)
+ self.refilling = False
+
+ def restore_defaults(self):
+ ConfigWidgetBase.restore_defaults(self)
+ self.current_plugboards = {}
+ self.refill_all_boxes()
+ self.changed_signal.emit()
+
+ def commit(self):
+ self.db.prefs.set('plugboards', self.current_plugboards)
+ return ConfigWidgetBase.commit(self)
+
+ def refresh_gui(self, gui):
+ pass
+
+
+if __name__ == '__main__':
+ from PyQt4.Qt import QApplication
+ app = QApplication([])
+ test_widget('Import/Export', 'plugboards')
+
diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui
new file mode 100644
index 0000000000..289518816f
--- /dev/null
+++ b/src/calibre/gui2/preferences/plugboard.ui
@@ -0,0 +1,224 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 707
+ 340
+
+
+
+ Form
+
+
+ -
+
+
+ Here you can change the metadata calibre uses to update a book when saving to disk or sending to device.
+
+Use this dialog to define a 'plugboard' for a format (or all formats) and a device (or all devices). The plugboard spefies what template is connected to what field. The template is used to compute a value, and that value is assigned to the connected field.
+
+Often templates will contain simple references to composite columns, but this is not necessary. You can use any template in a source box that you can use elsewhere in calibre.
+
+One possible use for a plugboard is to alter the title to contain series informaton. Another would be to change the author sort, something that mobi users might do to force it to use the ';' that the kindle requires. A third would be to specify the language.
+
+
+ Qt::PlainText
+
+
+ true
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
-
+
+
+ Format (choose first)
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ Device (choose second)
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ Add new plugboard
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ Edit existing plugboard
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ Existing plugboards
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Source template
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ Destination field
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Save plugboard
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Delete plugboard
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/calibre/gui2/preferences/plugins.py b/src/calibre/gui2/preferences/plugins.py
index a26553db1c..388227e438 100644
--- a/src/calibre/gui2/preferences/plugins.py
+++ b/src/calibre/gui2/preferences/plugins.py
@@ -199,7 +199,11 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
config_dialog.exec_()
if config_dialog.result() == QDialog.Accepted:
- plugin.save_settings(config_widget)
+ if hasattr(config_widget, 'validate'):
+ if config_widget.validate():
+ plugin.save_settings(config_widget)
+ else:
+ plugin.save_settings(config_widget)
self._plugin_model.refresh_plugin(plugin)
else:
help_text = plugin.customization_help(gui=True)
diff --git a/src/calibre/gui2/preferences/save_template.py b/src/calibre/gui2/preferences/save_template.py
index d325ac42ff..a7f57536d5 100644
--- a/src/calibre/gui2/preferences/save_template.py
+++ b/src/calibre/gui2/preferences/save_template.py
@@ -10,8 +10,9 @@ from PyQt4.Qt import QWidget, pyqtSignal
from calibre.gui2 import error_dialog
from calibre.gui2.preferences.save_template_ui import Ui_Form
-from calibre.library.save_to_disk import FORMAT_ARG_DESCS, \
- preprocess_template
+from calibre.library.save_to_disk import FORMAT_ARG_DESCS, preprocess_template
+from calibre.utils.formatter import validation_formatter
+
class SaveTemplate(QWidget, Ui_Form):
@@ -26,8 +27,11 @@ class SaveTemplate(QWidget, Ui_Form):
variables = sorted(FORMAT_ARG_DESCS.keys())
rows = []
for var in variables:
- rows.append(u'
%s | %s |
'%
+ rows.append(u'%s | | %s |
'%
(var, FORMAT_ARG_DESCS[var]))
+ rows.append(u'%s | | %s |
'%(
+ _('Any custom field'),
+ _('The lookup name of any custom field. These names begin with "#")')))
table = u''%(u'\n'.join(rows))
self.template_variables.setText(table)
@@ -41,12 +45,14 @@ class SaveTemplate(QWidget, Ui_Form):
self.changed_signal.emit()
def validate(self):
+ '''
+ Do a syntax check on the format string. Doing a semantic check
+ (verifying that the fields exist) is not useful in the presence of
+ custom fields, because they may or may not exist.
+ '''
tmpl = preprocess_template(self.opt_template.text())
- fa = {}
- for x in FORMAT_ARG_DESCS.keys():
- fa[x]='random long string'
try:
- tmpl.format(**fa)
+ validation_formatter.validate(tmpl)
except Exception, err:
error_dialog(self, _('Invalid template'),
''+_('The template %s is invalid:')%tmpl + \
diff --git a/src/calibre/gui2/preferences/sending.py b/src/calibre/gui2/preferences/sending.py
index 748c6b2a2d..ac4abbcf41 100644
--- a/src/calibre/gui2/preferences/sending.py
+++ b/src/calibre/gui2/preferences/sending.py
@@ -22,6 +22,9 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
r = self.register
+ for x in ('send_timefmt',):
+ r(x, self.proxy)
+
choices = [(_('Manual management'), 'manual'),
(_('Only on send'), 'on_send'),
(_('Automatic management'), 'on_connect')]
diff --git a/src/calibre/gui2/preferences/sending.ui b/src/calibre/gui2/preferences/sending.ui
index e064646afd..75b1899a3a 100644
--- a/src/calibre/gui2/preferences/sending.ui
+++ b/src/calibre/gui2/preferences/sending.ui
@@ -80,7 +80,20 @@
- -
+
-
+
+
+ Format &dates as:
+
+
+ opt_send_timefmt
+
+
+
+ -
+
+
+ -
Here you can control how calibre will save your books when you click the Send to Device button. This setting can be overriden for individual devices by customizing the device interface plugins in Preferences->Advanced->Plugins
@@ -90,7 +103,7 @@
- -
+
-
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 6c50a71b92..68b7645d36 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -505,7 +505,12 @@ class TagsModel(QAbstractItemModel): # {{{
key = item.parent.category_key
# make certain we know about the item's category
if key not in self.db.field_metadata:
- return
+ return False
+ if key == 'authors':
+ if val.find('&') >= 0:
+ error_dialog(self.tags_view, _('Invalid author name'),
+ _('Author names cannot contain & characters.')).exec_()
+ return False
if key == 'search':
if val in saved_searches().names():
error_dialog(self.tags_view, _('Duplicate search name'),
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index f8d50d1cd2..937b23b113 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -19,7 +19,7 @@ from PyQt4.Qt import Qt, SIGNAL, QTimer, \
QMessageBox, QHelpEvent
from calibre import prints
-from calibre.constants import __appname__, isosx
+from calibre.constants import __appname__, isosx, DEBUG
from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.config import prefs, dynamic
from calibre.utils.ipc.server import Server
@@ -360,6 +360,10 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
def library_moved(self, newloc):
if newloc is None: return
+ try:
+ olddb = self.library_view.model().db
+ except:
+ olddb = None
db = LibraryDatabase2(newloc)
self.library_path = newloc
self.book_on_device(None, reset=True)
@@ -380,6 +384,12 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
self.apply_named_search_restriction('') # reset restriction to null
self.saved_searches_changed() # reload the search restrictions combo box
self.apply_named_search_restriction(db.prefs['gui_restriction'])
+ if olddb is not None:
+ try:
+ olddb.conn.close()
+ except:
+ import traceback
+ traceback.print_exc()
def set_window_title(self):
self.setWindowTitle(__appname__ + u' - ||%s||'%self.iactions['Choose Library'].library_name())
@@ -533,6 +543,10 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
# Save the current field_metadata for applications like calibre2opds
# Goes here, because if cf is valid, db is valid.
db.prefs['field_metadata'] = db.field_metadata.all_metadata()
+ db.commit_dirty_cache()
+ if DEBUG and db.gm_count > 0:
+ print 'get_metadata cache: {0:d} calls, {1:4.2f}% misses'.format(
+ db.gm_count, (db.gm_missed*100.0)/db.gm_count)
for action in self.iactions.values():
if not action.shutting_down():
return
@@ -548,6 +562,10 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
cc = self.library_view.model().cover_cache
if cc is not None:
cc.stop()
+ mb = self.library_view.model().metadata_backup
+ if mb is not None:
+ mb.stop()
+
self.hide_windows()
self.emailer.stop()
try:
@@ -558,9 +576,11 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
s.exit()
except:
pass
- time.sleep(2)
except KeyboardInterrupt:
pass
+ time.sleep(2)
+ if mb is not None:
+ mb.flush()
self.hide_windows()
return True
diff --git a/src/calibre/gui2/wizard/__init__.py b/src/calibre/gui2/wizard/__init__.py
index ef58ec3a90..37b7c7bd7c 100644
--- a/src/calibre/gui2/wizard/__init__.py
+++ b/src/calibre/gui2/wizard/__init__.py
@@ -73,6 +73,14 @@ class JetBook(Device):
manufacturer = 'Ectaco'
id = 'jetbook'
+class JetBookMini(Device):
+
+ output_profile = 'jetbook5'
+ output_format = 'FB2'
+ name = 'JetBook Mini'
+ manufacturer = 'Ectaco'
+ id = 'jetbookmini'
+
class KindleDX(Kindle):
output_profile = 'kindle_dx'
diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py
index 58edd89cb2..c22f9e00b0 100644
--- a/src/calibre/library/caches.py
+++ b/src/calibre/library/caches.py
@@ -6,7 +6,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import re, itertools, time
+import re, itertools, time, traceback
from itertools import repeat
from datetime import timedelta
from threading import Thread, RLock
@@ -19,9 +19,106 @@ from calibre.utils.date import parse_date, now, UNDEFINED_DATE
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.utils.pyparsing import ParseException
from calibre.ebooks.metadata import title_sort
-from calibre import fit_image
+from calibre.ebooks.metadata.opf2 import metadata_to_opf
+from calibre import fit_image, prints
-class CoverCache(Thread):
+class MetadataBackup(Thread): # {{{
+ '''
+ Continuously backup changed metadata into OPF files
+ in the book directory. This class runs in its own
+ thread and makes sure that the actual file write happens in the
+ GUI thread to prevent Windows' file locking from causing problems.
+ '''
+
+ def __init__(self, db):
+ Thread.__init__(self)
+ self.daemon = True
+ self.db = db
+ self.keep_running = True
+ from calibre.gui2 import FunctionDispatcher
+ self.do_write = FunctionDispatcher(self.write)
+ self.get_metadata_for_dump = FunctionDispatcher(db.get_metadata_for_dump)
+ self.clear_dirtied = FunctionDispatcher(db.clear_dirtied)
+ self.set_dirtied = FunctionDispatcher(db.dirtied)
+ self.in_limbo = None
+
+ def stop(self):
+ self.keep_running = False
+
+ def run(self):
+ while self.keep_running:
+ self.in_limbo = None
+ try:
+ time.sleep(0.5) # Limit to two per second
+ id_ = self.db.dirtied_queue.get(True, 1.45)
+ except Empty:
+ continue
+ except:
+ # Happens during interpreter shutdown
+ break
+
+ try:
+ path, mi = self.get_metadata_for_dump(id_)
+ except:
+ prints('Failed to get backup metadata for id:', id_, 'once')
+ traceback.print_exc()
+ time.sleep(2)
+ try:
+ path, mi = self.get_metadata_for_dump(id_)
+ except:
+ prints('Failed to get backup metadata for id:', id_, 'again, giving up')
+ traceback.print_exc()
+ continue
+
+ # at this point the dirty indication is off
+
+ if mi is None:
+ continue
+ self.in_limbo = id_
+
+ # Give the GUI thread a chance to do something. Python threads don't
+ # have priorities, so this thread would naturally keep the processor
+ # until some scheduling event happens. The sleep makes such an event
+ time.sleep(0.1)
+ try:
+ raw = metadata_to_opf(mi)
+ except:
+ self.set_dirtied([id_])
+ prints('Failed to convert to opf for id:', id_)
+ traceback.print_exc()
+ continue
+
+ time.sleep(0.1) # Give the GUI thread a chance to do something
+ try:
+ self.do_write(path, raw)
+ except:
+ prints('Failed to write backup metadata for id:', id_, 'once')
+ time.sleep(2)
+ try:
+ self.do_write(path, raw)
+ except:
+ self.set_dirtied([id_])
+ prints('Failed to write backup metadata for id:', id_,
+ 'again, giving up')
+ continue
+ self.in_limbo = None
+
+ def flush(self):
+ 'Used during shutdown to ensure that a dirtied book is not missed'
+ if self.in_limbo is not None:
+ try:
+ self.db.dirtied([self.in_limbo])
+ except:
+ traceback.print_exc()
+
+ def write(self, path, raw):
+ with lopen(path, 'wb') as f:
+ f.write(raw)
+
+
+# }}}
+
+class CoverCache(Thread): # {{{
def __init__(self, db, cover_func):
Thread.__init__(self)
@@ -38,7 +135,6 @@ class CoverCache(Thread):
self.keep_running = False
def _image_for_id(self, id_):
- time.sleep(0.050) # Limit 20/second to not overwhelm the GUI
img = self.cover_func(id_, index_is_id=True, as_image=True)
if img is None:
img = QImage()
@@ -54,17 +150,46 @@ class CoverCache(Thread):
def run(self):
while self.keep_running:
try:
- id_ = self.load_queue.get(True, 1)
+ # The GUI puts the same ID into the queue many times. The code
+ # below emptys the queue, building a set of unique values. When
+ # the queue is empty, do the work
+ ids = set()
+ id_ = self.load_queue.get(True, 2)
+ ids.add(id_)
+ try:
+ while True:
+ # Give the gui some time to put values into the queue
+ id_ = self.load_queue.get(True, 0.5)
+ ids.add(id_)
+ except Empty:
+ pass
+ except:
+ # Happens during shutdown
+ break
except Empty:
continue
- try:
- img = self._image_for_id(id_)
except:
- import traceback
- traceback.print_exc()
- continue
- with self.lock:
- self.cache[id_] = img
+ #Happens during interpreter shutdown
+ break
+ if not self.keep_running:
+ break
+ for id_ in ids:
+ time.sleep(0.050) # Limit 20/second to not overwhelm the GUI
+ try:
+ img = self._image_for_id(id_)
+ except:
+ try:
+ traceback.print_exc()
+ except:
+ # happens during shutdown
+ break
+ continue
+ try:
+ with self.lock:
+ self.cache[id_] = img
+ except:
+ # Happens during interpreter shutdown
+ break
def set_cache(self, ids):
with self.lock:
@@ -90,6 +215,7 @@ class CoverCache(Thread):
for id_ in ids:
self.cache.pop(id_, None)
self.load_queue.put(id_)
+# }}}
### Global utility function for get_match here and in gui2/library.py
CONTAINS_MATCH = 0
@@ -107,7 +233,7 @@ def _match(query, value, matchkind):
pass
return False
-class ResultCache(SearchQueryParser):
+class ResultCache(SearchQueryParser): # {{{
'''
Stores sorted and filtered metadata in memory.
@@ -123,6 +249,11 @@ class ResultCache(SearchQueryParser):
self.build_date_relop_dict()
self.build_numeric_relop_dict()
+ self.composites = []
+ for key in field_metadata:
+ if field_metadata[key]['datatype'] == 'composite':
+ self.composites.append((key, field_metadata[key]['rec_index']))
+
def __getitem__(self, row):
return self._data[self._map_filtered[row]]
@@ -328,7 +459,7 @@ class ResultCache(SearchQueryParser):
if query and query.strip():
# get metadata key associated with the search term. Eliminates
# dealing with plurals and other aliases
- location = self.field_metadata.search_term_to_key(location.lower().strip())
+ location = self.field_metadata.search_term_to_field_key(location.lower().strip())
if isinstance(location, list):
if allow_recursion:
for loc in location:
@@ -374,7 +505,7 @@ class ResultCache(SearchQueryParser):
if len(self.field_metadata[x]['search_terms']):
db_col[x] = self.field_metadata[x]['rec_index']
if self.field_metadata[x]['datatype'] not in \
- ['text', 'comments', 'series']:
+ ['composite', 'text', 'comments', 'series']:
exclude_fields.append(db_col[x])
col_datatype[db_col[x]] = self.field_metadata[x]['datatype']
is_multiple_cols[db_col[x]] = self.field_metadata[x]['is_multiple']
@@ -506,6 +637,7 @@ class ResultCache(SearchQueryParser):
def set(self, row, col, val, row_is_id=False):
id = row if row_is_id else self._map_filtered[row]
+ self._data[id][self.FIELD_MAP['all_metadata']] = None
self._data[id][col] = val
def get(self, row, col, row_is_id=False):
@@ -536,6 +668,11 @@ class ResultCache(SearchQueryParser):
self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0]
self._data[id].append(db.has_cover(id, index_is_id=True))
self._data[id].append(db.book_on_device_string(id))
+ self._data[id].append(None)
+ if len(self.composites) > 0:
+ mi = db.get_metadata(id, index_is_id=True)
+ for k,c in self.composites:
+ self._data[id][c] = mi.get(k, None)
except IndexError:
return None
try:
@@ -552,6 +689,11 @@ class ResultCache(SearchQueryParser):
self._data[id] = db.conn.get('SELECT * from meta2 WHERE id=?', (id,))[0]
self._data[id].append(db.has_cover(id, index_is_id=True))
self._data[id].append(db.book_on_device_string(id))
+ self._data[id].append(None)
+ if len(self.composites) > 0:
+ mi = db.get_metadata(id, index_is_id=True)
+ for k,c in self.composites:
+ self._data[id][c] = mi.get(k)
self._map[0:0] = ids
self._map_filtered[0:0] = ids
@@ -577,6 +719,12 @@ class ResultCache(SearchQueryParser):
if item is not None:
item.append(db.has_cover(item[0], index_is_id=True))
item.append(db.book_on_device_string(item[0]))
+ item.append(None)
+ if len(self.composites) > 0:
+ mi = db.get_metadata(item[0], index_is_id=True)
+ for k,c in self.composites:
+ item[c] = mi.get(k)
+
self._map = [i[0] for i in self._data if i is not None]
if field is not None:
self.sort(field, ascending)
@@ -587,12 +735,9 @@ class ResultCache(SearchQueryParser):
# Sorting functions {{{
def sanitize_sort_field_name(self, field):
- field = field.lower().strip()
- if field not in self.field_metadata.iterkeys():
- if field in ('author', 'tag', 'comment'):
- field += 's'
- if field == 'date': field = 'timestamp'
- elif field == 'title': field = 'sort'
+ field = self.field_metadata.search_term_to_field_key(field.lower().strip())
+ # translate some fields to their hidden equivalent
+ if field == 'title': field = 'sort'
elif field == 'authors': field = 'author_sort'
return field
@@ -601,7 +746,7 @@ class ResultCache(SearchQueryParser):
def multisort(self, fields=[], subsort=False):
fields = [(self.sanitize_sort_field_name(x), bool(y)) for x, y in fields]
- keys = self.field_metadata.field_keys()
+ keys = self.field_metadata.sortable_field_keys()
fields = [x for x in fields if x[0] in keys]
if subsort and 'sort' not in [x[0] for x in fields]:
fields += [('sort', True)]
@@ -667,7 +812,7 @@ class SortKeyGenerator(object):
sidx = record[sidx_fm['rec_index']]
val = (val, sidx)
- elif dt in ('text', 'comments'):
+ elif dt in ('text', 'comments', 'composite'):
if val is None:
val = ''
val = val.lower()
@@ -675,4 +820,5 @@ class SortKeyGenerator(object):
# }}}
+# }}}
diff --git a/src/calibre/library/cli.py b/src/calibre/library/cli.py
index 9a2d0b0a62..19bd56bf55 100644
--- a/src/calibre/library/cli.py
+++ b/src/calibre/library/cli.py
@@ -10,7 +10,8 @@ Command line interface to the calibre database.
import sys, os, cStringIO, re
from textwrap import TextWrapper
-from calibre import terminal_controller, preferred_encoding, prints
+from calibre import terminal_controller, preferred_encoding, prints, \
+ isbytestring
from calibre.utils.config import OptionParser, prefs, tweaks
from calibre.ebooks.metadata.meta import get_metadata
from calibre.library.database2 import LibraryDatabase2
@@ -32,8 +33,9 @@ def send_message(msg=''):
t.conn.send('refreshdb:'+msg)
t.conn.close()
-
-
+def write_dirtied(db):
+ prints('Backing up metadata')
+ db.dump_metadata()
def get_parser(usage):
parser = OptionParser(usage)
@@ -259,6 +261,7 @@ def do_add(db, paths, one_book_per_directory, recurse, add_duplicates):
print >>sys.stderr, '\t', title+':'
print >>sys.stderr, '\t\t ', path
+ write_dirtied(db)
send_message()
finally:
sys.stdout = orig
@@ -299,6 +302,7 @@ def do_add_empty(db, title, authors, isbn):
if isbn:
mi.isbn = isbn
db.import_book(mi, [])
+ write_dirtied()
send_message()
def command_add(args, dbpath):
@@ -448,10 +452,11 @@ def command_show_metadata(args, dbpath):
return 0
def do_set_metadata(db, id, stream):
- mi = OPF(stream)
+ mi = OPF(stream).to_book_metadata()
db.set_metadata(id, mi)
db.clean()
do_show_metadata(db, id, False)
+ write_dirtied()
send_message()
def set_metadata_option_parser():
@@ -873,8 +878,60 @@ def command_saved_searches(args, dbpath):
COMMANDS = ('list', 'add', 'remove', 'add_format', 'remove_format',
'show_metadata', 'set_metadata', 'export', 'catalog',
'saved_searches', 'add_custom_column', 'custom_columns',
- 'remove_custom_column', 'set_custom')
+ 'remove_custom_column', 'set_custom', 'restore_database')
+def restore_database_option_parser():
+ parser = get_parser(_(
+ '''
+ %prog restore_database [options]
+
+ Restore this database from the metadata stored in OPF
+ files in each directory of the calibre library. This is
+ useful if your metadata.db file has been corrupted.
+
+ WARNING: This completely regenrates your datbase. 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()
+ opts, args = parser.parse_args(args)
+ if len(args) != 0:
+ parser.print_help()
+ return 1
+
+ if opts.library_path is not None:
+ dbpath = opts.library_path
+
+ if isbytestring(dbpath):
+ dbpath = dbpath.decode(preferred_encoding)
+
+ class Progress(object):
+ def __init__(self): self.total = 1
+
+ def __call__(self, msg, step):
+ if msg is None:
+ self.total = float(step)
+ else:
+ prints(msg, '...', '%d%%'%int(100*(step/self.total)))
+ r = Restore(dbpath, progress_callback=Progress())
+ r.start()
+ r.join()
+
+ if r.tb is not None:
+ prints('Restoring database failed with error:')
+ prints(r.tb)
+ else:
+ prints('Restoring database succeeded')
+ prints('old database saved as', r.olddb)
+ if r.errors_occurred:
+ name = 'calibre_db_restore_report.txt'
+ open('calibre_db_restore_report.txt',
+ 'wb').write(r.report.encode('utf-8'))
+ prints('Some errors occurred. A detailed report was '
+ 'saved to', name)
def option_parser():
parser = OptionParser(_(
diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py
index 4ba664dadc..fdd78e89f8 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
@@ -214,6 +214,7 @@ class CustomColumns(object):
'SELECT id FROM %s WHERE value=?'%table, (new_name,), all=False)
if new_id is None or old_id == new_id:
self.conn.execute('UPDATE %s SET value=? WHERE id=?'%table, (new_name, old_id))
+ new_id = old_id
else:
# New id exists. If the column is_multiple, then process like
# tags, otherwise process like publishers (see database2)
@@ -226,6 +227,7 @@ class CustomColumns(object):
self.conn.execute('''UPDATE %s SET value=?
WHERE value=?'''%lt, (new_id, old_id,))
self.conn.execute('DELETE FROM %s WHERE id=?'%table, (old_id,))
+ self.dirty_books_referencing('#'+data['label'], new_id, commit=False)
self.conn.commit()
def delete_custom_item_using_id(self, id, label=None, num=None):
@@ -382,6 +384,7 @@ class CustomColumns(object):
)
# get rid of the temp tables
self.conn.executescript(drops)
+ self.dirtied(ids, commit=False)
self.conn.commit()
# set the in-memory copies of the tags
@@ -402,19 +405,21 @@ class CustomColumns(object):
same length as ids.
'''
if extras is not None and len(extras) != len(ids):
- raise ValueError('Lentgh of ids and extras is not the same')
+ raise ValueError('Length of ids and extras is not the same')
ev = None
for idx,id in enumerate(ids):
if extras is not None:
ev = extras[idx]
self._set_custom(id, val, label=label, num=num, append=append,
notify=notify, extra=ev)
+ self.dirtied(ids, commit=False)
self.conn.commit()
def set_custom(self, id, val, label=None, num=None,
append=False, notify=True, extra=None, commit=True):
self._set_custom(id, val, label=label, num=num, append=append,
notify=notify, extra=extra)
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
@@ -424,6 +429,8 @@ class CustomColumns(object):
data = self.custom_column_label_map[label]
if num is not None:
data = self.custom_column_num_map[num]
+ if data['datatype'] == 'composite':
+ return None
if not data['editable']:
raise ValueError('Column %r is not editable'%data['label'])
table, lt = self.custom_table_names(data['num'])
@@ -540,7 +547,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 +558,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/database.py b/src/calibre/library/database.py
index 28a0de2153..c4f6908002 100644
--- a/src/calibre/library/database.py
+++ b/src/calibre/library/database.py
@@ -1333,7 +1333,7 @@ ALTER TABLE books ADD COLUMN isbn TEXT DEFAULT "" COLLATE NOCASE;
id = obj.lastrowid
self.conn.commit()
self.set_metadata(id, mi)
- stream = path if hasattr(path, 'read') else open(path, 'rb')
+ stream = path if hasattr(path, 'read') else lopen(path, 'rb')
stream.seek(0, 2)
usize = stream.tell()
stream.seek(0)
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 6a3ea9ba48..4de8c3d552 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -9,10 +9,12 @@ The database used to store ebook metadata
import os, sys, shutil, cStringIO, glob, time, functools, traceback, re
from itertools import repeat
from math import floor
+from Queue import Queue
from PyQt4.QtGui import QImage
from calibre.ebooks.metadata import title_sort, author_to_author_sort
+from calibre.ebooks.metadata.opf2 import metadata_to_opf
from calibre.library.database import LibraryDatabase
from calibre.library.field_metadata import FieldMetadata, TagsIcons
from calibre.library.schema_upgrades import SchemaUpgrade
@@ -20,8 +22,8 @@ from calibre.library.caches import ResultCache
from calibre.library.custom_columns import CustomColumns
from calibre.library.sqlite import connect, IntegrityError, DBThread
from calibre.library.prefs import DBPrefs
-from calibre.ebooks.metadata import string_to_authors, authors_to_string, \
- MetaInformation
+from calibre.ebooks.metadata import string_to_authors, authors_to_string
+from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.metadata.meta import get_metadata, metadata_from_formats
from calibre.constants import preferred_encoding, iswindows, isosx, filesystem_encoding
from calibre.ptempfile import PersistentTemporaryFile
@@ -45,13 +47,21 @@ def delete_file(path):
def delete_tree(path, permanent=False):
if permanent:
- shutil.rmtree(path)
+ try:
+ # For completely mysterious reasons, sometimes a file is left open
+ # leading to access errors. If we get an exception, wait and hope
+ # that whatever has the file (the O/S?) lets go of it.
+ shutil.rmtree(path)
+ except:
+ traceback.print_exc()
+ time.sleep(1)
+ shutil.rmtree(path)
else:
try:
if not permanent:
winshell.delete_file(path, silent=True, no_confirm=True)
except:
- shutil.rmtree(path)
+ delete_tree(path, permanent=True)
copyfile = os.link if hasattr(os, 'link') else shutil.copyfile
@@ -126,6 +136,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def __init__(self, library_path, row_factory=False):
self.field_metadata = FieldMetadata()
+ self.dirtied_queue = Queue()
if not os.path.exists(library_path):
os.makedirs(library_path)
self.listeners = set([])
@@ -282,6 +293,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.field_metadata.set_field_record_index('cover', base+1, prefer_custom=False)
self.FIELD_MAP['ondevice'] = base+2
self.field_metadata.set_field_record_index('ondevice', base+2, prefer_custom=False)
+ self.FIELD_MAP['all_metadata'] = base+3
+ self.field_metadata.set_field_record_index('all_metadata', base+3, prefer_custom=False)
script = '''
DROP VIEW IF EXISTS meta2;
@@ -323,11 +336,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.has_id = self.data.has_id
self.count = self.data.count
- self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
-
- self.refresh()
- self.last_update_check = self.last_modified()
-
+ # Count times get_metadata is called, and how many times in the cache
+ self.gm_count = 0
+ self.gm_missed = 0
for prop in ('author_sort', 'authors', 'comment', 'comments', 'isbn',
'publisher', 'rating', 'series', 'series_index', 'tags',
@@ -337,6 +348,16 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
setattr(self, 'title_sort', functools.partial(self.get_property,
loc=self.FIELD_MAP['sort']))
+ d = self.conn.get('SELECT book FROM metadata_dirtied', all=True)
+ for x in d:
+ self.dirtied_queue.put(x[0])
+ self.dirtied_cache = set([x[0] for x in d])
+
+ self.refresh_ondevice = functools.partial(self.data.refresh_ondevice, self)
+ self.refresh()
+ self.last_update_check = self.last_modified()
+
+
def initialize_database(self):
metadata_sqlite = open(P('metadata_sqlite.sql'), 'rb').read()
self.conn.executescript(metadata_sqlite)
@@ -432,7 +453,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if current_path and os.path.exists(spath): # Migrate existing files
cdata = self.cover(id, index_is_id=True)
if cdata is not None:
- with open(os.path.join(tpath, 'cover.jpg'), 'wb') as f:
+ with lopen(os.path.join(tpath, 'cover.jpg'), 'wb') as f:
f.write(cdata)
for format in formats:
# Get data as string (can't use file as source and target files may be the same)
@@ -443,6 +464,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.add_format(id, format, stream, index_is_id=True,
path=tpath, notify=False)
self.conn.execute('UPDATE books SET path=? WHERE id=?', (path, id))
+ self.dirtied([id], commit=False)
self.conn.commit()
self.data.set(id, self.FIELD_MAP['path'], path, row_is_id=True)
# Delete not needed directories
@@ -460,15 +482,16 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
# change case don't cause any changes to the directories in the file
# system. This can lead to having the directory names not match the
# title/author, which leads to trouble when libraries are copied to
- # a case-sensitive system. The following code fixes this by checking
- # each segment. If they are different because of case, then rename
- # the segment to some temp file name, then rename it back to the
- # correct name. Note that the code above correctly handles files in
- # the directories, so no need to do them here.
+ # a case-sensitive system. The following code attempts to fix this
+ # by checking each segment. If they are different because of case,
+ # then rename the segment to some temp file name, then rename it
+ # back to the correct name. Note that the code above correctly
+ # handles files in the directories, so no need to do them here.
for oldseg, newseg in zip(c1, c2):
if oldseg.lower() == newseg.lower() and oldseg != newseg:
try:
- os.rename(os.path.join(curpath, oldseg), os.path.join(curpath, newseg))
+ os.rename(os.path.join(curpath, oldseg),
+ os.path.join(curpath, newseg))
except:
break # Fail silently since nothing catastrophic has happened
curpath = os.path.join(curpath, newseg)
@@ -503,10 +526,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if as_path:
return path
try:
- f = open(path, 'rb')
+ f = lopen(path, 'rb')
except (IOError, OSError):
time.sleep(0.2)
- f = open(path, 'rb')
+ f = lopen(path, 'rb')
if as_image:
img = QImage()
img.loadFromData(f.read())
@@ -517,25 +540,178 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
f.close()
return ans
+ ### The field-style interface. These use field keys.
+
+ def get_field(self, idx, key, default=None, index_is_id=False):
+ mi = self.get_metadata(idx, index_is_id=index_is_id,
+ get_cover=key == 'cover')
+ return mi.get(key, default)
+
+ def standard_field_keys(self):
+ return self.field_metadata.standard_field_keys()
+
+ def custom_field_keys(self, include_composites=True):
+ return self.field_metadata.custom_field_keys(include_composites)
+
+ def all_field_keys(self):
+ return self.field_metadata.all_field_keys()
+
+ def sortable_field_keys(self):
+ return self.field_metadata.sortable_field_keys()
+
+ def searchable_fields(self):
+ return self.field_metadata.searchable_field_keys()
+
+ def search_term_to_field_key(self, term):
+ return self.field_metadata.search_term_to_key(term)
+
+ def custom_field_metadata(self, include_composites=True):
+ return self.field_metadata.custom_field_metadata(include_composites)
+
+ def all_metadata(self):
+ return self.field_metadata.all_metadata()
+
+ def metadata_for_field(self, key):
+ return self.field_metadata[key]
+
+ def clear_dirtied(self, book_ids):
+ '''
+ Clear the dirtied indicator for the books. This is used when fetching
+ metadata, creating an OPF, and writing a file are separated into steps.
+ The last step is clearing the indicator
+ '''
+ for book_id in book_ids:
+ self.conn.execute('DELETE FROM metadata_dirtied WHERE book=?',
+ (book_id,))
+ # if a later exception prevents the commit, then the dirtied
+ # table will still have the book. No big deal, because the OPF
+ # is there and correct. We will simply do it again on next
+ # start
+ self.dirtied_cache.discard(book_id)
+ self.conn.commit()
+
+ def dump_metadata(self, book_ids=None, remove_from_dirtied=True,
+ commit=True):
+ '''
+ Write metadata for each record to an individual OPF file
+ '''
+ if book_ids is None:
+ book_ids = [x[0] for x in self.conn.get(
+ 'SELECT book FROM metadata_dirtied', all=True)]
+ for book_id in book_ids:
+ if not self.data.has_id(book_id):
+ continue
+ path, mi = self.get_metadata_for_dump(book_id,
+ remove_from_dirtied=remove_from_dirtied)
+ if path is None:
+ continue
+ try:
+ raw = metadata_to_opf(mi)
+ with lopen(path, 'wb') as f:
+ f.write(raw)
+ except:
+ # Something went wrong. Put the book back on the dirty list
+ self.dirtied([book_id])
+ if commit:
+ self.conn.commit()
+
+ def dirtied(self, book_ids, commit=True):
+ for book in frozenset(book_ids) - self.dirtied_cache:
+ try:
+ self.conn.execute(
+ 'INSERT INTO metadata_dirtied (book) VALUES (?)',
+ (book,))
+ self.dirtied_queue.put(book)
+ except IntegrityError:
+ # Already in table
+ pass
+ # If the commit doesn't happen, then our cache will be wrong. This
+ # could lead to a problem because we won't put the book back into
+ # the dirtied table. We deal with this by writing the dirty cache
+ # back to the table on GUI exit. Not perfect, but probably OK
+ self.dirtied_cache.add(book)
+ if commit:
+ self.conn.commit()
+
+ def dirty_queue_length(self):
+ return len(self.dirtied_cache)
+
+ def commit_dirty_cache(self):
+ '''
+ Set the dirty indication for every book in the cache. The vast majority
+ of the time, the indication will already be set. However, sometimes
+ exceptions may have prevented a commit, which may remove some dirty
+ indications from the DB. This call will put them back. Note that there
+ is no problem with setting a dirty indication for a book that isn't in
+ fact dirty. Just wastes a few cycles.
+ '''
+ book_ids = list(self.dirtied_cache)
+ self.dirtied_cache = set()
+ self.dirtied(book_ids)
+
+ def get_metadata_for_dump(self, idx, remove_from_dirtied=True):
+ try:
+ path = os.path.join(self.abspath(idx, index_is_id=True), 'metadata.opf')
+ mi = self.get_metadata(idx, index_is_id=True)
+ # Always set cover to cover.jpg. Even if cover doesn't exist,
+ # no harm done. This way no need to call dirtied when
+ # cover is set/removed
+ mi.cover = 'cover.jpg'
+ except:
+ # This almost certainly means that the book has been deleted while
+ # the backup operation sat in the queue.
+ path, mi = (None, None)
+
+ try:
+ # clear the dirtied indicator. The user must put it back if
+ # something goes wrong with writing the OPF
+ if remove_from_dirtied:
+ self.clear_dirtied([idx])
+ except:
+ # No real problem. We will just do it again.
+ pass
+ return (path, mi)
+
def get_metadata(self, idx, index_is_id=False, get_cover=False):
'''
- Convenience method to return metadata as a L{MetaInformation} object.
+ Convenience method to return metadata as a :class:`Metadata` object.
+ Note that the list of formats is not verified.
'''
- aum = self.authors(idx, index_is_id=index_is_id)
- if aum: aum = [a.strip().replace('|', ',') for a in aum.split(',')]
- mi = MetaInformation(self.title(idx, index_is_id=index_is_id), aum)
+ self.gm_count += 1
+ mi = self.data.get(idx, self.FIELD_MAP['all_metadata'],
+ row_is_id = index_is_id)
+ if mi is not None:
+ if get_cover and mi.cover is None:
+ mi.cover = self.cover(idx, index_is_id=index_is_id, as_path=True)
+ return mi
+
+ self.gm_missed += 1
+ mi = Metadata(None)
+ self.data.set(idx, self.FIELD_MAP['all_metadata'], mi,
+ row_is_id = index_is_id)
+
+ aut_list = self.authors_with_sort_strings(idx, index_is_id=index_is_id)
+ aum = []
+ aus = {}
+ for (author, author_sort) in aut_list:
+ aum.append(author)
+ aus[author] = author_sort
+ mi.title = self.title(idx, index_is_id=index_is_id)
+ mi.authors = aum
mi.author_sort = self.author_sort(idx, index_is_id=index_is_id)
- if mi.authors:
- mi.author_sort_map = {}
- for name, sort in zip(mi.authors, self.authors_sort_strings(idx,
- index_is_id)):
- mi.author_sort_map[name] = sort
+ mi.author_sort_map = aus
mi.comments = self.comments(idx, index_is_id=index_is_id)
mi.publisher = self.publisher(idx, index_is_id=index_is_id)
mi.timestamp = self.timestamp(idx, index_is_id=index_is_id)
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,
+ verify_formats=False)
+ if hasattr(mi.formats, 'split'):
+ mi.formats = mi.formats.split(',')
+ else:
+ mi.formats = None
tags = self.tags(idx, index_is_id=index_is_id)
if tags:
mi.tags = [i.strip() for i in tags.split(',')]
@@ -546,6 +722,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
mi.isbn = self.isbn(idx, index_is_id=index_is_id)
id = idx if index_is_id else self.id(idx)
mi.application_id = id
+ mi.id = id
+ for key,meta in self.field_metadata.iteritems():
+ if meta['is_custom']:
+ mi.set_user_metadata(key, meta)
+ mi.set(key, val=self.get_custom(idx, label=meta['label'],
+ index_is_id=index_is_id),
+ extra=self.get_custom_extra(idx, label=meta['label'],
+ index_is_id=index_is_id))
if get_cover:
mi.cover = self.cover(id, index_is_id=True, as_path=True)
return mi
@@ -666,7 +850,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
return set([])
return set([f[0] for f in formats])
- def formats(self, index, index_is_id=False):
+ def formats(self, index, index_is_id=False, verify_formats=True):
''' Return available formats as a comma separated list or None if there are no available formats '''
id = index if index_is_id else self.id(index)
try:
@@ -674,6 +858,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
formats = map(lambda x:x[0], formats)
except:
return None
+ if not verify_formats:
+ return ','.join(formats)
ans = []
for format in formats:
if self.format_abspath(id, format, index_is_id=True) is not None:
@@ -720,7 +906,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'''
path = self.format_abspath(index, format, index_is_id=index_is_id)
if path is not None:
- f = open(path, mode)
+ f = lopen(path, mode)
try:
ret = f if as_file else f.read()
except IOError:
@@ -736,7 +922,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
path=None, notify=True):
npath = self.run_import_plugins(fpath, format)
format = os.path.splitext(npath)[-1].lower().replace('.', '').upper()
- stream = open(npath, 'rb')
+ stream = lopen(npath, 'rb')
format = check_ebook_format(stream, format)
return self.add_format(index, format, stream,
index_is_id=index_is_id, path=path, notify=notify)
@@ -757,7 +943,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
pdir = os.path.dirname(dest)
if not os.path.exists(pdir):
os.makedirs(pdir)
- with open(dest, 'wb') as f:
+ with lopen(dest, 'wb') as f:
shutil.copyfileobj(stream, f)
stream.seek(0, 2)
size=stream.tell()
@@ -1054,7 +1240,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def set_metadata(self, id, mi, ignore_errors=False):
'''
- Set metadata for the book `id` from the `MetaInformation` object `mi`
+ Set metadata for the book `id` from the `Metadata` object `mi`
'''
def doit(func, *args, **kwargs):
try:
@@ -1065,38 +1251,51 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
raise
if mi.title:
- self.set_title(id, 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)
+ self.set_authors(id, authors, notify=False, commit=False)
if mi.author_sort:
- doit(self.set_author_sort, id, mi.author_sort, notify=False)
+ doit(self.set_author_sort, id, mi.author_sort, notify=False,
+ commit=False)
if mi.publisher:
- doit(self.set_publisher, id, mi.publisher, notify=False)
+ doit(self.set_publisher, id, mi.publisher, notify=False,
+ commit=False)
if mi.rating:
- doit(self.set_rating, id, mi.rating, notify=False)
+ doit(self.set_rating, id, mi.rating, notify=False, commit=False)
if mi.series:
- doit(self.set_series, id, mi.series, notify=False)
+ doit(self.set_series, id, mi.series, notify=False, commit=False)
if mi.cover_data[1] is not None:
doit(self.set_cover, id, mi.cover_data[1]) # doesn't use commit
elif mi.cover is not None and os.access(mi.cover, os.R_OK):
- doit(self.set_cover, id, open(mi.cover, 'rb'))
+ doit(self.set_cover, id, lopen(mi.cover, 'rb'))
if mi.tags:
- doit(self.set_tags, id, mi.tags, notify=False)
+ doit(self.set_tags, id, mi.tags, notify=False, commit=False)
if mi.comments:
- doit(self.set_comment, id, mi.comments, notify=False)
+ doit(self.set_comment, id, mi.comments, notify=False, commit=False)
if mi.isbn and mi.isbn.strip():
- doit(self.set_isbn, id, mi.isbn, notify=False)
+ doit(self.set_isbn, id, mi.isbn, notify=False, commit=False)
if mi.series_index:
- doit(self.set_series_index, id, mi.series_index, notify=False)
+ doit(self.set_series_index, id, mi.series_index, notify=False,
+ commit=False)
if mi.pubdate:
- doit(self.set_pubdate, id, mi.pubdate, notify=False)
+ doit(self.set_pubdate, id, mi.pubdate, notify=False, commit=False)
if getattr(mi, 'timestamp', None) is not None:
- doit(self.set_timestamp, id, mi.timestamp, notify=False)
- self.set_path(id, True)
+ doit(self.set_timestamp, id, mi.timestamp, notify=False,
+ commit=False)
+
+ user_mi = mi.get_all_user_metadata(make_copy=False)
+ for key in user_mi.iterkeys():
+ if key in self.field_metadata and \
+ user_mi[key]['datatype'] == self.field_metadata[key]['datatype']:
+ doit(self.set_custom, id,
+ val=mi.get(key),
+ extra=mi.get_extra(key),
+ label=user_mi[key]['label'], commit=False)
+ self.conn.commit()
self.notify('metadata', [id])
def authors_sort_strings(self, id, index_is_id=False):
@@ -1115,6 +1314,19 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
result.append(sort)
return result
+ # Given a book, return the map of author sort strings for the book's authors
+ def authors_with_sort_strings(self, id, index_is_id=False):
+ id = id if index_is_id else self.id(id)
+ aut_strings = self.conn.get('''
+ SELECT authors.name, authors.sort
+ FROM authors, books_authors_link as bl
+ WHERE bl.book=? and authors.id=bl.author
+ ORDER BY bl.id''', (id,))
+ result = []
+ for (author, sort,) in aut_strings:
+ result.append((author.replace('|', ','), sort))
+ return result
+
# Given a book, return the author_sort string for authors of the book
def author_sort_from_book(self, id, index_is_id=False):
auts = self.authors_sort_strings(id, index_is_id)
@@ -1165,6 +1377,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
ss = self.author_sort_from_book(id, index_is_id=True)
self.conn.execute('UPDATE books SET author_sort=? WHERE id=?',
(ss, id))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['authors'],
@@ -1191,6 +1404,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
self.data.set(id, self.FIELD_MAP['sort'], title, row_is_id=True)
self.set_path(id, index_is_id=True)
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
if notify:
@@ -1200,6 +1414,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if dt:
self.conn.execute('UPDATE books SET timestamp=? WHERE id=?', (dt, id))
self.data.set(id, self.FIELD_MAP['timestamp'], dt, row_is_id=True)
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
if notify:
@@ -1209,6 +1424,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if dt:
self.conn.execute('UPDATE books SET pubdate=? WHERE id=?', (dt, id))
self.data.set(id, self.FIELD_MAP['pubdate'], dt, row_is_id=True)
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
if notify:
@@ -1227,6 +1443,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
aid = self.conn.execute('INSERT INTO publishers(name) VALUES (?)', (publisher,)).lastrowid
self.conn.execute('INSERT INTO books_publishers_link(book, publisher) VALUES (?,?)', (id, aid))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['publisher'], publisher, row_is_id=True)
@@ -1236,6 +1453,19 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
# Convenience methods for tags_list_editor
# Note: we generally do not need to refresh_ids because library_view will
# refresh everything.
+
+ def dirty_books_referencing(self, field, id, commit=True):
+ # Get the list of books to dirty -- all books that reference the item
+ table = self.field_metadata[field]['table']
+ link = self.field_metadata[field]['link_column']
+ bks = self.conn.get(
+ 'SELECT book from books_{0}_link WHERE {1}=?'.format(table, link),
+ (id,))
+ books = []
+ for (book_id,) in bks:
+ books.append(book_id)
+ self.dirtied(books, commit=commit)
+
def get_tags_with_ids(self):
result = self.conn.get('SELECT id,name FROM tags')
if not result:
@@ -1252,6 +1482,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
# there is a change of case
self.conn.execute('''UPDATE tags SET name=?
WHERE id=?''', (new_name, old_id))
+ new_id = old_id
else:
# It is possible that by renaming a tag, the tag will appear
# twice on a book. This will throw an integrity error, aborting
@@ -1269,9 +1500,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE tag=?''',(new_id, old_id,))
# Get rid of the no-longer used publisher
self.conn.execute('DELETE FROM tags WHERE id=?', (old_id,))
+ self.dirty_books_referencing('tags', new_id, commit=False)
self.conn.commit()
def delete_tag_using_id(self, id):
+ self.dirty_books_referencing('tags', id, commit=False)
self.conn.execute('DELETE FROM books_tags_link WHERE tag=?', (id,))
self.conn.execute('DELETE FROM tags WHERE id=?', (id,))
self.conn.commit()
@@ -1288,6 +1521,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'''SELECT id from series
WHERE name=?''', (new_name,), all=False)
if new_id is None or old_id == new_id:
+ new_id = old_id
self.conn.execute('UPDATE series SET name=? WHERE id=?',
(new_name, old_id))
else:
@@ -1311,15 +1545,17 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
SET series_index=?
WHERE id=?''',(index, book_id,))
index = index + 1
+ self.dirty_books_referencing('series', new_id, commit=False)
self.conn.commit()
def delete_series_using_id(self, id):
+ self.dirty_books_referencing('series', id, commit=False)
books = self.conn.get('SELECT book from books_series_link WHERE series=?', (id,))
self.conn.execute('DELETE FROM books_series_link WHERE series=?', (id,))
self.conn.execute('DELETE FROM series WHERE id=?', (id,))
- self.conn.commit()
for (book_id,) in books:
self.conn.execute('UPDATE books SET series_index=1.0 WHERE id=?', (book_id,))
+ self.conn.commit()
def get_publishers_with_ids(self):
result = self.conn.get('SELECT id,name FROM publishers')
@@ -1333,6 +1569,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'''SELECT id from publishers
WHERE name=?''', (new_name,), all=False)
if new_id is None or old_id == new_id:
+ new_id = old_id
# New name doesn't exist. Simply change the old name
self.conn.execute('UPDATE publishers SET name=? WHERE id=?', \
(new_name, old_id))
@@ -1343,9 +1580,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE publisher=?''',(new_id, old_id,))
# Get rid of the no-longer used publisher
self.conn.execute('DELETE FROM publishers WHERE id=?', (old_id,))
+ self.dirty_books_referencing('publisher', new_id, commit=False)
self.conn.commit()
def delete_publisher_using_id(self, old_id):
+ self.dirty_books_referencing('publisher', id, commit=False)
self.conn.execute('''DELETE FROM books_publishers_link
WHERE publisher=?''', (old_id,))
self.conn.execute('DELETE FROM publishers WHERE id=?', (old_id,))
@@ -1426,6 +1665,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
# Now delete the old author from the DB
bks = self.conn.get('SELECT book FROM books_authors_link WHERE author=?', (old_id,))
self.conn.execute('DELETE FROM authors WHERE id=?', (old_id,))
+ self.dirtied(books, commit=False)
self.conn.commit()
# the authors are now changed, either by changing the author's name
# or replacing the author in the list. Now must fix up the books.
@@ -1517,6 +1757,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
'''.format(tables[0], tables[1])
)
self.conn.executescript(drops)
+ self.dirtied(ids, commit=False)
self.conn.commit()
for x in ids:
@@ -1562,6 +1803,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
(id, tid), all=False):
self.conn.execute('INSERT INTO books_tags_link(book, tag) VALUES (?,?)',
(id, tid))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
tags = u','.join(self.get_tags(id))
@@ -1616,6 +1858,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
aid = self.conn.execute('INSERT INTO series(name) VALUES (?)', (series,)).lastrowid
self.conn.execute('INSERT INTO books_series_link(book, series) VALUES (?,?)', (id, aid))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['series'], series, row_is_id=True)
@@ -1630,6 +1873,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
except:
idx = 1.0
self.conn.execute('UPDATE books SET series_index=? WHERE id=?', (idx, id))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['series_index'], idx, row_is_id=True)
@@ -1642,6 +1886,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
rat = self.conn.get('SELECT id FROM ratings WHERE rating=?', (rating,), all=False)
rat = rat if rat else self.conn.execute('INSERT INTO ratings(rating) VALUES (?)', (rating,)).lastrowid
self.conn.execute('INSERT INTO books_ratings_link(book, rating) VALUES (?,?)', (id, rat))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['rating'], rating, row_is_id=True)
@@ -1654,11 +1899,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['comments'], text, row_is_id=True)
+ self.dirtied([id], commit=False)
if notify:
self.notify('metadata', [id])
def set_author_sort(self, id, sort, notify=True, commit=True):
self.conn.execute('UPDATE books SET author_sort=? WHERE id=?', (sort, id))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['author_sort'], sort, row_is_id=True)
@@ -1667,6 +1914,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def set_isbn(self, id, isbn, notify=True, commit=True):
self.conn.execute('UPDATE books SET isbn=? WHERE id=?', (isbn, id))
+ self.dirtied([id], commit=False)
if commit:
self.conn.commit()
self.data.set(id, self.FIELD_MAP['isbn'], isbn, row_is_id=True)
@@ -1675,7 +1923,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def add_catalog(self, path, title):
format = os.path.splitext(path)[1][1:].lower()
- with open(path, 'rb') as stream:
+ with lopen(path, 'rb') as stream:
matches = self.data.get_matches('title', '='+title)
if matches:
tag_matches = self.data.get_matches('tags', '='+_('Catalog'))
@@ -1693,7 +1941,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
try:
mi = get_metadata(stream, format)
except:
- mi = MetaInformation(title, ['calibre'])
+ mi = Metadata(title, ['calibre'])
stream.seek(0)
mi.title, mi.authors = title, ['calibre']
mi.tags = [_('Catalog')]
@@ -1710,7 +1958,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def add_news(self, path, arg):
format = os.path.splitext(path)[1][1:].lower()
- stream = path if hasattr(path, 'read') else open(path, 'rb')
+ stream = path if hasattr(path, 'read') else lopen(path, 'rb')
stream.seek(0)
mi = get_metadata(stream, format, use_libprs_metadata=False)
stream.seek(0)
@@ -1761,7 +2009,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
else:
mi.tags.append(tag)
- def create_book_entry(self, mi, cover=None, add_duplicates=True):
+ def create_book_entry(self, mi, cover=None, add_duplicates=True,
+ force_id=None):
self._add_newbook_tag(mi)
if not add_duplicates and self.has_book(mi):
return None
@@ -1772,9 +2021,17 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
aus = aus.decode(preferred_encoding, 'replace')
if isbytestring(title):
title = title.decode(preferred_encoding, 'replace')
- obj = self.conn.execute('INSERT INTO books(title, series_index, author_sort) VALUES (?, ?, ?)',
- (title, series_index, aus))
- id = obj.lastrowid
+ if force_id is None:
+ obj = self.conn.execute('INSERT INTO books(title, series_index, author_sort) VALUES (?, ?, ?)',
+ (title, series_index, aus))
+ id = obj.lastrowid
+ else:
+ id = force_id
+ obj = self.conn.execute(
+ 'INSERT INTO books(id, title, series_index, '
+ 'author_sort) VALUES (?, ?, ?, ?)',
+ (id, title, series_index, aus))
+
self.data.books_added([id], self)
self.set_path(id, True)
self.conn.commit()
@@ -1782,7 +2039,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
mi.timestamp = utcnow()
if mi.pubdate is None:
mi.pubdate = utcnow()
- self.set_metadata(id, mi)
+ self.set_metadata(id, mi, ignore_errors=True)
if cover is not None:
try:
self.set_cover(id, cover)
@@ -1827,7 +2084,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.set_metadata(id, mi)
npath = self.run_import_plugins(path, format)
format = os.path.splitext(npath)[-1].lower().replace('.', '').upper()
- stream = open(npath, 'rb')
+ stream = lopen(npath, 'rb')
format = check_ebook_format(stream, format)
self.add_format(id, format, stream, index_is_id=True)
stream.close()
@@ -1871,7 +2128,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if import_hooks:
self.add_format_with_hooks(id, ext, path, index_is_id=True)
else:
- with open(path, 'rb') as f:
+ with lopen(path, 'rb') as f:
self.add_format(id, ext, f, index_is_id=True)
self.conn.commit()
self.data.refresh_ids(self, [id]) # Needed to update format list and size
diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py
index 276a6ba971..37393d0d2c 100644
--- a/src/calibre/library/field_metadata.py
+++ b/src/calibre/library/field_metadata.py
@@ -5,6 +5,7 @@ Created on 25 May 2010
'''
from calibre.utils.ordered_dict import OrderedDict
+from calibre.utils.config import tweaks
class TagsIcons(dict):
'''
@@ -67,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 {{{
@@ -208,12 +209,21 @@ class FieldMetadata(dict):
'search_terms':[],
'is_custom':False,
'is_category':False}),
+ ('all_metadata',{'table':None,
+ 'column':None,
+ 'datatype':None,
+ 'is_multiple':None,
+ 'kind':'field',
+ 'name':None,
+ 'search_terms':[],
+ 'is_custom':False,
+ 'is_category':False}),
('ondevice', {'table':None,
'column':None,
'datatype':'text',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('On Device'),
'search_terms':['ondevice'],
'is_custom':False,
'is_category':False}),
@@ -231,7 +241,7 @@ class FieldMetadata(dict):
'datatype':'datetime',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('Published'),
'search_terms':['pubdate'],
'is_custom':False,
'is_category':False}),
@@ -258,7 +268,7 @@ class FieldMetadata(dict):
'datatype':'float',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('Size (MB)'),
'search_terms':['size'],
'is_custom':False,
'is_category':False}),
@@ -267,7 +277,7 @@ class FieldMetadata(dict):
'datatype':'datetime',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('Date'),
'search_terms':['date'],
'is_custom':False,
'is_category':False}),
@@ -276,7 +286,7 @@ class FieldMetadata(dict):
'datatype':'text',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('Title'),
'search_terms':['title'],
'is_custom':False,
'is_category':False}),
@@ -294,7 +304,6 @@ class FieldMetadata(dict):
# search labels that are not db columns
search_items = [ 'all',
-# 'date',
'search',
]
@@ -310,6 +319,10 @@ class FieldMetadata(dict):
self._tb_cats[k]['display'] = {}
self._tb_cats[k]['is_editable'] = True
self._add_search_terms_to_map(k, v['search_terms'])
+ self._tb_cats['timestamp']['display'] = {
+ 'date_format': tweaks['gui_timestamp_display_format']}
+ self._tb_cats['pubdate']['display'] = {
+ 'date_format': tweaks['gui_pubdate_display_format']}
self.custom_field_prefix = '#'
self.get = self._tb_cats.get
@@ -335,7 +348,26 @@ class FieldMetadata(dict):
def keys(self):
return self._tb_cats.keys()
- def field_keys(self):
+ def sortable_field_keys(self):
+ return [k for k in self._tb_cats.keys()
+ if self._tb_cats[k]['kind']=='field' and
+ self._tb_cats[k]['datatype'] is not None]
+
+ def standard_field_keys(self):
+ return [k for k in self._tb_cats.keys()
+ if self._tb_cats[k]['kind']=='field' and
+ not self._tb_cats[k]['is_custom']]
+
+ def custom_field_keys(self, include_composites=True):
+ res = []
+ for k in self._tb_cats.keys():
+ fm = self._tb_cats[k]
+ if fm['kind']=='field' and fm['is_custom'] and \
+ (fm['datatype'] != 'composite' or include_composites):
+ res.append(k)
+ return res
+
+ def all_field_keys(self):
return [k for k in self._tb_cats.keys() if self._tb_cats[k]['kind']=='field']
def iterkeys(self):
@@ -374,20 +406,16 @@ class FieldMetadata(dict):
return self.custom_label_to_key_map[label]
raise ValueError('Unknown key [%s]'%(label))
- def get_custom_fields(self):
- return [l for l in self._tb_cats if self._tb_cats[l]['is_custom']]
-
def all_metadata(self):
l = {}
for k in self._tb_cats:
l[k] = self._tb_cats[k]
return l
- def get_custom_field_metadata(self):
+ def custom_field_metadata(self, include_composites=True):
l = {}
- for k in self._tb_cats:
- if self._tb_cats[k]['is_custom']:
- l[k] = self._tb_cats[k]
+ for k in self.custom_field_keys(include_composites):
+ l[k] = self._tb_cats[k]
return l
def add_custom_field(self, label, table, column, datatype, colnum, name,
@@ -410,7 +438,7 @@ class FieldMetadata(dict):
if datatype == 'series':
key += '_index'
self._tb_cats[key] = {'table':None, 'column':None,
- 'datatype':'float', 'is_multiple':False,
+ 'datatype':'float', 'is_multiple':None,
'kind':'field', 'name':'',
'search_terms':[key], 'label':label+'_index',
'colnum':None, 'display':{},
@@ -459,36 +487,10 @@ class FieldMetadata(dict):
key = self.custom_field_prefix+label
self._tb_cats[key]['rec_index'] = index # let the exception fly ...
-
-# DEFAULT_LOCATIONS = frozenset([
-# 'all',
-# 'author', # compatibility
-# 'authors',
-# 'comment', # compatibility
-# 'comments',
-# 'cover',
-# 'date',
-# 'format', # compatibility
-# 'formats',
-# 'isbn',
-# 'ondevice',
-# 'pubdate',
-# 'publisher',
-# 'search',
-# 'series',
-# 'rating',
-# 'tag', # compatibility
-# 'tags',
-# 'title',
-# ])
-
def get_search_terms(self):
s_keys = sorted(self._search_term_map.keys())
for v in self.search_items:
s_keys.append(v)
-# if set(s_keys) != self.DEFAULT_LOCATIONS:
-# print 'search labels and default_locations do not match:'
-# print set(s_keys) ^ self.DEFAULT_LOCATIONS
return s_keys
def _add_search_terms_to_map(self, key, terms):
@@ -499,7 +501,12 @@ class FieldMetadata(dict):
raise ValueError('Attempt to add duplicate search term "%s"'%t)
self._search_term_map[t] = key
- def search_term_to_key(self, term):
+ def search_term_to_field_key(self, term):
if term in self._search_term_map:
return self._search_term_map[term]
return term
+
+ def searchable_fields(self):
+ return [k for k in self._tb_cats.keys()
+ if self._tb_cats[k]['kind']=='field' and
+ len(self._tb_cats[k]['search_terms']) > 0]
diff --git a/src/calibre/library/restore.py b/src/calibre/library/restore.py
new file mode 100644
index 0000000000..c81cf7abcb
--- /dev/null
+++ b/src/calibre/library/restore.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import re, os, traceback, shutil
+from threading import Thread
+from operator import itemgetter
+
+from calibre.ptempfile import TemporaryDirectory
+from calibre.ebooks.metadata.opf2 import OPF
+from calibre.library.database2 import LibraryDatabase2
+from calibre.constants import filesystem_encoding
+from calibre import isbytestring
+
+NON_EBOOK_EXTENSIONS = frozenset([
+ 'jpg', 'jpeg', 'gif', 'png', 'bmp',
+ 'opf', 'swp', 'swo'
+ ])
+
+class RestoreDatabase(LibraryDatabase2):
+
+ PATH_LIMIT = 10
+
+ def set_path(self, *args, **kwargs):
+ pass
+
+ def dirtied(self, *args, **kwargs):
+ pass
+
+class Restore(Thread):
+
+ def __init__(self, library_path, progress_callback=None):
+ super(Restore, self).__init__()
+ if isbytestring(library_path):
+ library_path = library_path.decode(filesystem_encoding)
+ self.src_library_path = os.path.abspath(library_path)
+ self.progress_callback = progress_callback
+ self.db_id_regexp = re.compile(r'^.* \((\d+)\)$')
+ self.bad_ext_pat = re.compile(r'[^a-z]+')
+ if not callable(self.progress_callback):
+ self.progress_callback = lambda x, y: x
+ self.dirs = []
+ self.ignored_dirs = []
+ self.failed_dirs = []
+ self.books = []
+ self.conflicting_custom_cols = {}
+ self.failed_restores = []
+ self.mismatched_dirs = []
+ self.successes = 0
+ self.tb = None
+
+ @property
+ def errors_occurred(self):
+ return self.failed_dirs or self.mismatched_dirs or \
+ self.conflicting_custom_cols or self.failed_restores
+
+ @property
+ def report(self):
+ ans = ''
+ failures = list(self.failed_dirs) + [(x['dirpath'], tb) for x, tb in
+ self.failed_restores]
+ if failures:
+ ans += 'Failed to restore the books in the following folders:\n'
+ for dirpath, tb in failures:
+ ans += '\t' + dirpath + ' with error:\n'
+ ans += '\n'.join('\t\t'+x for x in tb.splitlines())
+ ans += '\n\n'
+
+ if self.conflicting_custom_cols:
+ ans += '\n\n'
+ ans += 'The following custom columns were not fully restored:\n'
+ for x in self.conflicting_custom_cols:
+ ans += '\t#'+x+'\n'
+
+ if self.mismatched_dirs:
+ ans += '\n\n'
+ ans += 'The following folders were ignored:\n'
+ for x in self.mismatched_dirs:
+ ans += '\t'+x+'\n'
+
+
+ return ans
+
+
+ def run(self):
+ try:
+ with TemporaryDirectory('_library_restore') as tdir:
+ self.library_path = tdir
+ self.scan_library()
+ self.create_cc_metadata()
+ self.restore_books()
+ if self.successes == 0 and len(self.dirs) > 0:
+ raise Exception(('Something bad happened'))
+ self.replace_db()
+ except:
+ self.tb = traceback.format_exc()
+
+ def scan_library(self):
+ for dirpath, dirnames, filenames in os.walk(self.src_library_path):
+ leaf = os.path.basename(dirpath)
+ m = self.db_id_regexp.search(leaf)
+ if m is None or 'metadata.opf' not in filenames:
+ self.ignored_dirs.append(dirpath)
+ continue
+ self.dirs.append((dirpath, filenames, m.group(1)))
+
+ self.progress_callback(None, len(self.dirs))
+ for i, x in enumerate(self.dirs):
+ dirpath, filenames, book_id = x
+ try:
+ self.process_dir(dirpath, filenames, book_id)
+ except:
+ self.failed_dirs.append((dirpath, traceback.format_exc()))
+ self.progress_callback(_('Processed') + ' ' + dirpath, i+1)
+
+ def is_ebook_file(self, filename):
+ ext = os.path.splitext(filename)[1]
+ if not ext:
+ return False
+ ext = ext[1:].lower()
+ if ext in NON_EBOOK_EXTENSIONS or \
+ self.bad_ext_pat.search(ext) is not None:
+ return False
+ return True
+
+ def process_dir(self, dirpath, filenames, book_id):
+ book_id = int(book_id)
+ formats = filter(self.is_ebook_file, filenames)
+ fmts = [os.path.splitext(x)[1][1:].upper() for x in formats]
+ sizes = [os.path.getsize(os.path.join(dirpath, x)) for x in formats]
+ names = [os.path.splitext(x)[0] for x in formats]
+ opf = os.path.join(dirpath, 'metadata.opf')
+ mi = OPF(opf).to_book_metadata()
+ timestamp = os.path.getmtime(opf)
+ path = os.path.relpath(dirpath, self.src_library_path).replace(os.sep,
+ '/')
+
+ if int(mi.application_id) == book_id:
+ self.books.append({
+ 'mi': mi,
+ 'timestamp': timestamp,
+ 'formats': list(zip(fmts, sizes, names)),
+ 'id': book_id,
+ 'dirpath': dirpath,
+ 'path': path,
+ })
+ else:
+ self.mismatched_dirs.append(dirpath)
+
+ def create_cc_metadata(self):
+ self.books.sort(key=itemgetter('timestamp'))
+ m = {}
+ fields = ('label', 'name', 'datatype', 'is_multiple', 'is_editable',
+ 'display')
+ for b in self.books:
+ for key in b['mi'].custom_field_keys():
+ cfm = b['mi'].metadata_for_field(key)
+ args = []
+ for x in fields:
+ if x in cfm:
+ if x == 'is_multiple':
+ args.append(cfm[x] is not None)
+ else:
+ args.append(cfm[x])
+ if len(args) == len(fields):
+ # TODO: Do series type columns need special handling?
+ label = cfm['label']
+ if label in m and args != m[label]:
+ if label not in self.conflicting_custom_cols:
+ self.conflicting_custom_cols[label] = set([m[label]])
+ self.conflicting_custom_cols[label].add(args)
+ m[cfm['label']] = args
+
+ db = RestoreDatabase(self.library_path)
+ self.progress_callback(None, len(m))
+ if len(m):
+ for i,args in enumerate(m.values()):
+ db.create_custom_column(*args)
+ self.progress_callback(_('creating custom column ')+args[0], i+1)
+ db.conn.close()
+
+ def restore_books(self):
+ self.progress_callback(None, len(self.books))
+ self.books.sort(key=itemgetter('id'))
+
+ db = RestoreDatabase(self.library_path)
+
+ for i, book in enumerate(self.books):
+ try:
+ self.restore_book(book, db)
+ except:
+ self.failed_restores.append((book, traceback.format_exc()))
+ self.progress_callback(book['mi'].title, i+1)
+
+ db.conn.close()
+
+ def restore_book(self, book, db):
+ db.create_book_entry(book['mi'], add_duplicates=True,
+ force_id=book['id'])
+ db.conn.execute('UPDATE books SET path=? WHERE id=?', (book['path'],
+ book['id']))
+
+ for fmt, size, name in book['formats']:
+ db.conn.execute('''
+ INSERT INTO data (book,format,uncompressed_size,name)
+ VALUES (?,?,?,?)''', (book['id'], fmt, size, name))
+ db.conn.commit()
+ self.successes += 1
+
+ def replace_db(self):
+ dbpath = os.path.join(self.src_library_path, 'metadata.db')
+ ndbpath = os.path.join(self.library_path, 'metadata.db')
+
+ save_path = self.olddb = os.path.splitext(dbpath)[0]+'_pre_restore.db'
+ if os.path.exists(save_path):
+ os.remove(save_path)
+ os.rename(dbpath, save_path)
+ shutil.copyfile(ndbpath, dbpath)
+
diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py
index f5c4063789..afeb5ee0b9 100644
--- a/src/calibre/library/save_to_disk.py
+++ b/src/calibre/library/save_to_disk.py
@@ -9,14 +9,21 @@ __docformat__ = 'restructuredtext en'
import os, traceback, cStringIO, re
from calibre.utils.config import Config, StringConfig, tweaks
+from calibre.utils.formatter import TemplateFormatter
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.constants import preferred_encoding, filesystem_encoding
+from calibre.ebooks.metadata import fmt_sidx
from calibre.ebooks.metadata import title_sort
from calibre import strftime
+plugboard_any_device_value = 'any device'
+plugboard_any_format_value = 'any format'
+plugboard_save_to_disk_value = 'save_to_disk'
+
+
DEFAULT_TEMPLATE = '{author_sort}/{title}/{title} - {authors}'
DEFAULT_SEND_TEMPLATE = '{author_sort}/{title} - {authors}'
@@ -83,6 +90,9 @@ def config(defaults=None):
x('timefmt', default='%b, %Y',
help=_('The format in which to display dates. %d - day, %b - month, '
'%Y - year. Default is: %b, %Y'))
+ x('send_timefmt', default='%b, %Y',
+ help=_('The format in which to display dates. %d - day, %b - month, '
+ '%Y - year. Default is: %b, %Y'))
x('to_lowercase', default=False,
help=_('Convert paths to lowercase.'))
x('replace_whitespace', default=False,
@@ -97,29 +107,42 @@ def preprocess_template(template):
template = template.decode(preferred_encoding, 'replace')
return template
-def safe_format(x, format_args):
- try:
- ans = x.format(**format_args).strip()
- return re.sub(r'\s+', ' ', ans)
- except IndexError: # Thrown if user used [] and index is out of bounds
- pass
- except AttributeError: # Thrown if user used a non existing attribute
- pass
- return ''
+class SafeFormat(TemplateFormatter):
+ '''
+ Provides a format function that substitutes '' for any missing value
+ '''
+
+ def get_value(self, key, args, kwargs):
+ try:
+ b = self.book.get_user_metadata(key, False)
+ key = key.lower()
+ if b is not None and b['datatype'] == 'composite':
+ if key in self.composite_values:
+ return self.composite_values[key]
+ self.composite_values[key] = 'RECURSIVE_COMPOSITE FIELD (S2D) ' + key
+ self.composite_values[key] = \
+ self.vformat(b['display']['composite_template'], [], kwargs)
+ return self.composite_values[key]
+ if kwargs[key]:
+ return self.sanitize(kwargs[key.lower()])
+ return ''
+ except:
+ return ''
+
+safe_formatter = SafeFormat()
def get_components(template, mi, id, timefmt='%b %Y', length=250,
sanitize_func=ascii_filename, replace_whitespace=False,
to_lowercase=False):
library_order = tweaks['save_template_title_series_sorting'] == 'library_order'
tsfmt = title_sort if library_order else lambda x: x
- format_args = dict(**FORMAT_ARGS)
+ format_args = FORMAT_ARGS.copy()
+ format_args.update(mi.all_non_none_fields())
if mi.title:
format_args['title'] = tsfmt(mi.title)
if mi.authors:
format_args['authors'] = mi.format_authors()
format_args['author'] = format_args['authors']
- if mi.author_sort:
- format_args['author_sort'] = mi.author_sort
if mi.tags:
format_args['tags'] = mi.format_tags()
if format_args['tags'].startswith('/'):
@@ -132,17 +155,28 @@ def get_components(template, mi, id, timefmt='%b %Y', length=250,
template = re.sub(r'\{series_index[^}]*?\}', '', template)
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['pubdate'] = strftime(timefmt, mi.pubdate.timetuple())
format_args['id'] = str(id)
- components = [x.strip() for x in template.split('/') if x.strip()]
- components = [safe_format(x, format_args) for x in components]
+ # Now format the custom fields
+ custom_metadata = mi.get_all_user_metadata(make_copy=False)
+ for key in custom_metadata:
+ if key in format_args:
+ ## TODO: NEWMETA: should ratings be divided by 2? The standard rating isn't...
+ if custom_metadata[key]['datatype'] == 'series':
+ format_args[key] = tsfmt(format_args[key])
+ if key+'_index' in format_args:
+ format_args[key+'_index'] = fmt_sidx(format_args[key+'_index'])
+ elif custom_metadata[key]['datatype'] == 'datetime':
+ format_args[key] = strftime(timefmt, format_args[key].timetuple())
+ elif custom_metadata[key]['datatype'] == 'bool':
+ format_args[key] = _('yes') if format_args[key] else _('no')
+
+ components = safe_formatter.safe_format(template, format_args, '', mi,
+ sanitize=sanitize_func)
+ components = [x.strip() for x in components.split('/') if x.strip()]
components = [sanitize_func(x) for x in components if x]
if not components:
components = [str(id)]
@@ -209,6 +243,23 @@ def save_book_to_disk(id, db, root, opts, length):
written = False
for fmt in formats:
+ global plugboard_save_to_disk_value, plugboard_any_format_value
+ dev_name = plugboard_save_to_disk_value
+ plugboards = db.prefs.get('plugboards', {})
+ cpb = None
+ if fmt in plugboards:
+ cpb = plugboards[fmt]
+ if dev_name in cpb:
+ cpb = cpb[dev_name]
+ else:
+ cpb = None
+ if cpb is None and plugboard_any_format_value in plugboards:
+ cpb = plugboards[plugboard_any_format_value]
+ if dev_name in cpb:
+ cpb = cpb[dev_name]
+ else:
+ cpb = None
+ #prints('Using plugboard:', fmt, cpb)
data = db.format(id, fmt, index_is_id=True)
if data is None:
continue
@@ -219,7 +270,12 @@ def save_book_to_disk(id, db, root, opts, length):
stream.write(data)
stream.seek(0)
try:
- set_metadata(stream, mi, fmt)
+ if cpb:
+ newmi = mi.deepcopy()
+ newmi.template_to_attribute(mi, cpb)
+ else:
+ newmi = mi
+ set_metadata(stream, newmi, fmt)
except:
traceback.print_exc()
stream.seek(0)
diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py
index b08161abf2..167cc0a327 100644
--- a/src/calibre/library/schema_upgrades.py
+++ b/src/calibre/library/schema_upgrades.py
@@ -397,3 +397,15 @@ class SchemaUpgrade(object):
UNIQUE(key));
'''
self.conn.executescript(script)
+
+ def upgrade_version_13(self):
+ 'Dirtied table for OPF metadata backups'
+ script = '''
+ DROP TABLE IF EXISTS metadata_dirtied;
+ CREATE TABLE metadata_dirtied(id INTEGER PRIMARY KEY,
+ book INTEGER NOT NULL,
+ UNIQUE(book));
+ INSERT INTO metadata_dirtied (book) SELECT id FROM books;
+ '''
+ self.conn.executescript(script)
+
diff --git a/src/calibre/library/server/__init__.py b/src/calibre/library/server/__init__.py
index 5050dfaa99..7cdea9f602 100644
--- a/src/calibre/library/server/__init__.py
+++ b/src/calibre/library/server/__init__.py
@@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en'
import os
-from calibre.utils.config import Config, StringConfig, config_dir
+from calibre.utils.config import Config, StringConfig, config_dir, tweaks
listen_on = '0.0.0.0'
@@ -46,6 +46,16 @@ def server_config(defaults=None):
'to disable grouping.'))
return c
+def custom_fields_to_display(db):
+ ckeys = db.custom_field_keys()
+ yes_fields = set(tweaks['content_server_will_display'])
+ no_fields = set(tweaks['content_server_wont_display'])
+ if '*' in yes_fields:
+ yes_fields = set(ckeys)
+ if '*' in no_fields:
+ no_fields = set(ckeys)
+ return frozenset(yes_fields - no_fields)
+
def main():
from calibre.library.server.main import main
return main()
diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py
index 9e2f0cb2a2..9de748bcbe 100644
--- a/src/calibre/library/server/content.py
+++ b/src/calibre/library/server/content.py
@@ -56,7 +56,7 @@ class ContentServer(object):
def sort(self, items, field, order):
field = self.db.data.sanitize_sort_field_name(field)
- if field not in self.db.field_metadata.field_keys():
+ if field not in self.db.field_metadata.sortable_field_keys():
raise cherrypy.HTTPError(400, '%s is not a valid sort field'%field)
keyg = CSSortKeyGenerator([(field, order)], self.db.field_metadata)
items.sort(key=keyg, reverse=not order)
diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py
index cde245431f..223e5a5f2d 100644
--- a/src/calibre/library/server/mobile.py
+++ b/src/calibre/library/server/mobile.py
@@ -13,11 +13,12 @@ from lxml import html
from lxml.html.builder import HTML, HEAD, TITLE, LINK, DIV, IMG, BODY, \
OPTION, SELECT, INPUT, FORM, SPAN, TABLE, TR, TD, A, HR
+from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import strftime, format_tag_string
from calibre.ebooks.metadata import fmt_sidx
from calibre.constants import __appname__
from calibre import human_readable
-from calibre.utils.date import utcfromtimestamp, format_date
+from calibre.utils.date import utcfromtimestamp
from calibre.utils.filenames import ascii_filename
def CLASS(*args, **kwargs): # class is a reserved word in Python
@@ -199,9 +200,13 @@ class MobileServer(object):
self.sort(items, sort, (order.lower().strip() == 'ascending'))
CFM = self.db.field_metadata
- CKEYS = [key for key in sorted(CFM.get_custom_fields(),
+ CKEYS = [key for key in sorted(custom_fields_to_display(self.db),
cmp=lambda x,y: cmp(CFM[x]['name'].lower(),
CFM[y]['name'].lower()))]
+ # This method uses its own book dict, not the Metadata dict. The loop
+ # below could be changed to use db.get_metadata instead of reading
+ # info directly from the record made by the view, but it doesn't seem
+ # worth it at the moment.
books = []
for record in items[(start-1):(start-1)+num]:
book = {'formats':record[FM['formats']], 'size':record[FM['size']]}
@@ -216,7 +221,8 @@ class MobileServer(object):
book['authors'] = authors
book['series_index'] = fmt_sidx(float(record[FM['series_index']]))
book['series'] = record[FM['series']]
- book['tags'] = format_tag_string(record[FM['tags']], ',')
+ book['tags'] = format_tag_string(record[FM['tags']], ',',
+ no_tag_count=True)
book['title'] = record[FM['title']]
for x in ('timestamp', 'pubdate'):
book[x] = strftime('%Y/%m/%d %H:%M:%S', record[FM[x]])
@@ -225,27 +231,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, '|'))
- 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 not val:
+ 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 c3a1d68749..f1aeb583db 100644
--- a/src/calibre/library/server/opds.py
+++ b/src/calibre/library/server/opds.py
@@ -17,9 +17,10 @@ import routes
from calibre.constants import __appname__
from calibre.ebooks.metadata import fmt_sidx
from calibre.library.comments import comments_to_html
+from calibre.library.server import custom_fields_to_display
+from calibre.library.server.utils import format_tag_string
from calibre import guess_type
from calibre.utils.ordered_dict import OrderedDict
-from calibre.utils.date import format_date
BASE_HREFS = {
0 : '/stanza',
@@ -131,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')
@@ -147,28 +149,25 @@ def ACQUISITION_ENTRY(item, version, FM, updated, CFM, CKEYS):
extra.append(_('RATING: %s
')%rating)
tags = item[FM['tags']]
if tags:
- extra.append(_('TAGS: %s
')%\
- ', '.join(tags.split(',')))
+ extra.append(_('TAGS: %s
')%format_tag_string(tags, ',',
+ ignore_max=True,
+ no_tag_count=True))
series = item[FM['series']]
if series:
extra.append(_('SERIES: %s [%s]
')%\
(series,
fmt_sidx(float(item[FM['series_index']]))))
for key in CKEYS:
- val = item[CFM[key]['rec_index']]
- if val is not None:
- name = CFM[key]['name']
+ mi = db.get_metadata(item[CFM['id']['rec_index']], index_is_id=True)
+ name, val = mi.format_field(key)
+ if val:
datatype = CFM[key]['datatype']
if datatype == 'text' and CFM[key]['is_multiple']:
- extra.append('%s: %s
'%(name, ', '.join(val.split('|'))))
- 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'))))
+ extra.append('%s: %s
'%(name, format_tag_string(val, ',',
+ ignore_max=True,
+ no_tag_count=True)))
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)
@@ -276,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)
- CKEYS = [key for key in sorted(CFM.get_custom_fields(),
+ CFM = db.field_metadata
+ CKEYS = [key for key in sorted(custom_fields_to_display(db),
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):
@@ -380,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/utils.py b/src/calibre/library/server/utils.py
index 23916aa75c..9a64948a3d 100644
--- a/src/calibre/library/server/utils.py
+++ b/src/calibre/library/server/utils.py
@@ -5,7 +5,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import time
+import time, sys
import cherrypy
@@ -44,8 +44,8 @@ def strftime(fmt='%Y/%m/%d %H:%M:%S', dt=None):
except:
return _strftime(fmt, nowf().timetuple())
-def format_tag_string(tags, sep):
- MAX = tweaks['max_content_server_tags_shown']
+def format_tag_string(tags, sep, ignore_max=False, no_tag_count=False):
+ MAX = sys.maxint if ignore_max else tweaks['max_content_server_tags_shown']
if tags:
tlist = [t.strip() for t in tags.split(sep)]
else:
@@ -53,5 +53,9 @@ def format_tag_string(tags, sep):
tlist.sort(cmp=lambda x,y:cmp(x.lower(), y.lower()))
if len(tlist) > MAX:
tlist = tlist[:MAX]+['...']
- return u'%s'%(', '.join(tlist)) if tlist else ''
+ if no_tag_count:
+ return ', '.join(tlist) if tlist else ''
+ else:
+ return u'%s:&:%s'%(tweaks['max_content_server_tags_shown'],
+ ', '.join(tlist)) if tlist else ''
diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py
index 41fcfd8a93..469d2457e7 100644
--- a/src/calibre/library/server/xml.py
+++ b/src/calibre/library/server/xml.py
@@ -11,11 +11,11 @@ import cherrypy
from lxml.builder import ElementMaker
from lxml import etree
+from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import strftime, format_tag_string
from calibre.ebooks.metadata import fmt_sidx
from calibre.constants import preferred_encoding
from calibre import isbytestring
-from calibre.utils.date import format_date
from calibre.utils.filenames import ascii_filename
E = ElementMaker()
@@ -67,6 +67,10 @@ class XMLServer(object):
return x.decode(preferred_encoding, 'replace')
return unicode(x)
+ # This method uses its own book dict, not the Metadata dict. The loop
+ # below could be changed to use db.get_metadata instead of reading
+ # info directly from the record made by the view, but it doesn't seem
+ # worth it at the moment.
for record in items[start:start+num]:
kwargs = {}
aus = record[FM['authors']] if record[FM['authors']] else __builtin__._('Unknown')
@@ -86,7 +90,7 @@ class XMLServer(object):
'comments'):
y = record[FM[x]]
if x == 'tags':
- y = format_tag_string(y, ',')
+ y = format_tag_string(y, ',', ignore_max=True)
kwargs[x] = serialize(y) if y else ''
kwargs['safe_title'] = ascii_filename(kwargs['title'])
@@ -94,36 +98,28 @@ class XMLServer(object):
c = kwargs.pop('comments')
CFM = self.db.field_metadata
- CKEYS = [key for key in sorted(CFM.get_custom_fields(),
+ CKEYS = [key for key in sorted(custom_fields_to_display(self.db),
cmp=lambda x,y: cmp(CFM[x]['name'].lower(),
CFM[y]['name'].lower()))]
custcols = []
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(name, format_tag_string(val,'|'))
- 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))
@@ -141,6 +137,3 @@ class XMLServer(object):
return etree.tostring(ans, encoding='utf-8', pretty_print=True,
xml_declaration=True)
-
-
-
diff --git a/src/calibre/manual/faq.rst b/src/calibre/manual/faq.rst
index ab674a17f1..3cf171bc1b 100644
--- a/src/calibre/manual/faq.rst
+++ b/src/calibre/manual/faq.rst
@@ -280,9 +280,16 @@ Why doesn't |app| have a column for foo?
|app| is designed to have columns for the most frequently and widely used fields. In addition, you can add any columns you like. Columns can be added via :guilabel:`Preferences->Interface->Add your own columns`.
Watch the tutorial `UI Power tips `_ to learn how to create your own columns.
+You can also create "virtual columns" that contain combinations of the metadata from other columns. In the add column dialog choose the option "Column from other columns" and in the template enter the other column names. For example to create a virtual column containing formats or ISBN, enter ``{formats}`` for formats or ``{isbn}`` for ISBN. For more details, see :ref:`templatelangcalibre`.
+
+
+Can I have a column showing the formats or the ISBN?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Yes, you can. Follow the instructions in the answer above for adding custom columns.
+
How do I move my |app| library from one computer to another?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Simply copy the |app| library folder from the old to the new computer. You can find out what the library folder is by clicking the calibre icon in the toolbar. The very first item is the path to the library folder. Now on the new computer, start |app| for the first time. It will run the Welcome Wizard asking you for the location of the |app| library. Point it to the previously copied folder. If the computer you are transferring too already has a calibre installation, then the Welcome wizard wont run. In that case, click the calibre icon in the tooolbar and point it to the newly copied directory. You will now have two calibre libraries on your computer and you can switch between them by clicking the calibre icon on the toolbar.
+Simply copy the |app| library folder from the old to the new computer. You can find out what the library folder is by clicking the calibre icon in the toolbar. The very first item is the path to the library folder. Now on the new computer, start |app| for the first time. It will run the Welcome Wizard asking you for the location of the |app| library. Point it to the previously copied folder. If the computer you are transferring to already has a calibre installation, then the Welcome wizard wont run. In that case, click the calibre icon in the tooolbar and point it to the newly copied directory. You will now have two calibre libraries on your computer and you can switch between them by clicking the calibre icon on the toolbar.
Note that if you are transferring between different types of computers (for example Windows to OS X) then after doing the above you should also go to :guilabel:`Preferences->Advanced->Miscellaneous` and click the "Check database integrity button". It will warn you about missing files, if any, which you should then transfer by hand.
diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst
index aa49c51b76..377c409bd0 100644
--- a/src/calibre/manual/gui.rst
+++ b/src/calibre/manual/gui.rst
@@ -84,6 +84,9 @@ Send to device
1. **Send to main memory**: The selected books are transferred to the main memory of the ebook reader.
2. **Send to card**: The selected books are transferred to the storage card on the ebook reader.
+You can control the file name and folder structure of files sent to the device by setting up a template in
+:guilabel:`Preferences->Import/Export->Sending books to devices`. Also see :ref:`templatelangcalibre`.
+
.. _save_to_disk:
Save to disk
@@ -108,6 +111,10 @@ All available formats as well as metadata is stored to disk for each selected bo
Saved books can be re-imported to the library without any loss of information by using the :ref:`Add books ` action.
+You can control the file name and folder structure of files saved to disk by setting up a template in
+:guilabel:`Preferences->Import/Export->Saving books to disk`. Also see :ref:`templatelangcalibre`.
+
+
.. _fetch_news:
Fetch news
diff --git a/src/calibre/manual/index.rst b/src/calibre/manual/index.rst
index 827d848eb1..d63b0b71a9 100644
--- a/src/calibre/manual/index.rst
+++ b/src/calibre/manual/index.rst
@@ -33,7 +33,7 @@ Sections
conversion
metadata
faq
- xpath
+ tutorials
customize
cli/cli-index
develop
diff --git a/src/calibre/manual/regexp.rst b/src/calibre/manual/regexp.rst
new file mode 100644
index 0000000000..5cd9a8b097
--- /dev/null
+++ b/src/calibre/manual/regexp.rst
@@ -0,0 +1,136 @@
+
+.. include:: global.rst
+
+.. _regexptutorial:
+
+All about using regular expressions in |app|
+=======================================================
+
+Regular expressions are features used in many places in |app| to perform sophisticated manipulation of ebook content and metadata. This tutorial is a gentle introduction to getting you started with using regular expressions in |app|.
+
+.. contents:: Contents
+ :depth: 2
+ :local:
+
+
+First, a word of warning and a word of courage
+-------------------------------------------------
+
+This is, inevitably, going to be somewhat technical- after all, regular expressions are a technical tool for doing technical stuff. I'm going to have to use some jargon and concepts that may seem complicated or convoluted. I'm going to try to explain those concepts as clearly as I can, but really can't do without using them at all. That being said, don't be discouraged by any jargon, as I've tried to explain everything new. And while regular expressions themselves may seem like an arcane, black magic (or, to be more prosaic, a random string of mumbo-jumbo letters and signs), I promise that they are not all that complicated. Even those who understand regular expressions really well have trouble reading the more complex ones, but writing them isn't as difficult- you construct the expression step by step. So, take a step and follow me into the rabbit hole.
+
+Where in |app| can you use regular expressions?
+---------------------------------------------------
+
+There are a few places |app| uses regular expressions. There's the header/footer removal in conversion options, metadata detection from filenames in the import settings and, since last version, there's the option to use regular expressions to search and replace in metadata of multiple books.
+
+What on earth *is* a regular expression?
+------------------------------------------------
+
+A regular expression is a way to describe sets of strings. A single regular expression cat *match* a number of different strings. This is what makes regular expression so powerful -- they are a concise way of describing a potentially large number of variations.
+
+.. note:: I'm using string here in the sense it is used in programming languages: a string of one or more characters, characters including actual characters, numbers, punctuation and so-called whitespace (linebreaks, tabulators etc.). Please note that generally, uppercase and lowercase characters are not considered the same, thus "a" being a different character from "A" and so forth. In |app|, regular expressions are case insensitive in the search bar, but not in the conversion options. There's a way to make every regular expression case insensitive, but we'll discuss that later. It gets complicated because regular expressions allow for variations in the strings it matches, so one expression can match multiple strings, which is why people bother using them at all. More on that in a bit.
+
+Care to explain?
+--------------------
+
+Well, that's why we're here. First, this is the most important concept in regular expressions: *A string by itself is a regular expression that matches itself*. That is to say, if I wanted to match the string ``"Hello, World!"`` using a regular expression, the regular expression to use would be ``Hello, World!``. And yes, it really is that simple. You'll notice, though, that this *only* matches the exact string ``"Hello, World!"``, not e.g. ``"Hello, wOrld!"`` or ``"hello, world!"`` or any other such variation.
+
+That doesn't sound too bad. What's next?
+------------------------------------------
+
+Next is the beginning of the really good stuff. Remember where I said that regular expressions can match multiple strings? This is were it gets a little more complicated. Say, as a somewhat more practical exercise, the ebook you wanted to convert had a nasty footer counting the pages, like "Page 5 of 423". Obviously the page number would rise from 1 to 423, thus you'd have to match 423 different strings, right? Wrong, actually: regular expressions allow you to define sets of characters that are matched: To define a set, you put all the characters you want to be in the set into square brackets. So, for example, the set ``[abc]`` would match either the character "a", "b" or "c". *Sets will always only match one of the characters in the set*. They "understand" character ranges, that is, if you wanted to match all the lower case characters, you'd use the set ``[a-z]`` for lower- and uppercase characters you'd use ``[a-zA-Z]`` and so on. Got the idea? So, obviously, using the expression ``Page [0-9] of 423`` you'd be able to match the first 9 pages, thus reducing the expressions needed to three: The second expression ``Page [0-9][0-9] of 423`` would match all two-digit page numbers, and I'm sure you can guess what the third expression would look like. Yes, go ahead. Write it down.
+
+Hey, neat! This is starting to make sense!
+---------------------------------------------
+
+I was hoping you'd say that. But brace yourself, now it gets even better! We just saw that using sets, we could match one of several characters at once. But you can even repeat a character or set, reducing the number of expressions needed to handle the above page number example to one. Yes, ONE! Excited? You should be! It works like this: Some so-called special characters, "+", "?" and "*", *repeat the single element preceding them*. (Element means either a single character, a character set, an escape sequence or a group (we'll learn about those last two later)- in short, any single entity in a regular expression.) These characters are called wildcards or quantifiers. To be more precise, "?" matches *0 or 1* of the preceding element, "*" matches *0 or more* of the preceding element and "+" matches *1 or more* of the preceding element. A few examples: The expression ``a?`` would match either "" (which is the empty string, not strictly useful in this case) or "a", the expression ``a*`` would match "", "a", "aa" or any number of a's in a row, and, finally, the expression ``a+`` would match "a", "aa" or any number of a's in a row (Note: it wouldn't match the empty string!). Same deal for sets: The expression ``[0-9]+`` would match *every integer number there is*! I know what you're thinking, and you're right: If you use that in the above case of matching page numbers, wouldn't that be the single one expression to match all the page numbers? Yes, the expression ``Page [0-9]+ of 423`` would match every page number in that book!
+
+.. note::
+ A note on these quantifiers: They generally try to match as much text as possible, so be careful when using them. This is called "greedy behaviour"- I'm sure you get why. It gets problematic when you, say, try to match a tag. Consider, for example, the string ``"Title here
"`` and let's say you'd want to match the opening tag (the part between the first pair of angle brackets, a little more on tags later). You'd think that the expression ```` would match that tag, but actually, it matches the whole string! (The character "." is another special character. It matches anything *except* linebreaks, so, basically, the expression ``.*`` would match any single line you can think of.) Instead, try using ```` which makes the quantifier ``"*"`` non-greedy. That expression would only match the first opening tag, as intended.
+ There's actually another way to accomplish this: The expression ``]*>`` will match that same opening tag- you'll see why after the next section. Just note that there quite frequently is more than one way to write a regular expression.
+
+Well, these special characters are very neat and all, but what if I wanted to match a dot or a question mark?
+-----------------------------------------------------------------------------------------------------------------
+
+You can of course do that: Just put a backslash in front of any special character and it is interpreted as the literal character, without any special meaning. This pair of a backslash followed by a single character is called an escape sequence, and the act of putting a backslash in front of a special character is called escaping that character. An escape sequence is interpreted as a single element. There are of course escape sequences that do more than just escaping special characters, for example ``"\t"`` means a tabulator. We'll get to some of the escape sequences later. Oh, and by the way, concerning those special characters: Consider any character we discuss in this introduction as having some function to be special and thus needing to be escaped if you want the literal character.
+
+So, what are the most useful sets?
+------------------------------------
+
+Knew you'd ask. Some useful sets are ``[0-9]`` matching a single number, ``[a-z]`` matching a single lowercase letter, ``[A-Z]`` matching a single uppercase letter, ``[a-zA-Z]`` matching a single letter and ``[a-zA-Z0-9]`` matching a single letter or number. You can also use an escape sequence as shorthand::
+
+ \d is equivalent to [0-9]
+ \w is equivalent to [a-zA-Z0-9_]
+ \s is equivalent to any whitespace
+
+.. note::
+ "Whitespace" is a term for anything that won't be printed. These characters include space, tabulator, line feed, form feed and carriage return.
+
+As a last note on sets, you can also define a set as any character *but* those in the set. You do that by including the character ``"^"`` as the *very first character in the set*. Thus, ``[^a]`` would match any character excluding "a". That's called complementing the set. Those escape sequence shorthands we saw earlier can also be complemented: ``"\D"`` means any non-number character, thus being equivalent to ``[^0-9]``. The other shorthands can be complemented by, you guessed it, using the respective uppercase letter instead of the lowercase one. So, going back to the example ``
]*>`` from the previous section, now you can see that the character set it's using tries to match any character except for a closing angle bracket.
+
+But if I had a few varying strings I wanted to match, things get complicated?
+-------------------------------------------------------------------------------
+
+Fear not, life still is good and easy. Consider this example: The book you're converting has "Title" written on every odd page and "Author" written on every even page. Looks great in print, right? But in ebooks, it's annoying. You can group whole expressions in normal parentheses, and the character ``"|"`` will let you match *either* the expression to its right *or* the one to its left. Combine those and you're done. Too fast for you? Okay, first off, we group the expressions for odd and even pages, thus getting ``(Title)(Author)`` as our two needed expressions. Now we make things simpler by using the vertical bar (``"|"`` is called the vertical bar character): If you use the expression ``(Title|Author)`` you'll either get a match for "Title" (on the odd pages) or you'd match "Author" (on the even pages). Well, wasn't that easy?
+
+You can, of course, use the vertical bar without using grouping parentheses, as well. Remember when I said that quantifiers repeat the element preceding them? Well, the vertical bar works a little differently: The expression "Title|Author" will also match either the string "Title" or the string "Author", just as the above example using grouping. *The vertical bar selects between the entire expression preceding and following it*. So, if you wanted to match the strings "Calibre" and "calibre" and wanted to select only between the upper- and lowercase "c", you'd have to use the expression ``(c|C)alibre``, where the grouping ensures that only the "c" will be selected. If you were to use ``c|Calibre``, you'd get a match on the string "c" or on the string "Calibre", which isn't what we wanted. In short: If in doubt, use grouping together with the vertical bar.
+
+You missed...
+-------------------
+
+... wait just a minute, there's one last, really neat thing you can do with groups. If you have a group that you previously matched, you can use references to that group later in the expression: Groups are numbered starting with 1, and you reference them by escaping the number of the group you want to reference, thus, the fifth group would be referenced as ``\5``. So, if you searched for ``([^ ]+) \1`` in the string "Test Test", you'd match the whole string!
+
+
+In the beginning, you said there was a way to make a regular expression case insensitive?
+------------------------------------------------------------------------------------------------------------------
+
+Yes, I did, thanks for paying attention and reminding me. You can tell |app| how you want certain things handled by using something called flags. You include flags in your expression by using the special construct ``(?flags go here)`` where, obviously, you'd replace "flags go here" with the specific flags you want. For ignoring case, the flag is ``i``, thus you include ``(?i)`` in your expression. Thus, ``test(?i)`` would match "Test", "tEst", "TEst" and any case variation you could think of.
+
+Another useful flag lets the dot match any character at all, *including* the newline, the flag ``s``. If you want to use multiple flags in an expression, just put them in the same statement: ``(?is)`` would ignore case and make the dot match all. It doesn't matter which flag you state first, ``(?si)`` would be equivalent to the above. By the way, good places for putting flags in your expression would be either the very beginning or the very end. That way, they don't get mixed up with anything else.
+
+I think I'm beginning to understand these regular expressions now... how do I use them in |app|?
+-----------------------------------------------------------------------------------------------------
+
+Conversions
+^^^^^^^^^^^^^^
+
+Let's begin with the conversion settings, which is really neat. In the structure detection part, you can input a regexp (short for regular expression) that describes the header or footer string that will be removed during the conversion. The neat part is the wizard. Click on the wizard staff and you get a preview of what |app| "sees" during the conversion process. Scroll down to the header or footer you want to remove, select and copy it, paste it into the regexp field on top of the window. If there are variable parts, like page numbers or so, use sets and quantifiers to cover those, and while you're at it, remember to escape special characters, if there are some. Hit the button labeled :guilabel:`Test` and |app| highlights the parts it would remove were you to use the regexp. Once you're satisfied, hit OK and convert. Be careful if your conversion source has tags like this example::
+
+ Maybe, but the cops feel like you do, Anita. What's one more dead vampire?
+ New laws don't change that.
+ Generated by ABC Amber LIT Conv
+ erter,
+ http://www.processtext.com/abclit.html
+ It had only been two years since Addison v. Clark.
+ The court case gave us a revised version of what life was
+
+(shamelessly ripped out of `this thread `_). You'd have to remove some of the tags as well. In this example, I'd recommend beginning with the tag ````, now you have to end with the corresponding closing tag (opening tags are ````, closing tags are ````), which is simply the next ```` in this case. (Refer to a good HTML manual or ask in the forum if you are unclear on this point.) The opening tag can be described using ````, the closing tag using ````, thus we could remove everything between those tags using ``.*?``. But using this expression would be a bad idea, because it removes everything enclosed by - tags (which, by the way, render the enclosed text in bold print), and it's a fair bet that we'll remove portions of the book in this way. Instead, include the beginning of the enclosed string as well, making the regular expression ``\s*Generated\s+by\s+ABC\s+Amber\s+LIT.*?`` The ``\s`` with quantifiers are included here instead of explicitly using the spaces as seen in the string to catch any variations of the string that might occur. Remember to check what |app| will remove to make sure you don't remove any portions you want to keep if you test a new expression. If you only check one occurrence, you might miss a mismatch somewhere else in the text. Also note that should you accidentally remove more or fewer tags than you actually wanted to, |app| tries to repair the damaged code after doing the header/footer removal.
+
+Adding books
+^^^^^^^^^^^^^^^^
+
+Another thing you can use regular expressions for is to extract metadata from filenames. You can find this feature in the "Adding books" part of the settings. There's a special feature here: You can use field names for metadata fields, for example ``(?P)`` would indicate that calibre uses this part of the string as book title. The allowed field names are listed in the windows, together with another nice test field. An example: Say you want to import a whole bunch of files named like ``Classical Texts: The Divine Comedy by Dante Alighieri.mobi``.
+(Obviously, this is already in your library, since we all love classical italian poetry) or ``Science Fiction epics: The Foundation Trilogy by Isaac Asimov.epub``. This is obviously a naming scheme that |app| won't extract any meaningful data out of - its standard expression for extracting metadata is ``(?P.+) - (?P[^_]+)``. A regular expression that works here would be ``[a-zA-Z]+: (?P.+) by (?P.+)``. Please note that, inside the group for the metadata field, you need to use expressions to describe what the field actually matches. And also note that, when using the test field |app| provides, you need to add the file extension to your testing filename, otherwise you won't get any matches at all, despite using a working expression.
+
+Bulk editing metadata
+^^^^^^^^^^^^^^^^^^^^^^^
+
+The last part is regular expression search and replace in metadata fields. You can access this by selecting multiple books in the library and using bulk metadata edit. Be very careful when using this last feature, as it can do **Very Bad Things** to your library! Doublecheck that your expressions do what you want them to using the test fields, and only mark the books you really want to change! In the regular expression search mode, you can search in one field, replace the text with something and even write the result into another field. A practical example: Say your library contained the books of Frank Herbert's Dune series, named after the fashion ``Dune 1 - Dune``, ``Dune 2 - Dune Messiah`` and so on. Now you want to get ``Dune`` into the series field. You can do that by searching for ``(.*?) \d+ - .*`` in the title field and replacing it with ``\1`` in the series field. See what I did there? That's a reference to the first group you're replacing the series field with. Now that you have the series all set, you only need to do another search for ``.*? -`` in the title field and replace it with ``""`` (an empty string), again in the title field, and your metadata is all neat and tidy. Isn't that great? By the way, instead of replacing the entire field, you can also append or prepend to the field, so, if you *wanted* the book title to be prepended with series info, you could do that as well. As you by now have undoubtedly noticed, there's a checkbox labeled :guilabel:`Case sensitive`, so you won't have to use flags to select behaviour here.
+
+Well, that just about concludes the very short introduction to regular expressions. Hopefully I'll have shown you enough to at least get you started and to enable you to continue learning by yourself- a good starting point would be the `Python documentation for regexps `_.
+
+One last word of warning, though: Regexps are powerful, but also really easy to get wrong. |app| provides really great testing possibilities to see if your expressions behave as you expect them to. Use them. Try not to shoot yourself in the foot. (God, I love that expression...) But should you, despite the warning, injure your foot (or any other body parts), try to learn from it.
+
+Credits
+-------------
+
+Thanks for helping with tips, corrections and such:
+
+ * ldolse
+ * kovidgoyal
+ * chaley
+ * dwanthny
+ * kacir
+ * Starson17
+
+
diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst
new file mode 100644
index 0000000000..8cdf114b40
--- /dev/null
+++ b/src/calibre/manual/template_lang.rst
@@ -0,0 +1,178 @@
+
+.. include:: global.rst
+
+.. _templatelangcalibre:
+
+The |app| template language
+=======================================================
+
+The |app| template language is used in various places. It is used to control the folder structure and file name when saving files from the |app| library to the disk or eBook reader.
+It is also used to define "virtual" columns that contain data from other columns and so on.
+
+The basic template language is very simple, but has very powerful advanced features. The basic idea is that a template consists of text and names in curly brackets that are then replaced by the corresponding metadata from the book being processed. So, for example, the default template used for saving books to device in |app| is::
+
+ {author_sort}/{title}/{title} - {authors}
+
+For the book "The Foundation" by "Isaac Asimov" it will become::
+
+ Asimov, Isaac/The Foundation/The Foundation - Isaac Asimov
+
+The slashes are text, which is put into the template where it appears. For example, if your template is::
+
+ {author_sort} Some Important Text {title}/{title} - {authors}
+
+For the book "The Foundation" by "Isaac Asimov" it will become::
+
+ Asimov, Isaac Some Important Text The Foundation/The Foundation - Isaac Asimov
+
+You can use all the various metadata fields available in calibre in a template, including any custom columns you have created yourself. To find out the template name for a column simply hover your mouse over the column header. Names for custom fields (columns you have created yourself) always have a # as the first character. For series type custom fields, there is always an additional field named ``#seriesname_index`` that becomes the series index for that series. So if you have a custom series field named ``#myseries``, there will also be a field named ``#myseries_index``.
+
+In addition to the column based fields, you also can use::
+
+ {formats} - A list of formats available in the calibre library for a book
+ {isbn} - The ISBN number of the book
+
+If a particular book does not have a particular piece of metadata, the field in the template is automatically removed for that book. Consider, for example::
+
+ {author_sort}/{series}/{title} {series_index}
+
+If a book has a series, the template will produce::
+
+ {Asimov, Isaac}/Foundation/Second Foundation - 3
+
+and if a book does not have a series::
+
+ {Asimov, Isaac}/Second Foundation
+
+(|app| automatically removes multiple slashes and leading or trailing spaces).
+
+
+Advanced formatting
+----------------------
+
+You can do more than just simple substitution with the templates. You can also conditionally include text and control how the substituted data is formatted.
+
+First, conditionally including text. There are cases where you might want to have text appear in the output only if a field is not empty. A common case is ``series`` and ``series_index``, where you want either nothing or the two values with a hyphen between them. Calibre handles this case using a special field syntax.
+
+For example, assume you want to use the template::
+
+ {series} - {series_index} - {title}
+
+If the book has no series, the answer will be ``- - title``. Many people would rather the result be simply ``title``, without the hyphens. To do this, use the extended syntax ``{field:|prefix_text|suffix_text}``. When you use this syntax, if field has the value SERIES then the result will be ``prefix_textSERIESsuffix_text``. If field has no value, then the result will be the empty string (nothing); the prefix and suffix are ignored. The prefix and suffix can contain blanks.
+
+Using this syntax, we can solve the above series problem with the template::
+
+ {series}{series_index:| - | - }{title}
+
+The hyphens will be included only if the book has a series index, which it will have only if it has a series.
+
+Notes: you must include the : character if you want to use a prefix or a suffix. You must either use no \| characters or both of them; using one, as in ``{field:| - }``, is not allowed. It is OK not to provide any text for one side or the other, such as in ``{series:|| - }``. Using ``{title:||}`` is the same as using ``{title}``.
+
+Second: formatting. Suppose you wanted to ensure that the series_index is always formatted as three digits with leading zeros. This would do the trick::
+
+ {series_index:0>3s} - Three digits with leading zeros
+
+If instead of leading zeros you want leading spaces, use::
+
+ {series_index:>3s} - Three digits with leading spaces
+
+For trailing zeros, use::
+
+ {series_index:0<3s} - Three digits with trailing zeros
+
+
+If you want only the first two letters of the data, use::
+
+ {author_sort:.2} - Only the first two letter of the author sort name
+
+The |app| template language comes from python and for more details on the syntax of these advanced formatting operations, look at the `Python documentation `_.
+
+Advanced features
+------------------
+
+Using templates in custom columns
+----------------------------------
+
+There are sometimes cases where you want to display metadata that |app| does not normally display, or to display data in a way different from how |app| normally does. For example, you might want to display the ISBN, a field that |app| does not display. You can use custom columns for this by creating a column with the type 'column built from other columns' (hereafter called composite columns), and entering a template. Result: |app| will display a column showing the result of evaluating that template. To display the ISBN, create the column and enter ``{isbn}`` into the template box. To display a column containing the values of two series custom columns separated by a comma, use ``{#series1:||,}{#series2}``.
+
+Composite columns can use any template option, including formatting.
+
+You cannot change the data contained in a composite column. If you edit a composite column by double-clicking on any item, you will open the template for editing, not the underlying data. Editing the template on the GUI is a quick way of testing and changing composite columns.
+
+Using functions in templates
+-----------------------------
+
+Suppose you want to display the value of a field in upper case, when that field is normally in title case. You can do this (and many more things) using the functions available for templates. For example, to display the title in upper case, use ``{title:uppercase()}``. To display it in title case, use ``{title:titlecase()}``.
+
+Function references appear in the format part, going after the ``:`` and before the first ``|`` or the closing ``}``. If you have both a format and a function reference, the function comes after another ``:``. Functions must always end with ``()``. Some functions take extra values (arguments), and these go inside the ``()``.
+
+Functions are always applied before format specifications. See further down for an example of using both a format and a function, where this order is demonstrated.
+
+The syntax for using functions is ``{field:function(arguments)}``, or ``{field:function(arguments)|prefix|suffix}``. Argument values cannot contain a comma, because it is used to separate arguments. The last (or only) argument cannot contain a closing parenthesis ( ')' ). Functions return the value of the field used in the template, suitably modified.
+
+The functions available are:
+
+ * ``lowercase()`` -- return value of the field in lower case.
+ * ``uppercase()`` -- return the value of the field in upper case.
+ * ``titlecase()`` -- return the value of the field in title case.
+ * ``capitalize()`` -- return the value as capitalized.
+ * ``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`.
+ * ``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::
+
+ {#myint:0>3s:ifempty(0)}
+
+Note that you can use the prefix and suffix as well. If you want the number to appear as ``[003]`` or ``[000]``, then use the field::
+
+ {#myint:0>3s:ifempty(0)|[|]}
+
+
+
+Special notes for save/send templates
+-------------------------------------
+
+Special processing is applied when a template is used in a `save to disk` or `send to device` template. The values of the fields are cleaned, replacing characters that are special to file systems with underscores, including slashes. This means that field text cannot be used to create folders. However, slashes are not changed in prefix or suffix strings, so slashes in these strings will cause folders to be created. Because of this, you can create variable-depth folder structure.
+
+For example, assume we want the folder structure `series/series_index - title`, with the caveat that if series does not exist, then the title should be in the top folder. The template to do this is::
+
+ {series:||/}{series_index:|| - }{title}
+
+The slash and the hyphen appear only if series is not empty.
+
+The lookup function lets us do even fancier processing. For example, assume that if a book has a series, then we want the folder structure `series/series index - title.fmt`. If the book does not have a series, then we want the folder structure `genre/author_sort/title.fmt`. If the book has no genre, we want to use 'Unknown'. We want two completely different paths, depending on the value of series.
+
+To accomplish this, we:
+ 1. Create a composite field (call it AA) containing ``{series}/{series_index} - {title'}``. If the series is not empty, then this template will produce `series/series_index - title`.
+ 2. Create a composite field (call it BB) containing ``{#genre:ifempty(Unknown)}/{author_sort}/{title}``. This template produces `genre/author_sort/title`, where an empty genre is replaced wuth `Unknown`.
+ 3. Set the save template to ``{series:lookup(AA,BB)}``. This template chooses composite field AA if series is not empty, and composite field BB if series is empty. We therefore have two completely different save paths, depending on whether or not `series` is empty.
+
+Templates and Plugboards
+------------------------
+
+Plugboards are used for changing the metadata written into books during send-to-device and save-to-disk operations. A plugboard permits you to specify a template to provide the data to write into the book's metadata. You can use plugboards to modify the following fields: authors, author_sort, language, publisher, tags, title, title_sort. This feature should help those of you who want to use different metadata in your books on devices to solve sorting or display issues.
+
+When you create a plugboard, you specify the format and device for which the plugboard is to be used. A special device is provided, save_to_disk, that is used when saving formats (as opposed to sending them to a device). Once you have chosen the format and device, you choose the metadata fields to change, providing templates to supply the new values. These templates are `connected` to their destination fields, hence the name `plugboards`. You can, of course, use composite columns in these templates.
+
+The tags and authors fields have special treatment, because both of these fields can hold more than one item. After all, book can have many tags and many authors. When you specify that one of these two fields is to be changed, the result of evaluating the template is examined to see if more than one item is there.
+
+For tags, the result cut apart whereever |app| finds a comma. For example, if the template produces the value ``Thriller, Horror``, then the result will be two tags, ``Thriller`` and ``Horror``. There is no way to put a comma in the middle of a tag.
+
+The same thing happens for authors, but using a different character for the cut, a `&` (ampersand) instead of a comma. For example, if the template produces the value ``Blogs, Joe&Posts, Susan``, then the book will end up with two authors, ``Blogs, Joe`` and ``Posts, Susan``. If the template produces the value ``Blogs, Joe;Posts, Susan``, then the book will have one author with a rather strange name.
+
+Plugboards affect only the metadata written into the book. They do not affect calibre's metadata or the metadata used in ``save to disk`` and ``send to device`` templates. Plugboards also do not affect what is written into a Sony's database, so cannot be used for altering the metadata shown on a Sony's menu.
+
+Helpful Tips
+------------
+
+You might find the following tips useful.
+
+ * Create a custom composite column to test templates. Once you have the column, you can change its template simply by double-clicking on the column. Hide the column when you are not testing.
+ * Templates can use other templates by referencing a composite custom column.
+ * In a plugboard, you can set a field to empty (or whatever is equivalent to empty) by using the special template ``{null}``. This template will always evaluate to an empty string.
+ * The technique described above to show numbers even if they have a zero value works with the standard field series_index.
+
\ No newline at end of file
diff --git a/src/calibre/manual/tutorials.rst b/src/calibre/manual/tutorials.rst
new file mode 100644
index 0000000000..084c44ff64
--- /dev/null
+++ b/src/calibre/manual/tutorials.rst
@@ -0,0 +1,19 @@
+
+.. include:: global.rst
+
+.. _tutorials:
+
+Tutorials
+=======================================================
+
+Here you will find tutorials to get you started using |app|'s more advanced features, like XPath and templates.
+
+.. toctree::
+ :maxdepth: 1
+
+ news
+ xpath
+ template_lang
+ regexp
+ portable
+
diff --git a/src/calibre/startup.py b/src/calibre/startup.py
index 75aac7c277..9d5c4b51fc 100644
--- a/src/calibre/startup.py
+++ b/src/calibre/startup.py
@@ -106,5 +106,84 @@ if not _run_once:
os.path.join = my_join
+ def local_open(name, mode='r', bufsize=-1):
+ '''
+ Open a file that wont be inherited by child processes
+
+ Only supports the following modes:
+ r, w, a, rb, wb, ab, r+, w+, a+, r+b, w+b, a+b
+ '''
+ if iswindows:
+ m = mode[0]
+ random = len(mode) > 1 and mode[1] == '+'
+ binary = mode[-1] == 'b'
+
+ if m == 'a':
+ flags = os.O_APPEND| os.O_RDWR
+ flags |= os.O_RANDOM if random else os.O_SEQUENTIAL
+ elif m == 'r':
+ if random:
+ flags = os.O_RDWR | os.O_RANDOM
+ else:
+ flags = os.O_RDONLY | os.O_SEQUENTIAL
+ elif m == 'w':
+ if random:
+ flags = os.O_RDWR | os.O_RANDOM
+ else:
+ flags = os.O_WRONLY | os.O_SEQUENTIAL
+ flags |= os.O_TRUNC | os.O_CREAT
+ if binary:
+ flags |= os.O_BINARY
+ else:
+ flags |= os.O_TEXT
+ flags |= os.O_NOINHERIT
+ fd = os.open(name, flags)
+ ans = os.fdopen(fd, mode, bufsize)
+ else:
+ import fcntl
+ try:
+ cloexec_flag = fcntl.FD_CLOEXEC
+ except AttributeError:
+ cloexec_flag = 1
+ ans = open(name, mode, bufsize)
+ old = fcntl.fcntl(ans, fcntl.F_GETFD)
+ fcntl.fcntl(ans, fcntl.F_SETFD, old | cloexec_flag)
+ return ans
+
+ __builtin__.__dict__['lopen'] = local_open
+
+def test_lopen():
+ from calibre.ptempfile import TemporaryDirectory
+ from calibre import CurrentDir
+ n = u'f\xe4llen'
+
+ with TemporaryDirectory() as tdir:
+ with CurrentDir(tdir):
+ with lopen(n, 'w') as f:
+ f.write('one')
+ print 'O_CREAT tested'
+ with lopen(n, 'w+b') as f:
+ f.write('two')
+ with lopen(n, 'r') as f:
+ if f.read() == 'two':
+ print 'O_TRUNC tested'
+ else:
+ raise Exception('O_TRUNC failed')
+ with lopen(n, 'ab') as f:
+ f.write('three')
+ with lopen(n, 'r+') as f:
+ if f.read() == 'twothree':
+ print 'O_APPEND tested'
+ else:
+ raise Exception('O_APPEND failed')
+ with lopen(n, 'r+') as f:
+ f.seek(3)
+ f.write('xxxxx')
+ f.seek(0)
+ if f.read() == 'twoxxxxx':
+ print 'O_RANDOM tested'
+ else:
+ raise Exception('O_RANDOM failed')
+
diff --git a/src/calibre/translations/calibre.pot b/src/calibre/translations/calibre.pot
index 146a2e2e0c..aa0c3de442 100644
--- a/src/calibre/translations/calibre.pot
+++ b/src/calibre/translations/calibre.pot
@@ -5,8 +5,8 @@
msgid ""
msgstr ""
"Project-Id-Version: calibre 0.7.20\n"
-"POT-Creation-Date: 2010-09-24 14:39+MDT\n"
-"PO-Revision-Date: 2010-09-24 14:39+MDT\n"
+"POT-Creation-Date: 2010-09-30 11:49+MDT\n"
+"PO-Revision-Date: 2010-09-30 11:49+MDT\n"
"Last-Translator: Automatically generated\n"
"Language-Team: LANGUAGE\n"
"MIME-Version: 1.0\n"
@@ -22,13 +22,13 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/__init__.py:46
#: /home/kovid/work/calibre/src/calibre/devices/jetbook/driver.py:74
#: /home/kovid/work/calibre/src/calibre/devices/kindle/driver.py:76
-#: /home/kovid/work/calibre/src/calibre/devices/kobo/books.py:46
+#: /home/kovid/work/calibre/src/calibre/devices/kobo/books.py:24
#: /home/kovid/work/calibre/src/calibre/devices/kobo/driver.py:412
#: /home/kovid/work/calibre/src/calibre/devices/nook/driver.py:70
#: /home/kovid/work/calibre/src/calibre/devices/nook/driver.py:71
#: /home/kovid/work/calibre/src/calibre/devices/prs500/books.py:267
#: /home/kovid/work/calibre/src/calibre/devices/prs505/sony_cache.py:526
-#: /home/kovid/work/calibre/src/calibre/devices/usbms/driver.py:405
+#: /home/kovid/work/calibre/src/calibre/devices/usbms/driver.py:398
#: /home/kovid/work/calibre/src/calibre/ebooks/chm/input.py:97
#: /home/kovid/work/calibre/src/calibre/ebooks/chm/input.py:100
#: /home/kovid/work/calibre/src/calibre/ebooks/chm/metadata.py:56
@@ -40,23 +40,25 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/ebooks/lrf/html/convert_from.py:1894
#: /home/kovid/work/calibre/src/calibre/ebooks/lrf/html/convert_from.py:1896
#: /home/kovid/work/calibre/src/calibre/ebooks/lrf/output.py:24
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:240
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:283
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:286
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:402
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:20
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:21
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:223
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:29
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:30
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:66
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:346
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:351
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:551
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/ereader.py:36
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/ereader.py:61
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/fb2.py:46
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/fetch.py:333
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:65
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/meta.py:36
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/meta.py:64
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/meta.py:66
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/meta.py:123
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/meta.py:125
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:945
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:1057
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:1017
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:1129
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/pdb.py:39
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/pdf.py:28
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/pml.py:23
@@ -75,8 +77,8 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/base.py:911
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/base.py:916
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/base.py:982
-#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/reader.py:137
-#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/reader.py:139
+#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/reader.py:136
+#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/reader.py:138
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:64
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:112
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:118
@@ -104,46 +106,45 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/writer.py:98
#: /home/kovid/work/calibre/src/calibre/ebooks/rtf/input.py:239
#: /home/kovid/work/calibre/src/calibre/ebooks/rtf/input.py:241
-#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:352
-#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:359
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:296
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:299
+#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:355
+#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:362
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:302
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:305
#: /home/kovid/work/calibre/src/calibre/gui2/add.py:137
#: /home/kovid/work/calibre/src/calibre/gui2/add.py:144
#: /home/kovid/work/calibre/src/calibre/gui2/convert/__init__.py:42
#: /home/kovid/work/calibre/src/calibre/gui2/convert/metadata.py:111
#: /home/kovid/work/calibre/src/calibre/gui2/convert/metadata.py:136
#: /home/kovid/work/calibre/src/calibre/gui2/convert/metadata.py:138
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:869
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:878
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1162
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1165
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:894
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:903
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1187
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1190
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/comicconf.py:47
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:120
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:155
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_single.py:571
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:173
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:357
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:377
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:877
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1062
-#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:91
-#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:96
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:177
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:380
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:399
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:912
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1100
+#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:97
#: /home/kovid/work/calibre/src/calibre/gui2/viewer/main.py:187
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:213
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:215
#: /home/kovid/work/calibre/src/calibre/library/database.py:913
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:375
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:387
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1064
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1139
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1843
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1845
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1972
-#: /home/kovid/work/calibre/src/calibre/library/server/mobile.py:211
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:137
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:140
-#: /home/kovid/work/calibre/src/calibre/library/server/xml.py:71
-#: /home/kovid/work/calibre/src/calibre/utils/localization.py:117
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:396
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:408
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:1256
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:1357
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2106
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2108
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2235
+#: /home/kovid/work/calibre/src/calibre/library/server/mobile.py:219
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:139
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:142
+#: /home/kovid/work/calibre/src/calibre/library/server/xml.py:76
+#: /home/kovid/work/calibre/src/calibre/utils/localization.py:118
#: /home/kovid/work/calibre/src/calibre/utils/podofo/__init__.py:46
#: /home/kovid/work/calibre/src/calibre/utils/podofo/__init__.py:64
#: /home/kovid/work/calibre/src/calibre/utils/podofo/__init__.py:78
@@ -213,34 +214,34 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:205
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:215
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:225
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:236
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:247
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:259
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:280
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:291
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:301
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:311
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:235
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:246
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:258
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:279
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:290
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:300
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:310
msgid "Read metadata from %s files"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:270
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:269
msgid "Read metadata from ebooks in RAR archives"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:322
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:321
msgid "Read metadata from ebooks in ZIP archives"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:335
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:345
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:355
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:377
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:388
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:398
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:334
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:344
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:354
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:376
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:387
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:397
msgid "Set metadata in %s files"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:366
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:365
msgid "Set metadata from %s files"
msgstr ""
@@ -268,7 +269,7 @@ msgid "Change the way calibre behaves"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:711
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:176
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:177
msgid "Add your own columns"
msgstr ""
@@ -321,6 +322,7 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:769
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:781
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:793
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:805
msgid "Import/Export"
msgstr ""
@@ -345,53 +347,61 @@ msgid "Control how calibre transfers files to your ebook reader"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:803
-msgid "Sharing books by email"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:805
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:817
-msgid "Sharing"
+msgid "Metadata plugboards"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:809
+msgid "Change metadata fields before saving/sending"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:814
+msgid "Sharing books by email"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:816
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:828
+msgid "Sharing"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:820
msgid "Setup sharing of books via email. Can be used for automatic sending of downloaded news to your devices"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:815
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:826
msgid "Sharing over the net"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:821
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:832
msgid "Setup the calibre Content Server which will give you access to your calibre library from anywhere, on any device, over the internet"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:828
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:839
msgid "Plugins"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:830
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:842
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:841
#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:853
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:864
msgid "Advanced"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:834
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:845
msgid "Add/remove/customize various bits of calibre functionality"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:840
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:851
msgid "Tweaks"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:846
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:857
msgid "Fine tune how calibre behaves in various contexts"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:851
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:862
msgid "Miscellaneous"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:857
+#: /home/kovid/work/calibre/src/calibre/customize/builtins.py:868
msgid "Miscellaneous advanced configuration"
msgstr ""
@@ -411,112 +421,112 @@ msgstr ""
msgid "If specified, the output plugin will try to create output that is as human readable as possible. May not have any effect for some output plugins."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:45
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:46
msgid "Input profile"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:49
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:50
msgid "This profile tries to provide sane defaults and is useful if you know nothing about the input document."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:57
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:418
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:58
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:422
msgid "This profile is intended for the SONY PRS line. The 500/505/600/700 etc."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:69
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:70
msgid "This profile is intended for the SONY PRS 300."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:78
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:453
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:79
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:459
msgid "This profile is intended for the SONY PRS-900."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:86
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:483
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:87
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:489
msgid "This profile is intended for the Microsoft Reader."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:97
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:494
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:98
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:500
msgid "This profile is intended for the Mobipocket books."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:110
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:507
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:111
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:513
msgid "This profile is intended for the Hanlin V3 and its clones."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:122
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:519
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:123
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:525
msgid "This profile is intended for the Hanlin V5 and its clones."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:132
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:527
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:133
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:533
msgid "This profile is intended for the Cybook G3."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:145
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:540
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:146
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:546
msgid "This profile is intended for the Cybook Opus."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:157
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:551
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:158
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:557
msgid "This profile is intended for the Amazon Kindle."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:169
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:589
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:170
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:595
msgid "This profile is intended for the Irex Illiad."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:181
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:602
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:182
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:608
msgid "This profile is intended for the IRex Digital Reader 1000."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:194
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:616
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:195
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:622
msgid "This profile is intended for the IRex Digital Reader 800."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:206
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:630
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:207
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:636
msgid "This profile is intended for the B&N Nook."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:228
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:229
msgid "Output profile"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:232
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:233
msgid "This profile tries to provide sane defaults and is useful if you want to produce a document intended to be read at a computer or on a range of devices."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:262
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:266
msgid "Intended for the iPad and similar devices with a resolution of 768x1024"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:431
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:437
msgid "This profile is intended for the Kobo Reader."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:444
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:450
msgid "This profile is intended for the SONY PRS-300."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:462
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:468
msgid "This profile is intended for the 5-inch JetBook."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:471
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:477
msgid "This profile is intended for the SONY PRS line. The 500/505/700 etc, in landscape mode. Mainly useful for comics."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:571
+#: /home/kovid/work/calibre/src/calibre/customize/profiles.py:577
msgid "This profile is intended for the Amazon Kindle DX."
msgstr ""
@@ -592,80 +602,80 @@ msgstr ""
msgid "Communicate with S60 phones."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:85
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:86
msgid "Apple device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:87
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:88
msgid "Communicate with iTunes/iBooks."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:93
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:94
msgid "Apple device detected, launching iTunes, please wait ..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:246
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:249
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:247
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:250
msgid "Updating device metadata listing..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:323
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:362
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:922
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:962
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2831
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2871
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:324
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:363
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:923
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:963
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2832
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2872
msgid "%d of %d"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:369
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:967
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2877
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:370
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:968
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2878
msgid "finished"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:544
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:545
msgid "Use Series as Category in iTunes/iBooks"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:546
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:547
msgid "Cache covers from iTunes/iBooks"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:558
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:559
msgid ""
"Some books not found in iTunes database.\n"
"Delete using the iBooks app.\n"
"Click 'Show Details' for a list."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:886
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:887
msgid ""
"Some cover art could not be converted.\n"
"Click 'Show Details' for a list."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2499
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2500
#: /home/kovid/work/calibre/src/calibre/devices/usbms/device.py:817
#: /home/kovid/work/calibre/src/calibre/devices/usbms/device.py:823
-#: /home/kovid/work/calibre/src/calibre/devices/usbms/device.py:851
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:244
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:198
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:211
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1712
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:134
+#: /home/kovid/work/calibre/src/calibre/devices/usbms/device.py:853
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:248
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:209
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:222
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:1966
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:135
msgid "News"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2500
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2501
#: /home/kovid/work/calibre/src/calibre/gui2/catalog/catalog_epub_mobi.py:20
#: /home/kovid/work/calibre/src/calibre/library/catalog.py:556
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1675
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1693
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:1929
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:1947
msgid "Catalog"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2738
+#: /home/kovid/work/calibre/src/calibre/devices/apple/driver.py:2739
msgid "Communicate with iTunes."
msgstr ""
@@ -791,6 +801,10 @@ msgstr ""
msgid "Communicate with the MiBuk Wolder reader."
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/devices/jetbook/driver.py:116
+msgid "Communicate with the JetBook Mini reader."
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/devices/kindle/driver.py:43
msgid "Communicate with the Kindle eBook reader."
msgstr ""
@@ -1028,7 +1042,7 @@ msgstr ""
msgid "Transferring books to device..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/devices/usbms/driver.py:314
+#: /home/kovid/work/calibre/src/calibre/devices/usbms/driver.py:313
msgid "Sending metadata to device..."
msgstr ""
@@ -1743,7 +1757,7 @@ msgid "Path to output file"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/ebooks/lrf/lrs/convert_from.py:290
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:114
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:128
msgid "Verbose processing"
msgstr ""
@@ -1816,92 +1830,6 @@ msgstr ""
msgid "Comic"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:401
-#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/manipulate/info.py:45
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:97
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:98
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:75
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:58
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:65
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:354
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:882
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:589
-msgid "Title"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:402
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:59
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:67
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:359
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:883
-msgid "Author(s)"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:403
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:61
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:72
-msgid "Publisher"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:404
-#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/manipulate/info.py:49
-msgid "Producer"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:405
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:35
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:210
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:211
-#: /home/kovid/work/calibre/src/calibre/gui2/convert/metadata_ui.py:189
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:99
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info_ui.py:72
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:313
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1081
-msgid "Comments"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:413
-#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:154
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:27
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:73
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:301
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1077
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:145
-msgid "Tags"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:415
-#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:152
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:26
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:74
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:318
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1086
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:93
-msgid "Series"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:416
-msgid "Language"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:418
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1069
-msgid "Timestamp"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:420
-#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:151
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:63
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:70
-msgid "Published"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/__init__.py:422
-msgid "Rights"
-msgstr ""
-
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/amazon.py:85
msgid "EDITORIAL REVIEW"
msgstr ""
@@ -1910,6 +1838,108 @@ msgstr ""
msgid "Extract common e-book formats from archives (zip/rar) files. Also try to autodetect if they are actually cbz/cbr files."
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:109
+msgid "TEMPLATE ERROR"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:479
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:59
+msgid "No"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:479
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:59
+msgid "Yes"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:550
+#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/manipulate/info.py:45
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:97
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:98
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:75
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:58
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:65
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:377
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:917
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:289
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:589
+msgid "Title"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:551
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:59
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:67
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:382
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:918
+msgid "Author(s)"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:552
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:61
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:72
+msgid "Publisher"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:553
+#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/manipulate/info.py:49
+msgid "Producer"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:554
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:37
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:212
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:213
+#: /home/kovid/work/calibre/src/calibre/gui2/convert/metadata_ui.py:189
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:99
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info_ui.py:72
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:332
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1119
+msgid "Comments"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:556
+#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:154
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:27
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:73
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:320
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1115
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:146
+msgid "Tags"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:558
+#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:152
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:26
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:74
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:337
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1124
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:94
+msgid "Series"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:559
+msgid "Language"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:561
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1107
+msgid "Timestamp"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:563
+#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/transforms/jacket.py:151
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/fetch_metadata.py:63
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:70
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:244
+msgid "Published"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/book/base.py:565
+msgid "Rights"
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/cli.py:20
msgid "options"
msgstr ""
@@ -1992,38 +2022,46 @@ msgstr ""
msgid "No cover found"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:27
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:28
msgid "Cover download"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:79
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:80
msgid "Download covers from openlibrary.org"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:107
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:136
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:108
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:137
msgid "ISBN: %s not found"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:117
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:118
msgid "Download covers from librarything.com"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:128
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:68
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:129
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:69
msgid "LibraryThing.com timed out. Try again later."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:135
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:75
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:136
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:76
msgid "Could not fetch cover as server is experiencing high load. Please try again later."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:139
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:79
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:140
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:80
msgid "LibraryThing.com server error. Try again later."
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:226
+msgid "Download covers from Douban.com"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/covers.py:235
+msgid "Douban.com API timed out. Try again later."
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/douban.py:42
msgid "Downloads metadata from Douban.com"
msgstr ""
@@ -2068,7 +2106,7 @@ msgstr ""
msgid "Downloads series/tags/rating information from librarything.com"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:95
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:109
msgid ""
"\n"
"%prog [options] key\n"
@@ -2081,27 +2119,27 @@ msgid ""
"\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:106
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:120
msgid "The ISBN ID of the book you want metadata for."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:108
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:122
msgid "The author whose book to search for."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:110
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:124
msgid "The title of the book to search for."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:112
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/isbndb.py:126
msgid "The publisher of the book to search for."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:76
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:77
msgid " not found."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:86
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/library_thing.py:87
msgid ""
"\n"
"%prog [options] ISBN\n"
@@ -2109,7 +2147,7 @@ msgid ""
"Fetch a cover image/social metadata for the book identified by ISBN from LibraryThing.com\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:1227
+#: /home/kovid/work/calibre/src/calibre/ebooks/metadata/opf2.py:1303
#: /home/kovid/work/calibre/src/calibre/ebooks/oeb/base.py:1399
msgid "Cover"
msgstr ""
@@ -2382,7 +2420,7 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/ebooks/pdf/manipulate/info.py:46
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:75
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:33
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:34
msgid "Author"
msgstr ""
@@ -2639,7 +2677,7 @@ msgid "Disable UI animations"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:182
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:479
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:487
msgid "Copied"
msgstr ""
@@ -2651,7 +2689,7 @@ msgstr ""
msgid "Copy to Clipboard"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:462
+#: /home/kovid/work/calibre/src/calibre/gui2/__init__.py:465
msgid "Choose Files"
msgstr ""
@@ -2795,7 +2833,7 @@ msgid "Add books to your calibre library from the connected device"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/annotate.py:20
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:498
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:521
msgid "Fetch annotations (experimental)"
msgstr ""
@@ -2814,9 +2852,9 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/convert.py:87
#: /home/kovid/work/calibre/src/calibre/gui2/actions/copy_to_library.py:116
#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:76
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:142
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:180
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:208
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:147
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:185
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:214
#: /home/kovid/work/calibre/src/calibre/gui2/actions/save_to_disk.py:92
msgid "No books selected"
msgstr ""
@@ -2863,7 +2901,7 @@ msgid "Generating %s catalog..."
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/catalog.py:58
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:229
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:230
msgid "No books found"
msgstr ""
@@ -2887,7 +2925,7 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:81
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/toolbar.py:51
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:111
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:112
msgid "%d books"
msgstr ""
@@ -2911,65 +2949,81 @@ msgstr ""
msgid "Delete library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:168
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:120
+msgid "Library backup status..."
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:176
msgid "Rename"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:169
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:177
msgid "Choose a new name for the library %s. "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:170
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:178
msgid "Note that the actual library folder will be renamed."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:177
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:185
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/user_profiles.py:185
msgid "Already exists"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:178
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:186
msgid "The folder %s already exists. Delete it first."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:184
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:192
msgid "Rename failed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:185
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:193
msgid "Failed to rename the library at %s. The most common cause for this is if one of the files in the library is open in another program."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:195
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:203
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/confirm_delete_ui.py:53
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/columns.py:102
msgid "Are you sure?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:196
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:204
msgid "All files from %s will be permanently deleted. Are you sure?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:216
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:223
+msgid "none"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:224
+msgid "Backup status"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:225
+msgid "Book metadata files remaining to be written: %s"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:234
msgid "No library found"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:217
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:235
msgid "No existing calibre library was found at %s. It will be removed from the list of known libraries."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:249
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:254
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:267
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:272
#: /home/kovid/work/calibre/src/calibre/gui2/actions/save_to_disk.py:101
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:584
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:587
msgid "Not allowed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:250
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:268
msgid "You cannot change libraries when a device is connected."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:255
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/choose_library.py:273
msgid "You cannot change libraries while jobs are running."
msgstr ""
@@ -3027,8 +3081,8 @@ msgid "Could not copy books: "
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/copy_to_library.py:138
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:670
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:428
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:693
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:675
msgid "Failed"
msgstr ""
@@ -3089,14 +3143,14 @@ msgid "Main memory"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/delete.py:116
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:435
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:444
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:458
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:467
msgid "Storage Card A"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/delete.py:117
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:437
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:446
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:460
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:469
msgid "Storage Card B"
msgstr ""
@@ -3224,65 +3278,65 @@ msgstr ""
msgid "Cannot download metadata"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:98
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:101
msgid "social metadata"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:100
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:103
msgid "covers"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:100
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:103
msgid "metadata"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:105
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:108
msgid "Downloading %s for %d book(s)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:126
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:131
msgid "Failed to download some metadata"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:127
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:132
msgid "Failed to download metadata for the following:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:130
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:135
msgid "Failed to download metadata:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:131
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:607
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:136
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:630
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:65
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:112
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:125
#: /home/kovid/work/calibre/src/calibre/utils/ipc/job.py:54
msgid "Error"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:141
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:179
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:146
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:184
msgid "Cannot edit metadata"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:207
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:210
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:213
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:216
msgid "Cannot merge books"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:211
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:217
msgid "At least two books must be selected for merging"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:215
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:221
msgid "Book formats and metadata from the selected books will be added to the first selected book. ISBN will not be merged.
The second and subsequently selected books will not be deleted or changed.
Please confirm you want to proceed."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:227
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:233
msgid "Book formats and metadata from the selected books will be merged into the first selected book. ISBN will not be merged.
After merger the second and subsequently selected books will be deleted.
All book formats of the first selected book will be kept and any duplicate formats in the second and subsequently selected books will be permanently deleted from your computer.
Are you sure you want to proceed?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:240
+#: /home/kovid/work/calibre/src/calibre/gui2/actions/edit_metadata.py:246
msgid "You are about to merge more than 5 books. Are you sure you want to proceed?"
msgstr ""
@@ -3348,6 +3402,7 @@ msgid "&Restart"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/actions/restart.py:14
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/main.py:59
msgid "Ctrl+R"
msgstr ""
@@ -3524,56 +3579,56 @@ msgstr ""
msgid "Searching in"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:197
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:198
msgid "Adding..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:210
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:211
msgid "Searching in all sub-directories..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:223
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:224
msgid "Path error"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:224
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:225
msgid "The specified directory could not be processed."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:228
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:811
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:229
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:836
msgid "No books"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:293
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:294
msgid "Added"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:306
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:307
msgid "Adding failed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:307
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:308
msgid "The add books process seems to have hung. Try restarting calibre and adding the books in smaller increments, until you find the problem book."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:322
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:323
msgid "Duplicates found!"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:323
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:324
msgid "Books with the same title as the following already exist in the database. Add them anyway?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:326
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:327
msgid "Adding duplicates..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:393
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:390
msgid "Saving..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/add.py:446
+#: /home/kovid/work/calibre/src/calibre/gui2/add.py:443
msgid "Saved"
msgstr ""
@@ -3713,48 +3768,48 @@ msgid "&Multiple books per folder, assumes every ebook file is a different book"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:23
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:45
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:54
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:311
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:47
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:56
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:313
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:114
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:115
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:116
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:126
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:76
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:308
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1067
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:327
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1105
msgid "Path"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:24
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:48
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:50
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:117
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:118
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:119
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/book_info.py:122
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:307
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:326
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/emailp.py:24
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:102
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:103
msgid "Formats"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:25
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:886
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1070
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:921
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1108
msgid "Collections"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:47
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:56
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:49
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:58
msgid "Click to open"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:48
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:300
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:306
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:312
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1076
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1080
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:50
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:319
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:325
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:331
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1114
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1118
#: /home/kovid/work/calibre/src/calibre/gui2/shortcuts.py:47
#: /home/kovid/work/calibre/src/calibre/gui2/shortcuts_ui.py:78
#: /home/kovid/work/calibre/src/calibre/gui2/shortcuts_ui.py:83
@@ -3762,7 +3817,7 @@ msgstr ""
msgid "None"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:310
+#: /home/kovid/work/calibre/src/calibre/gui2/book_details.py:312
msgid "Click to open Book Details window"
msgstr ""
@@ -4901,26 +4956,14 @@ msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:145
#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:164
#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:270
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:110
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:130
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:205
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:238
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:242
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:111
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:131
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:206
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:239
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:243
msgid "Undefined"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:59
-#: /home/kovid/work/calibre/src/calibre/library/server/mobile.py:241
-#: /home/kovid/work/calibre/src/calibre/library/server/xml.py:119
-msgid "Yes"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:59
-#: /home/kovid/work/calibre/src/calibre/library/server/mobile.py:243
-#: /home/kovid/work/calibre/src/calibre/library/server/xml.py:121
-msgid "No"
-msgstr ""
-
#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:122
msgid "star(s)"
msgstr ""
@@ -4937,243 +4980,264 @@ msgstr ""
msgid " index:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:451
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk_ui.py:257
-msgid "Automatically number books in this series"
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:460
+msgid "Remove series"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:498
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:463
+msgid "Automatically number books"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:466
+msgid "Force numbers to start with "
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:529
msgid "Remove all tags"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:519
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:550
msgid "tags to add"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:524
+#: /home/kovid/work/calibre/src/calibre/gui2/custom_column_widgets.py:555
msgid "tags to remove"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:48
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:50
#: /home/kovid/work/calibre/src/calibre/utils/ipc/job.py:136
msgid "No details available."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:165
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:167
msgid "Device no longer connected."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:283
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:285
msgid "Get device information"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:294
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:296
msgid "Get list of books on device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:304
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:306
msgid "Get annotations from device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:313
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:315
msgid "Send metadata to device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:318
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:320
msgid "Send collections to device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:342
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:365
msgid "Upload %d books to device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:357
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:380
msgid "Delete books from device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:374
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:397
msgid "Download books from device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:384
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:407
msgid "View book on device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:418
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:441
msgid "Set default send to device action"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:424
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:447
msgid "Send to main memory"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:426
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:449
msgid "Send to storage card A"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:428
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:451
msgid "Send to storage card B"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:433
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:442
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:456
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:465
msgid "Main Memory"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:453
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:476
msgid "Send and delete from library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:454
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:477
msgid "Send specific format"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:490
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:513
msgid "Eject device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:608
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:631
msgid "Error communicating with device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:629
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:652
msgid "Select folder to open as device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:676
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:699
msgid "Error talking to device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:677
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:700
msgid "There was a temporary error talking to the device. Please unplug and reconnect the device and or reboot."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:720
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:743
msgid "Device: "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:722
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:745
msgid " detected."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:812
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:837
msgid "selected to send"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:817
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:842
msgid "Choose format to send to device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:826
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:851
msgid "No device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:827
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:852
msgid "Cannot send: No device is connected"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:830
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:834
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:855
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:859
msgid "No card"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:831
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:835
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:856
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:860
msgid "Cannot send: Device has no storage card"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:876
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:901
msgid "E-book:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:879
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:904
msgid "Attached, you will find the e-book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:880
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:905
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:107
msgid "by"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:881
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:906
msgid "in the %s format."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:894
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:919
msgid "Sending email to"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:924
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:932
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1025
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1087
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1206
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1214
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:949
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:957
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1050
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1112
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1231
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1239
msgid "No suitable formats"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:925
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:950
msgid "Auto convert the following books before sending via email?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:933
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:958
msgid "Could not email the following books as no suitable formats were found:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:951
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:976
msgid "Failed to email books"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:952
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:977
msgid "Failed to email the following books:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:956
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:981
msgid "Sent by email:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:984
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1009
msgid "News:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:985
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1010
msgid "Attached is the"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:996
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1021
msgid "Sent news to"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1026
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1088
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1207
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1051
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1113
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1232
msgid "Auto convert the following books before uploading to the device?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1056
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1081
msgid "Sending catalogs to device."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1120
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1145
msgid "Sending news to device."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1173
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1198
msgid "Sending books to device."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1215
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1240
msgid "Could not upload the following books to the device, as no suitable formats were found. Convert the book(s) to a format supported by your device first."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1277
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1303
msgid "No space on device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1278
+#: /home/kovid/work/calibre/src/calibre/gui2/device.py:1304
msgid "Cannot upload books to device there is no more free space available "
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/gui2/device_drivers/configwidget.py:89
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:324
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugboard.py:227
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:57
+msgid "Invalid template"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/device_drivers/configwidget.py:90
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:325
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugboard.py:228
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:58
+msgid "The template %s is invalid:"
+msgstr ""
+
#:
#: /home/kovid/work/calibre/src/calibre/gui2/device_drivers/configwidget_ui.py:83
msgid "Select available formats and their order for this device"
@@ -5223,7 +5287,7 @@ msgid "My Books"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/catalog_ui.py:69
-#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:301
+#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:304
msgid "Generate catalog"
msgstr ""
@@ -5288,6 +5352,7 @@ msgid "No location selected"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/choose_library.py:84
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:628
msgid "Bad location"
msgstr ""
@@ -5364,15 +5429,16 @@ msgstr ""
#:
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:76
#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:69
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:884
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:919
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:31
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:280
#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:588
msgid "Date"
msgstr ""
#:
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/delete_matching_from_device.py:76
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1066
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1104
msgid "Format"
msgstr ""
@@ -5381,10 +5447,22 @@ msgstr ""
msgid "Delete from device"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:33
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:34
msgid "Author sort"
msgstr ""
+#:
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:115
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:511
+msgid "Invalid author name"
+msgstr ""
+
+#:
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog.py:116
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:512
+msgid "Author names cannot contain & characters."
+msgstr ""
+
#:
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/edit_authors_dialog_ui.py:66
msgid "Manage authors"
@@ -5498,47 +5576,108 @@ msgstr ""
msgid "Stop &all non device jobs"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:107
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:25
+msgid "Title/Author"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:26
+msgid "Standard metadata"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:27
+msgid "Custom metadata"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:28
+msgid "Search/Replace"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:32
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/progress.py:76
+msgid "Working"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:180
#: /home/kovid/work/calibre/src/calibre/gui2/widgets.py:385
msgid "Lower Case"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:108
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:181
#: /home/kovid/work/calibre/src/calibre/gui2/widgets.py:384
msgid "Upper Case"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:109
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:182
#: /home/kovid/work/calibre/src/calibre/gui2/widgets.py:387
msgid "Title Case"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:119
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:185
+msgid "Character match"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:186
+msgid "Regular Expression"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:189
+msgid "Replace field"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:190
+msgid "Prepend to field"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:191
+msgid "Append to field"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:202
msgid "Editing meta information for %d books"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:166
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:255
msgid "Book %d:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:182
-msgid "Search and replace in text fields using regular expressions. The search text is an arbitrary python-compatible regular expression. The replacement text can contain backreferences to parenthesized expressions in the pattern. The search is not anchored, and can match and replace multiple times on the same string. See this reference for more information, and in particular the 'sub' function."
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:270
+msgid "You can destroy your library using this feature. Changes are permanent. There is no undo function. This feature is experimental, and there may be bugs. You are strongly encouraged to back up your library before proceeding.
Search and replace in text fields using character matching or regular expressions. "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:192
-msgid "Note: you can destroy your library using this feature. Changes are permanent. There is no undo function. You are strongly encouraged to back up your library before proceeding."
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:279
+msgid "In character mode, the field is searched for the entered search text. The text is replaced by the specified replacement text everywhere it is found in the specified field. After replacement is finished, the text can be changed to upper-case, lower-case, or title-case. If the case-sensitive check box is checked, the search text must match exactly. If it is unchecked, the search text will match both upper- and lower-case letters"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:386
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:290
+msgid "In regular expression mode, the search text is an arbitrary python-compatible regular expression. The replacement text can contain backreferences to parenthesized expressions in the pattern. The search is not anchored, and can match and replace multiple times on the same string. The modification functions (lower-case etc) are applied to the matched text, not to the field as a whole. The destination box specifies the field where the result after matching and replacement is to be assigned. You can replace the text in the field, or prepend or append the matched text. See this reference for more information on python's regular expressions, and in particular the 'sub' function."
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:432
+msgid "You must specify a destination when source is a composite field"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:524
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:532
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:623
msgid "Search/replace invalid"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:387
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:525
+msgid "Authors cannot be set to the empty string. Book title %s not processed"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:533
+msgid "Title cannot be set to the empty string. Book title %s not processed"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:624
msgid "Search pattern is invalid: %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:419
-msgid "Applying changes to %d books. This may take a while."
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk.py:661
+msgid ""
+"Applying changes to %d books.\n"
+"Phase {0} {1}%%."
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk_ui.py:229
@@ -5621,6 +5760,10 @@ msgid ""
"Book A will have series number 1 and Book B series number 2."
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk_ui.py:257
+msgid "Automatically number books in this series"
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/metadata_bulk_ui.py:258
msgid ""
"Remove stored conversion settings for the selected books.\n"
@@ -5946,10 +6089,6 @@ msgstr ""
msgid "Aborting..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/progress.py:76
-msgid "Working"
-msgstr ""
-
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/saved_search_editor.py:54
msgid "The current saved search will be permanently deleted. Are you sure?"
msgstr ""
@@ -5989,48 +6128,48 @@ msgstr ""
msgid "Change the contents of the saved search"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:120
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:124
msgid "Need username and password"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:121
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:125
msgid "You must provide a username and/or password to use this news source."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:172
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:176
msgid "Created by: "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:179
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:183
msgid "Last downloaded: never"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:194
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:198
msgid "%d days, %d hours and %d minutes ago"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:196
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:200
msgid "Last downloaded"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:220
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:224
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler_ui.py:197
msgid "Schedule news download"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:223
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:227
msgid "Add a custom news source"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:228
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:232
msgid "Download all scheduled new sources"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:328
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:332
msgid "No internet connection"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:329
+#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/scheduler.py:333
msgid "Cannot download news as no internet connection is active"
msgstr ""
@@ -6215,12 +6354,12 @@ msgid "Choose formats"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:82
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:83
msgid "Authors"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/dialogs/tag_categories.py:50
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:113
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:114
msgid "Publishers"
msgstr ""
@@ -6777,7 +6916,7 @@ msgid "Eject this device"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/layout.py:62
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:210
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:209
msgid "Library"
msgstr ""
@@ -6794,7 +6933,7 @@ msgid "Show books in the main memory of the device"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/layout.py:66
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:655
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:839
msgid "Card A"
msgstr ""
@@ -6803,7 +6942,7 @@ msgid "Show books in storage card A"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/layout.py:68
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:657
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:841
msgid "Card B"
msgstr ""
@@ -6823,6 +6962,10 @@ msgstr ""
msgid "Advanced search"
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/gui2/layout.py:155
+msgid "&Search:"
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/gui2/layout.py:162
msgid "
Search the list of books by title, author, publisher, tags, comments, etc.
Words separated by spaces are ANDed"
msgstr ""
@@ -6843,90 +6986,92 @@ msgstr ""
msgid "Delete current saved search"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:284
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:285
msgid "N"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:284
+#: /home/kovid/work/calibre/src/calibre/gui2/library/delegates.py:285
msgid "Y"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:66
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:226
msgid "On Device"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:68
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:271
msgid "Size (MB)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:319
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1086
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:338
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1124
msgid "Book %s of %s."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:674
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1184
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:697
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1222
#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:401
msgid "The lookup/search name is \"{0}\""
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:881
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:916
msgid "In Library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:885
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:920
msgid "Size"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1164
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1202
msgid "Marked for deletion"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1167
+#: /home/kovid/work/calibre/src/calibre/gui2/library/models.py:1205
msgid "Double click to edit me
"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:114
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:115
msgid "Hide column %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:119
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:120
msgid "Sort on %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:120
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:121
msgid "Ascending"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:123
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:124
msgid "Descending"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:135
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:136
msgid "Change text alignment for %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:137
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:138
msgid "Left"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:137
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:138
msgid "Right"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:138
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:139
msgid "Center"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:157
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:158
msgid "Show column"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:169
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:170
msgid "Restore default layout"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:585
+#: /home/kovid/work/calibre/src/calibre/gui2/library/views.py:588
msgid "Dropping onto a device is not supported. First add the book to the calibre library."
msgstr ""
@@ -7021,7 +7166,7 @@ msgid "Do not check for updates"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/main.py:58
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:598
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:636
msgid "Calibre Library"
msgstr ""
@@ -7079,40 +7224,40 @@ msgstr ""
msgid "Bad database location %r. Will start with a new, empty calibre library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:238
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:237
msgid "Starting %s: Loading books..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:283
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:282
msgid "If you are sure it is not running"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:285
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:284
msgid "Cannot Start "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:286
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:285
msgid "%s is already running."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:289
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:288
msgid "may be running in the system tray, in the"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:291
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:290
msgid "upper right region of the screen."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:293
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:292
msgid "lower right region of the screen."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:296
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:295
msgid "try rebooting your computer."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:298
-#: /home/kovid/work/calibre/src/calibre/gui2/main.py:310
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:297
+#: /home/kovid/work/calibre/src/calibre/gui2/main.py:309
msgid "try deleting the file"
msgstr ""
@@ -7129,14 +7274,15 @@ msgid "&Quit"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/main_window.py:90
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:109
msgid "ERROR: Unhandled exception"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:93
+#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:94
msgid "Book has neither title nor ISBN"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:119
+#: /home/kovid/work/calibre/src/calibre/gui2/metadata.py:120
msgid "No matches found for this book"
msgstr ""
@@ -7349,50 +7495,60 @@ msgid "Yes/No"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:69
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:42
+msgid "Column built from other columns"
+msgstr ""
+
+#:
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:71
msgid "No column selected"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:70
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:72
msgid "No column has been selected"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:74
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:76
msgid "Selected column is not a user-defined column"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:105
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:111
msgid "No lookup name was provided"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:107
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:113
msgid "The lookup name must contain only lower case letters, digits and underscores, and start with a letter"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:109
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:116
msgid "Lookup names cannot end with _index, because these names are reserved for the index of a series column."
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:118
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:126
msgid "No column heading was provided"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:124
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:133
msgid "The lookup name %s is already used"
msgstr ""
#:
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:134
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:145
msgid "The heading %s is already used"
msgstr ""
+#:
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column.py:156
+msgid "You must enter a template for composite columns"
+msgstr ""
+
#:
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column_ui.py:106
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/create_custom_column_ui.py:122
@@ -7652,19 +7808,27 @@ msgstr ""
msgid "The following books had formats listed in the database that are not actually available. The entries for the formats have been removed. You should check them manually. This can happen if you manipulate the files in the library folder directly."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:113
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:99
+msgid "Backup metadata"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:100
+msgid "Metadata will be backed up while calibre is running, at the rate of 30 books per minute."
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:126
msgid "Failed to install command line tools."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:116
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:129
msgid "Command line tools installed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:117
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:130
msgid "Command line tools installed in"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:118
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/misc.py:131
msgid "If you move calibre.app, you have to re-install the command line tools."
msgstr ""
@@ -7692,6 +7856,18 @@ msgstr ""
msgid "&Install command line tools"
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugboard.py:200
+msgid "That format and device already has a plugboard or conflicts with another plugboard."
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugboard.py:233
+msgid "Invalid destination"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugboard.py:234
+msgid "The destination field cannot be blank"
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:100
msgid "%(plugin_type)s %(plugins)s"
msgstr ""
@@ -7738,11 +7914,11 @@ msgstr ""
msgid "Customize"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:232
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:236
msgid "Cannot remove builtin plugin"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:233
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/plugins.py:237
msgid " cannot be removed. It is a builtin plugin. Try disabling it instead."
msgstr ""
@@ -7774,12 +7950,12 @@ msgstr ""
msgid "&Add"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:51
-msgid "Invalid template"
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:33
+msgid "Any custom field"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:52
-msgid "The template %s is invalid:"
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/save_template.py:34
+msgid "The lookup name of any custom field. These names begin with \"#\")"
msgstr ""
#:
@@ -7833,17 +8009,17 @@ msgstr ""
msgid "Save metadata in &OPF file"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:25
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:28
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending_ui.py:63
msgid "Manual management"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:26
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:29
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending_ui.py:64
msgid "Only on send"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:27
+#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending.py:30
#: /home/kovid/work/calibre/src/calibre/gui2/preferences/sending_ui.py:65
msgid "Automatic management"
msgstr ""
@@ -8173,51 +8349,51 @@ msgid "Manage User Categories"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:433
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:304
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:317
msgid "Searches"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:511
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:516
msgid "Duplicate search name"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:512
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:517
msgid "The saved search name %s is already used."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:769
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:774
msgid "Sort by name"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:769
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:774
msgid "Sort by popularity"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:770
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:775
msgid "Sort by average rating"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:773
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:778
msgid "Set the sort order for entries in the Tag Browser"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:779
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:784
msgid "Match all"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:779
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:784
msgid "Match any"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:784
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:789
msgid "When selecting multiple entries in the Tag Browser match any or all of them"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:788
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:793
msgid "Manage &user categories"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:791
+#: /home/kovid/work/calibre/src/calibre/gui2/tag_view.py:796
msgid "Add your own categories to the Tag Browser"
msgstr ""
@@ -8244,15 +8420,15 @@ msgstr ""
msgid "Queueing "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:243
+#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:246
msgid "Fetch news from "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:313
+#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:316
msgid "Convert existing"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:314
+#: /home/kovid/work/calibre/src/calibre/gui2/tools.py:317
msgid "The following books have already been converted to %s format. Do you wish to reconvert them?"
msgstr ""
@@ -8272,43 +8448,43 @@ msgstr ""
msgid "Calibre Quick Start Guide"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:418
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:446
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:428
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:456
msgid "Conversion Error"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:419
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:429
msgid "
Could not convert: %s
It is a DRMed book. You must first remove the DRM using third party tools."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:432
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:442
msgid "Recipe Disabled"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:447
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:457
msgid "Failed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:483
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:493
msgid "is the result of the efforts of many volunteers from all over the world. If you find it useful, please consider donating to support its development. Your donation helps keep calibre development going."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:509
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:519
msgid "There are active jobs. Are you sure you want to quit?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:512
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:522
msgid ""
" is communicating with the device!
\n"
" Quitting may cause corruption on the device.
\n"
" Are you sure you want to quit?"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:516
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:526
msgid "WARNING: Active jobs"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:584
+#: /home/kovid/work/calibre/src/calibre/gui2/ui.py:604
msgid "will keep running in the system tray. To close it, choose Quit in the context menu of the system tray."
msgstr ""
@@ -8812,44 +8988,48 @@ msgstr ""
msgid "Toggle"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:370
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:378
msgid "If you use the WordPlayer e-book app on your Android phone, you can access your calibre book collection directly on the device. To do this you have to turn on the content server."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:374
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:382
msgid "Remember to leave calibre running as the server only runs as long as calibre is running."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:376
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:384
msgid "You have to add the URL http://myhostname:8080 as your calibre library in WordPlayer. Here myhostname should be the fully qualified hostname or the IP address of the computer calibre is running on."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:453
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:461
msgid "Moving library..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:469
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:470
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:477
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:478
msgid "Failed to move library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:524
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:532
msgid "Invalid database"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:525
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:533
msgid "
An invalid library already exists at %s, delete it before trying to move the existing library.
Error: %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:536
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:544
msgid "Could not move library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:590
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:615
msgid "Select location for books"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:665
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:629
+msgid "You must choose an empty folder for the calibre library. %s is not empty."
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/gui2/wizard/__init__.py:703
msgid "welcome wizard"
msgstr ""
@@ -9035,48 +9215,50 @@ msgstr ""
msgid "Turn on the &content server"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:234
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:365
msgid "today"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:237
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:368
msgid "yesterday"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:240
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:371
msgid "thismonth"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:243
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:244
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:374
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:375
msgid "daysago"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:408
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:418
-msgid "no"
-msgstr ""
-
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:408
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:418
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:539
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:549
msgid "unchecked"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:411
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:421
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:539
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:549
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:175
+msgid "no"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:542
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:552
msgid "checked"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:411
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:421
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:542
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:552
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:175
msgid "yes"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:415
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:546
msgid "blank"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/caches.py:415
+#: /home/kovid/work/calibre/src/calibre/library/caches.py:546
msgid "empty"
msgstr ""
@@ -9233,64 +9415,64 @@ msgid ""
"Applies to: ePub, MOBI output formats"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:41
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:43
msgid "Path to the calibre library. Default is to use the path stored in the settings."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:120
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:122
msgid ""
"%prog list [options]\n"
"\n"
"List the books available in the calibre database.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:128
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:130
msgid ""
"The fields to display when listing books in the database. Should be a comma separated list of fields.\n"
"Available fields: %s\n"
"Default: %%default. The special field \"all\" can be used to select all fields. Only has effect in the text output format."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:135
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:137
msgid ""
"The field by which to sort the results.\n"
"Available fields: %s\n"
"Default: %%default"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:137
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:139
msgid "Sort results in ascending order"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:139
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:141
msgid "Filter the results by the search query. For the format of the search query, please see the search related documentation in the User Manual. Default is to do no filtering."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:141
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:143
msgid "The maximum width of a single line in the output. Defaults to detecting screen size."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:142
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:144
msgid "The string used to separate fields. Default is a space."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:143
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:145
msgid "The prefix for all file paths. Default is the absolute path to the library folder."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:165
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:167
msgid "Invalid fields. Available fields:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:172
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:174
msgid "Invalid sort field. Available fields:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:244
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:246
msgid "The following books were not added as they already exist in the database (see --duplicates option):"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:267
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:270
msgid ""
"%prog add [options] file1 file2 file3 ...\n"
"\n"
@@ -9298,65 +9480,65 @@ msgid ""
"the directory related options below.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:276
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:279
msgid "Assume that each directory has only a single logical book and that all files in it are different e-book formats of that book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:278
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:281
msgid "Process directories recursively"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:280
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:283
msgid "Add books to database even if they already exist. Comparison is done based on book titles."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:282
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:285
msgid "Add an empty book (a book with no formats)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:284
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:287
msgid "Set the title of the added empty book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:286
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:289
msgid "Set the authors of the added empty book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:288
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:291
msgid "Set the ISBN of the added empty book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:313
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:317
msgid "You must specify at least one file to add"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:330
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:334
msgid ""
"%prog remove ids\n"
"\n"
"Remove the books identified by ids from the database. ids should be a comma separated list of id numbers (you can get id numbers by using the list command). For example, 23,34,57-85\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:345
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:349
msgid "You must specify at least one book to remove"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:364
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:368
msgid ""
"%prog add_format [options] id ebook_file\n"
"\n"
"Add the ebook in ebook_file to the available formats for the logical book identified by id. You can get id by using the list command. If the format already exists, it is replaced.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:379
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:383
msgid "You must specify an id and an ebook file"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:384
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:388
msgid "ebook file must have an extension"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:392
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:396
msgid ""
"\n"
"%prog remove_format [options] id fmt\n"
@@ -9364,11 +9546,11 @@ msgid ""
"Remove the format fmt from the logical book identified by id. You can get id by using the list command. fmt should be a file extension like LRF or TXT or EPUB. If the logical book does not have fmt available, do nothing.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:409
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:413
msgid "You must specify an id and a format"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:427
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:431
msgid ""
"\n"
"%prog show_metadata [options] id\n"
@@ -9377,15 +9559,15 @@ msgid ""
"id is an id number from the list command.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:435
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:439
msgid "Print metadata in OPF form (XML)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:444
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:448
msgid "You must specify an id"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:458
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:463
msgid ""
"\n"
"%prog set_metadata [options] id /path/to/metadata.opf\n"
@@ -9396,11 +9578,11 @@ msgid ""
"show_metadata command.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:474
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:479
msgid "You must specify an id and a metadata file"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:494
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:499
msgid ""
"%prog export [options] ids\n"
"\n"
@@ -9409,27 +9591,27 @@ msgid ""
"an opf file). You can get id numbers from the list command.\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:502
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:507
msgid "Export all books in database, ignoring the list of ids."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:504
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:509
msgid "Export books to the specified directory. Default is"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:506
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:511
msgid "Export all books into a single directory"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:513
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:518
msgid "Specifying this switch will turn this behavior off."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:536
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:541
msgid "You must specify some ids or the %s option"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:549
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:554
msgid ""
"%prog add_custom_column [options] label name datatype\n"
"\n"
@@ -9438,19 +9620,19 @@ msgid ""
"datatype is one of: {0}\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:558
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:563
msgid "This column stores tag like data (i.e. multiple comma separated values). Only applies if datatype is text."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:562
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:567
msgid "A dictionary of options to customize how the data in this column will be interpreted."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:575
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:580
msgid "You must specify label, name and datatype"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:636
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:641
msgid ""
"\n"
" %prog catalog /path/to/destination.(csv|epub|mobi|xml ...) [options]\n"
@@ -9460,29 +9642,29 @@ msgid ""
" "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:650
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:655
msgid ""
"Comma-separated list of database IDs to catalog.\n"
"If declared, --search is ignored.\n"
"Default: all"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:654
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:659
msgid ""
"Filter the results by the search query. For the format of the search query, please see the search-related documentation in the User Manual.\n"
"Default: no filtering"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:660
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:665
#: /home/kovid/work/calibre/src/calibre/web/fetch/simple.py:505
msgid "Show detailed output information. Useful for debugging"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:673
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:678
msgid "Error: You must specify a catalog output file"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:722
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:727
msgid ""
"\n"
" %prog set_custom [options] column id value\n"
@@ -9494,15 +9676,15 @@ msgid ""
" "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:733
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:738
msgid "If the column stores multiple values, append the specified values to the existing ones, instead of replacing them."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:744
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:749
msgid "Error: You must specify a field name, id and value"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:763
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:768
msgid ""
"\n"
" %prog custom_columns [options]\n"
@@ -9511,19 +9693,19 @@ msgid ""
" "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:770
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:775
msgid "Show details for each column."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:782
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:787
msgid "You will lose all data in the column: %r. Are you sure (y/n)? "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:784
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:789
msgid "y"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:790
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:795
msgid ""
"\n"
" %prog remove_custom_column [options] label\n"
@@ -9533,15 +9715,15 @@ msgid ""
" "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:798
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:803
msgid "Do not ask for confirmation"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:808
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:813
msgid "Error: You must specify a column label"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:818
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:823
msgid ""
"\n"
" %prog saved_searches [options] list\n"
@@ -9554,39 +9736,53 @@ msgid ""
" "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:836
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:841
msgid "Error: You must specify an action (add|remove|list)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:844
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:849
msgid "Name:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:845
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:850
msgid "Search string:"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:851
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:856
msgid "Error: You must specify a name and a search string"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:854
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:859
msgid "added"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:859
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:864
msgid "Error: You must specify a name"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:862
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:867
msgid "removed"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:866
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:871
msgid "Error: Action %s not recognized, must be one of: (add|remove|list)"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/cli.py:880
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:884
+msgid ""
+"\n"
+" %prog restore_database [options]\n"
+"\n"
+" Restore this database from the metadata stored in OPF\n"
+" files in each directory of the calibre library. This is\n"
+" useful if your metadata.db file has been corrupted.\n"
+"\n"
+" WARNING: This completely regenrates your datbase. You will\n"
+" lose stored per-book conversion settings and custom recipes.\n"
+" "
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/library/cli.py:937
msgid ""
"%%prog command [options] [arguments]\n"
"\n"
@@ -9598,143 +9794,152 @@ msgid ""
"For help on an individual command: %%prog command --help\n"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/custom_columns.py:537
+#: /home/kovid/work/calibre/src/calibre/library/custom_columns.py:544
msgid "No label was provided"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/custom_columns.py:539
+#: /home/kovid/work/calibre/src/calibre/library/custom_columns.py:546
msgid "The label must contain only lower case letters, digits and underscores, and start with a letter"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:71
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:81
msgid "%sAverage rating is %3.1f"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:653
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:837
msgid "Main"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:1998
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2261
msgid "
Migrating old database to ebook library in %s
"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:2027
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2290
msgid "Copying %s"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:2044
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2307
msgid "Compacting database"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:2137
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2400
msgid "Checking SQL integrity..."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:2176
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2439
msgid "Checking for missing files."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/database2.py:2198
+#: /home/kovid/work/calibre/src/calibre/library/database2.py:2461
msgid "Checked id"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:124
+#: /home/kovid/work/calibre/src/calibre/library/field_metadata.py:125
msgid "Ratings"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:24
+#: /home/kovid/work/calibre/src/calibre/library/restore.py:117
+msgid "Processed"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/library/restore.py:182
+msgid "creating custom column "
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:31
msgid "The title"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:25
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:32
msgid "The authors"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:26
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:33
msgid "The author sort string. To use only the first letter of the name use {author_sort[0]}"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:28
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:35
msgid "The tags"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:29
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:36
msgid "The series"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:30
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:37
msgid "The series number. To get leading zeros use {series_index:0>3s} or {series_index:>3s} for leading spaces"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:33
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:40
msgid "The rating"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:34
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:41
msgid "The ISBN"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:35
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:42
msgid "The publisher"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:36
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:43
msgid "The date"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:37
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:44
msgid "The published date"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:38
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:45
msgid "The calibre internal id"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:48
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:55
msgid "Options to control saving to disk"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:54
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:61
msgid "Normally, calibre will update the metadata in the saved files from what is in the calibre library. Makes saving to disk slower."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:57
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:64
msgid "Normally, calibre will write the metadata into a separate OPF file along with the actual e-book files."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:60
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:67
msgid "Normally, calibre will save the cover in a separate file along with the actual e-book file(s)."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:63
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:70
msgid "Comma separated list of formats to save for each book. By default all available formats are saved."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:66
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:73
msgid "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}"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:71
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:78
msgid "The template to control the filename and directory structure of files sent to the device. Default is \"%s\" which will save books into a per-author directory with filenames containing title and author. Available controls are: {%s}"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:78
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:85
msgid "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."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:84
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:91
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:94
msgid "The format in which to display dates. %d - day, %b - month, %Y - year. Default is: %b, %Y"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:87
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:97
msgid "Convert paths to lowercase."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:89
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:99
msgid "Replace whitespace with underscores."
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:263
+#: /home/kovid/work/calibre/src/calibre/library/save_to_disk.py:319
msgid "Requested formats not available"
msgstr ""
@@ -9797,35 +10002,35 @@ msgstr ""
msgid "Specifies a restriction to be used for this invocation. This option overrides any per-library settings specified in the GUI"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:111
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:112
msgid "%d book"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:130
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:131
msgid "%d items"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:147
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:149
msgid "RATING: %s
"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:150
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:152
msgid "TAGS: %s
"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:154
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:157
msgid "SERIES: %s [%s]
"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:249
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:248
msgid "Books in your library"
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:255
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:254
msgid "By "
msgstr ""
-#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:256
+#: /home/kovid/work/calibre/src/calibre/library/server/opds.py:255
msgid "Books sorted by "
msgstr ""
@@ -10007,13 +10212,53 @@ msgid "German (AT)"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/utils/localization.py:115
-msgid "Dutch (NL)"
+msgid "French (BE)"
msgstr ""
#: /home/kovid/work/calibre/src/calibre/utils/localization.py:116
+msgid "Dutch (NL)"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/localization.py:117
msgid "Dutch (BE)"
msgstr ""
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:56
+msgid "Choose theme (needs restart)"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:188
+msgid "No interpreter"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:189
+msgid "No active interpreter found. Try restarting the console"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:203
+msgid "Interpreter died"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/console.py:204
+msgid "Interpreter dies while excuting a command. To see the command, click Show details"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/main.py:20
+msgid "Welcome to"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/main.py:41
+msgid " console "
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/main.py:51
+msgid "Code is running"
+msgstr ""
+
+#: /home/kovid/work/calibre/src/calibre/utils/pyconsole/main.py:58
+msgid "Restart console"
+msgstr ""
+
#: /home/kovid/work/calibre/src/calibre/utils/sftp.py:53
msgid "URL must have the scheme sftp"
msgstr ""
diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py
new file mode 100644
index 0000000000..043d55b34f
--- /dev/null
+++ b/src/calibre/utils/formatter.py
@@ -0,0 +1,149 @@
+'''
+Created on 23 Sep 2010
+
+@author: charles
+'''
+
+import re, string
+
+class TemplateFormatter(string.Formatter):
+ '''
+ Provides a format function that substitutes '' for any missing value
+ '''
+
+ # Dict to do recursion detection. It is up the the individual get_value
+ # method to use it. It is cleared when starting to format a template
+ composite_values = {}
+
+ def __init__(self):
+ string.Formatter.__init__(self)
+ self.book = None
+ self.kwargs = None
+ self.sanitize = None
+
+ def _lookup(self, val, field_if_set, field_not_set):
+ if val:
+ return self.vformat('{'+field_if_set.strip()+'}', [], self.kwargs)
+ else:
+ return self.vformat('{'+field_not_set.strip()+'}', [], self.kwargs)
+
+ def _test(self, val, value_if_set, value_not_set):
+ if val:
+ return value_if_set
+ else:
+ return value_not_set
+
+ def _contains(self, val, test, value_if_present, value_if_not):
+ if re.search(test, val):
+ return value_if_present
+ else:
+ return value_if_not
+
+ def _re(self, val, pattern, replacement):
+ return re.sub(pattern, replacement, val)
+
+ def _ifempty(self, val, value_if_empty):
+ if val:
+ return val
+ else:
+ return value_if_empty
+
+ def _shorten(self, val, leading, center_string, trailing):
+ l = int(leading)
+ t = int(trailing)
+ if len(val) > l + len(center_string) + t:
+ return val[0:l] + center_string + val[-t:]
+ else:
+ return val
+
+ functions = {
+ 'uppercase' : (0, lambda s,x: x.upper()),
+ 'lowercase' : (0, lambda s,x: x.lower()),
+ 'titlecase' : (0, lambda s,x: x.title()),
+ 'capitalize' : (0, lambda s,x: x.capitalize()),
+ 'contains' : (3, _contains),
+ 'ifempty' : (1, _ifempty),
+ 'lookup' : (2, _lookup),
+ 're' : (2, _re),
+ 'shorten' : (3, _shorten),
+ 'test' : (2, _test),
+ }
+
+ format_string_re = re.compile(r'^(.*)\|(.*)\|(.*)$')
+ compress_spaces = re.compile(r'\s+')
+
+ def get_value(self, key, args, kwargs):
+ raise Exception('get_value must be implemented in the subclass')
+
+
+ def _explode_format_string(self, fmt):
+ try:
+ matches = self.format_string_re.match(fmt)
+ if matches is None or matches.lastindex != 3:
+ return fmt, '', ''
+ return matches.groups()
+ except:
+ import traceback
+ traceback.print_exc()
+ return fmt, '', ''
+
+ def format_field(self, val, fmt):
+ # Handle conditional text
+ fmt, prefix, suffix = self._explode_format_string(fmt)
+
+ # Handle functions
+ p = fmt.find('(')
+ dispfmt = fmt
+ if p >= 0 and fmt[-1] == ')':
+ colon = fmt[0:p].find(':')
+ if colon < 0:
+ dispfmt = ''
+ colon = 0
+ else:
+ dispfmt = fmt[0:colon]
+ colon += 1
+ if fmt[colon:p] in self.functions:
+ field = fmt[colon:p]
+ func = self.functions[field]
+ args = fmt[p+1:-1].split(',')
+ 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])
+ if func[0] == 0:
+ val = func[1](self, val)
+ else:
+ val = func[1](self, val, *args)
+ if val:
+ val = string.Formatter.format_field(self, val, dispfmt)
+ if not val:
+ return ''
+ return prefix + val + suffix
+
+ def vformat(self, fmt, args, kwargs):
+ ans = string.Formatter.vformat(self, fmt, args, kwargs)
+ return self.compress_spaces.sub(' ', ans).strip()
+
+ def safe_format(self, fmt, kwargs, error_value, book, sanitize=None):
+ self.kwargs = kwargs
+ self.book = book
+ self.sanitize = sanitize
+ self.composite_values = {}
+ try:
+ ans = self.vformat(fmt, [], kwargs).strip()
+ except:
+ ans = error_value
+ return ans
+
+class ValidateFormat(TemplateFormatter):
+ '''
+ Provides a format function that substitutes '' for any missing value
+ '''
+ def get_value(self, key, args, kwargs):
+ return 'this is some text that should be long enough'
+
+ def validate(self, x):
+ return self.vformat(x, [], {})
+
+validation_formatter = ValidateFormat()
+
+
diff --git a/src/calibre/utils/ipc/launch.py b/src/calibre/utils/ipc/launch.py
index 0de81ed644..e13c9e0cb6 100644
--- a/src/calibre/utils/ipc/launch.py
+++ b/src/calibre/utils/ipc/launch.py
@@ -21,14 +21,16 @@ class Worker(object):
Platform independent object for launching child processes. All processes
have the environment variable :envvar:`CALIBRE_WORKER` set.
- Useful attributes: ``is_alive``, ``returncode``
- usefule methods: ``kill``
+ Useful attributes: ``is_alive``, ``returncode``, ``pid``
+ Useful methods: ``kill``
To launch child simply call the Worker object. By default, the child's
output is redirected to an on disk file, the path to which is returned by
the call.
'''
+ exe_name = 'calibre-parallel'
+
@property
def osx_interpreter(self):
exe = os.path.basename(sys.executable)
@@ -41,32 +43,33 @@ class Worker(object):
@property
def executable(self):
+ e = self.exe_name
if iswindows:
return os.path.join(os.path.dirname(sys.executable),
- 'calibre-parallel.exe' if isfrozen else \
- 'Scripts\\calibre-parallel.exe')
+ e+'.exe' if isfrozen else \
+ 'Scripts\\%s.exe'%e)
if isnewosx:
- return os.path.join(sys.console_binaries_path, 'calibre-parallel')
+ return os.path.join(sys.console_binaries_path, e)
if isosx:
- if not isfrozen: return 'calibre-parallel'
+ if not isfrozen: return e
contents = os.path.join(self.osx_contents_dir,
'console.app', 'Contents')
return os.path.join(contents, 'MacOS', self.osx_interpreter)
if isfrozen:
- return os.path.join(getattr(sys, 'frozen_path'), 'calibre-parallel')
+ return os.path.join(getattr(sys, 'frozen_path'), e)
- c = os.path.join(sys.executables_location, 'calibre-parallel')
+ c = os.path.join(sys.executables_location, e)
if os.access(c, os.X_OK):
return c
- return 'calibre-parallel'
+ return e
@property
def gui_executable(self):
if isnewosx:
- return os.path.join(sys.binaries_path, 'calibre-parallel')
+ return os.path.join(sys.binaries_path, self.exe_name)
if isfrozen and isosx:
return os.path.join(self.osx_contents_dir,
@@ -91,6 +94,11 @@ class Worker(object):
self.child.poll()
return self.child.returncode
+ @property
+ def pid(self):
+ if not hasattr(self, 'child'): return None
+ return getattr(self.child, 'pid', None)
+
def kill(self):
try:
if self.is_alive:
@@ -175,6 +183,12 @@ class Worker(object):
args['stdout'] = _windows_null_file
args['stderr'] = subprocess.STDOUT
+ if not iswindows:
+ # Close inherited file descriptors in worker
+ # On windows, this is done in the worker process
+ # itself
+ args['close_fds'] = True
+
self.child = subprocess.Popen(cmd, **args)
if 'stdin' in args:
self.child.stdin.close()
diff --git a/src/calibre/utils/ipc/worker.py b/src/calibre/utils/ipc/worker.py
index 73233840fe..e3584380a1 100644
--- a/src/calibre/utils/ipc/worker.py
+++ b/src/calibre/utils/ipc/worker.py
@@ -13,6 +13,7 @@ from Queue import Queue
from contextlib import closing
from binascii import unhexlify
from calibre import prints
+from calibre.constants import iswindows, isosx
PARALLEL_FUNCS = {
'lrfviewer' :
@@ -76,12 +77,19 @@ def get_func(name):
return func, notification
def main():
- from calibre.constants import isosx
+ if iswindows:
+ # Close open file descriptors inherited from parent
+ # On Unix this is done by the subprocess module
+ os.closerange(3, 256)
if isosx and 'CALIBRE_WORKER_ADDRESS' not in os.environ:
# On some OS X computers launchd apparently tries to
# launch the last run process from the bundle
+ # so launch the gui as usual
from calibre.gui2.main import main as gui_main
return gui_main(['calibre'])
+ if 'CALIBRE_LAUNCH_INTERPRETER' in os.environ:
+ from calibre.utils.pyconsole.interpreter import main
+ return main()
address = cPickle.loads(unhexlify(os.environ['CALIBRE_WORKER_ADDRESS']))
key = unhexlify(os.environ['CALIBRE_WORKER_KEY'])
resultf = unhexlify(os.environ['CALIBRE_WORKER_RESULT'])
diff --git a/src/calibre/utils/localization.py b/src/calibre/utils/localization.py
index 94f3923acf..2532962bf6 100644
--- a/src/calibre/utils/localization.py
+++ b/src/calibre/utils/localization.py
@@ -112,6 +112,7 @@ _extra_lang_codes = {
'en_CN' : _('English (China)'),
'es_PY' : _('Spanish (Paraguay)'),
'de_AT' : _('German (AT)'),
+ 'fr_BE' : _('French (BE)'),
'nl' : _('Dutch (NL)'),
'nl_BE' : _('Dutch (BE)'),
'und' : _('Unknown')
diff --git a/src/calibre/utils/pyconsole/__init__.py b/src/calibre/utils/pyconsole/__init__.py
new file mode 100644
index 0000000000..6ef9f04d4b
--- /dev/null
+++ b/src/calibre/utils/pyconsole/__init__.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import sys, os
+
+from calibre import prints as prints_, preferred_encoding, isbytestring
+from calibre.utils.config import Config, ConfigProxy, JSONConfig
+from calibre.utils.ipc.launch import Worker
+from calibre.constants import __appname__, __version__, iswindows
+from calibre.gui2 import error_dialog
+
+# Time to wait for communication to/from the interpreter process
+POLL_TIMEOUT = 0.01 # seconds
+
+preferred_encoding, isbytestring, __appname__, __version__, error_dialog, \
+iswindows
+
+def console_config():
+ desc='Settings to control the calibre console'
+ c = Config('console', desc)
+
+ c.add_opt('theme', default='native', help='The color theme')
+ c.add_opt('scrollback', default=10000,
+ help='Max number of lines to keep in the scrollback buffer')
+
+ return c
+
+prefs = ConfigProxy(console_config())
+dynamic = JSONConfig('console')
+
+def prints(*args, **kwargs):
+ kwargs['file'] = sys.__stdout__
+ prints_(*args, **kwargs)
+
+class Process(Worker):
+
+ @property
+ def env(self):
+ env = dict(os.environ)
+ env.update(self._env)
+ return env
+
+
diff --git a/src/calibre/utils/pyconsole/console.py b/src/calibre/utils/pyconsole/console.py
new file mode 100644
index 0000000000..13c22a928f
--- /dev/null
+++ b/src/calibre/utils/pyconsole/console.py
@@ -0,0 +1,519 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import sys, textwrap, traceback, StringIO
+from functools import partial
+from codeop import CommandCompiler
+
+from PyQt4.Qt import QTextEdit, Qt, QTextFrameFormat, pyqtSignal, \
+ QApplication, QColor, QPalette, QMenu, QActionGroup, QTimer
+
+from pygments.lexers import PythonLexer, PythonTracebackLexer
+from pygments.styles import get_all_styles
+
+from calibre.utils.pyconsole.formatter import Formatter
+from calibre.utils.pyconsole.controller import Controller
+from calibre.utils.pyconsole.history import History
+from calibre.utils.pyconsole import prints, prefs, __appname__, \
+ __version__, error_dialog, dynamic
+
+class EditBlock(object): # {{{
+
+ def __init__(self, cursor):
+ self.cursor = cursor
+
+ def __enter__(self):
+ self.cursor.beginEditBlock()
+ return self.cursor
+
+ def __exit__(self, *args):
+ self.cursor.endEditBlock()
+# }}}
+
+class Prepender(object): # {{{
+ 'Helper class to insert output before the current prompt'
+ def __init__(self, console):
+ self.console = console
+
+ def __enter__(self):
+ c = self.console
+ self.opos = c.cursor_pos
+ cur = c.prompt_frame.firstCursorPosition()
+ cur.movePosition(cur.PreviousCharacter)
+ c.setTextCursor(cur)
+
+ def __exit__(self, *args):
+ self.console.cursor_pos = self.opos
+# }}}
+
+class ThemeMenu(QMenu): # {{{
+
+ def __init__(self, parent):
+ QMenu.__init__(self, _('Choose theme (needs restart)'))
+ parent.addMenu(self)
+ self.group = QActionGroup(self)
+ current = prefs['theme']
+ alls = list(sorted(get_all_styles()))
+ if current not in alls:
+ current = prefs['theme'] = 'default'
+ self.actions = []
+ for style in alls:
+ ac = self.group.addAction(style)
+ ac.setCheckable(True)
+ if current == style:
+ ac.setChecked(True)
+ self.actions.append(ac)
+ ac.triggered.connect(partial(self.set_theme, style))
+ self.addAction(ac)
+
+ def set_theme(self, style, *args):
+ prefs['theme'] = style
+
+# }}}
+
+
+class Console(QTextEdit):
+
+ running = pyqtSignal()
+ running_done = pyqtSignal()
+
+ @property
+ def doc(self):
+ return self.document()
+
+ @property
+ def cursor(self):
+ return self.textCursor()
+
+ @property
+ def root_frame(self):
+ return self.doc.rootFrame()
+
+ def unhandled_exception(self, type, value, tb):
+ if type == KeyboardInterrupt:
+ return
+ try:
+ sio = StringIO.StringIO()
+ traceback.print_exception(type, value, tb, file=sio)
+ fe = sio.getvalue()
+ prints(fe)
+ try:
+ val = unicode(value)
+ except:
+ val = repr(value)
+ msg = '%s:'%type.__name__ + val
+ error_dialog(self, _('ERROR: Unhandled exception'), msg,
+ det_msg=fe, show=True)
+ except BaseException:
+ pass
+
+ def __init__(self,
+ prompt='>>> ',
+ continuation='... ',
+ parent=None):
+ QTextEdit.__init__(self, parent)
+ self.shutting_down = False
+ self.compiler = CommandCompiler()
+ self.buf = self.old_buf = []
+ self.history = History([''], dynamic.get('console_history', []))
+ self.prompt_frame = None
+ self.allow_output = False
+ self.prompt_frame_format = QTextFrameFormat()
+ self.prompt_frame_format.setBorder(1)
+ self.prompt_frame_format.setBorderStyle(QTextFrameFormat.BorderStyle_Solid)
+ self.prompt_len = len(prompt)
+
+ self.doc.setMaximumBlockCount(int(prefs['scrollback']))
+ self.lexer = PythonLexer(ensurenl=False)
+ self.tb_lexer = PythonTracebackLexer()
+
+ self.context_menu = cm = QMenu(self) # {{{
+ cm.theme = ThemeMenu(cm)
+ # }}}
+
+ self.formatter = Formatter(prompt, continuation, style=prefs['theme'])
+ p = QPalette()
+ p.setColor(p.Base, QColor(self.formatter.background_color))
+ p.setColor(p.Text, QColor(self.formatter.color))
+ self.setPalette(p)
+
+ self.key_dispatcher = { # {{{
+ Qt.Key_Enter : self.enter_pressed,
+ Qt.Key_Return : self.enter_pressed,
+ Qt.Key_Up : self.up_pressed,
+ Qt.Key_Down : self.down_pressed,
+ Qt.Key_Home : self.home_pressed,
+ Qt.Key_End : self.end_pressed,
+ Qt.Key_Left : self.left_pressed,
+ Qt.Key_Right : self.right_pressed,
+ Qt.Key_Backspace : self.backspace_pressed,
+ Qt.Key_Delete : self.delete_pressed,
+ } # }}}
+
+ motd = textwrap.dedent('''\
+ # Python {0}
+ # {1} {2}
+ '''.format(sys.version.splitlines()[0], __appname__,
+ __version__))
+
+ sys.excepthook = self.unhandled_exception
+
+ self.controllers = []
+ QTimer.singleShot(0, self.launch_controller)
+
+
+ with EditBlock(self.cursor):
+ self.render_block(motd)
+
+ def shutdown(self):
+ dynamic.set('console_history', self.history.serialize())
+ self.shutting_down = True
+ for c in self.controllers:
+ c.kill()
+
+ def contextMenuEvent(self, event):
+ self.context_menu.popup(event.globalPos())
+ event.accept()
+
+ # Controller management {{{
+ @property
+ def controller(self):
+ return self.controllers[-1]
+
+ def no_controller_error(self):
+ error_dialog(self, _('No interpreter'),
+ _('No active interpreter found. Try restarting the'
+ ' console'), show=True)
+
+ def launch_controller(self, *args):
+ c = Controller(self)
+ c.write_output.connect(self.show_output, type=Qt.QueuedConnection)
+ c.show_error.connect(self.show_error, type=Qt.QueuedConnection)
+ c.interpreter_died.connect(self.interpreter_died,
+ type=Qt.QueuedConnection)
+ c.interpreter_done.connect(self.execution_done)
+ self.controllers.append(c)
+
+ def interpreter_died(self, controller, returncode):
+ if not self.shutting_down and controller.current_command is not None:
+ error_dialog(self, _('Interpreter died'),
+ _('Interpreter dies while excuting a command. To see '
+ 'the command, click Show details'),
+ det_msg=controller.current_command, show=True)
+
+ def execute(self, prompt_lines):
+ c = self.root_frame.lastCursorPosition()
+ self.setTextCursor(c)
+ self.old_prompt_frame = self.prompt_frame
+ self.prompt_frame = None
+ self.old_buf = self.buf
+ self.buf = []
+ self.running.emit()
+ self.controller.runsource('\n'.join(prompt_lines))
+
+ def execution_done(self, controller, ret):
+ if controller is self.controller:
+ self.running_done.emit()
+ if ret: # Incomplete command
+ self.buf = self.old_buf
+ self.prompt_frame = self.old_prompt_frame
+ c = self.prompt_frame.lastCursorPosition()
+ c.insertBlock()
+ self.setTextCursor(c)
+ else: # Command completed
+ try:
+ self.old_prompt_frame.setFrameFormat(QTextFrameFormat())
+ except RuntimeError:
+ # Happens if enough lines of output that the old
+ # frame was deleted
+ pass
+
+ self.render_current_prompt()
+
+ # }}}
+
+ # Prompt management {{{
+
+ @dynamic_property
+ def cursor_pos(self):
+ doc = '''
+ The cursor position in the prompt has the form (row, col).
+ row starts at 0 for the first line
+ col is 0 if the cursor is at the start of the line, 1 if it is after
+ the first character, n if it is after the nth char.
+ '''
+
+ def fget(self):
+ if self.prompt_frame is not None:
+ pos = self.cursor.position()
+ it = self.prompt_frame.begin()
+ lineno = 0
+ while not it.atEnd():
+ bl = it.currentBlock()
+ if bl.contains(pos):
+ return (lineno, pos - bl.position())
+ it += 1
+ lineno += 1
+ return (-1, -1)
+
+ def fset(self, val):
+ row, col = val
+ if self.prompt_frame is not None:
+ it = self.prompt_frame.begin()
+ lineno = 0
+ while not it.atEnd():
+ if lineno == row:
+ c = self.cursor
+ c.setPosition(it.currentBlock().position())
+ c.movePosition(c.NextCharacter, n=col)
+ self.setTextCursor(c)
+ break
+ it += 1
+ lineno += 1
+
+ return property(fget=fget, fset=fset, doc=doc)
+
+ def move_cursor_to_prompt(self):
+ if self.prompt_frame is not None and self.cursor_pos[0] < 0:
+ c = self.prompt_frame.lastCursorPosition()
+ self.setTextCursor(c)
+
+ def prompt(self, strip_prompt_strings=True):
+ if not self.prompt_frame:
+ yield u'' if strip_prompt_strings else self.formatter.prompt
+ else:
+ it = self.prompt_frame.begin()
+ while not it.atEnd():
+ bl = it.currentBlock()
+ t = unicode(bl.text())
+ if strip_prompt_strings:
+ t = t[self.prompt_len:]
+ yield t
+ it += 1
+
+ def set_prompt(self, lines):
+ self.render_current_prompt(lines)
+
+ def clear_current_prompt(self):
+ if self.prompt_frame is None:
+ c = self.root_frame.lastCursorPosition()
+ self.prompt_frame = c.insertFrame(self.prompt_frame_format)
+ self.setTextCursor(c)
+ else:
+ c = self.prompt_frame.firstCursorPosition()
+ self.setTextCursor(c)
+ c.setPosition(self.prompt_frame.lastPosition(), c.KeepAnchor)
+ c.removeSelectedText()
+ c.setPosition(self.prompt_frame.firstPosition())
+
+ def render_current_prompt(self, lines=None, restore_cursor=False):
+ row, col = self.cursor_pos
+ cp = list(self.prompt()) if lines is None else lines
+ self.clear_current_prompt()
+
+ for i, line in enumerate(cp):
+ start = i == 0
+ end = i == len(cp) - 1
+ self.formatter.render_prompt(not start, self.cursor)
+ self.formatter.render(self.lexer.get_tokens(line), self.cursor)
+ if not end:
+ self.cursor.insertBlock()
+
+ if row > -1 and restore_cursor:
+ self.cursor_pos = (row, col)
+
+ self.ensureCursorVisible()
+
+ # }}}
+
+ # Non-prompt Rendering {{{
+
+ def render_block(self, text, restore_prompt=True):
+ self.formatter.render(self.lexer.get_tokens(text), self.cursor)
+ self.cursor.insertBlock()
+ self.cursor.movePosition(self.cursor.End)
+ if restore_prompt:
+ self.render_current_prompt()
+
+ def show_error(self, is_syntax_err, tb, controller=None):
+ if self.prompt_frame is not None:
+ # At a prompt, so redirect output
+ return prints(tb, end='')
+ try:
+ self.buf.append(tb)
+ if is_syntax_err:
+ self.formatter.render_syntax_error(tb, self.cursor)
+ else:
+ self.formatter.render(self.tb_lexer.get_tokens(tb), self.cursor)
+ except:
+ prints(tb, end='')
+ self.ensureCursorVisible()
+ QApplication.processEvents()
+
+ def show_output(self, raw, which='stdout', controller=None):
+ def do_show():
+ try:
+ self.buf.append(raw)
+ self.formatter.render_raw(raw, self.cursor)
+ except:
+ import traceback
+ prints(traceback.format_exc())
+ prints(raw, end='')
+
+ if self.prompt_frame is not None:
+ with Prepender(self):
+ do_show()
+ else:
+ do_show()
+ self.ensureCursorVisible()
+ QApplication.processEvents()
+
+ # }}}
+
+ # Keyboard management {{{
+
+ def keyPressEvent(self, ev):
+ text = unicode(ev.text())
+ key = ev.key()
+ action = self.key_dispatcher.get(key, None)
+ if callable(action):
+ action()
+ elif key in (Qt.Key_Escape,):
+ QTextEdit.keyPressEvent(self, ev)
+ elif text:
+ self.text_typed(text)
+ else:
+ QTextEdit.keyPressEvent(self, ev)
+
+ def left_pressed(self):
+ lineno, pos = self.cursor_pos
+ if lineno < 0: return
+ if pos > self.prompt_len:
+ c = self.cursor
+ c.movePosition(c.PreviousCharacter)
+ self.setTextCursor(c)
+ elif lineno > 0:
+ c = self.cursor
+ c.movePosition(c.Up)
+ c.movePosition(c.EndOfLine)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+ def up_pressed(self):
+ lineno, pos = self.cursor_pos
+ if lineno < 0: return
+ if lineno == 0:
+ b = self.history.back()
+ if b is not None:
+ self.set_prompt(b)
+ else:
+ c = self.cursor
+ c.movePosition(c.Up)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+
+ def backspace_pressed(self):
+ lineno, pos = self.cursor_pos
+ if lineno < 0: return
+ if pos > self.prompt_len:
+ self.cursor.deletePreviousChar()
+ elif lineno > 0:
+ c = self.cursor
+ c.movePosition(c.Up)
+ c.movePosition(c.EndOfLine)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+ def delete_pressed(self):
+ self.cursor.deleteChar()
+ self.ensureCursorVisible()
+
+ def right_pressed(self):
+ lineno, pos = self.cursor_pos
+ if lineno < 0: return
+ c = self.cursor
+ cp = list(self.prompt(False))
+ if pos < len(cp[lineno]):
+ c.movePosition(c.NextCharacter)
+ elif lineno < len(cp)-1:
+ c.movePosition(c.NextCharacter, n=1+self.prompt_len)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+ def down_pressed(self):
+ lineno, pos = self.cursor_pos
+ if lineno < 0: return
+ c = self.cursor
+ cp = list(self.prompt(False))
+ if lineno >= len(cp) - 1:
+ b = self.history.forward()
+ if b is not None:
+ self.set_prompt(b)
+ else:
+ c = self.cursor
+ c.movePosition(c.Down)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+
+ def home_pressed(self):
+ if self.prompt_frame is not None:
+ mods = QApplication.keyboardModifiers()
+ ctrl = bool(int(mods & Qt.CTRL))
+ if ctrl:
+ self.cursor_pos = (0, self.prompt_len)
+ else:
+ c = self.cursor
+ c.movePosition(c.StartOfLine)
+ c.movePosition(c.NextCharacter, n=self.prompt_len)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+ def end_pressed(self):
+ if self.prompt_frame is not None:
+ mods = QApplication.keyboardModifiers()
+ ctrl = bool(int(mods & Qt.CTRL))
+ if ctrl:
+ self.cursor_pos = (len(list(self.prompt()))-1, self.prompt_len)
+ c = self.cursor
+ c.movePosition(c.EndOfLine)
+ self.setTextCursor(c)
+ self.ensureCursorVisible()
+
+ def enter_pressed(self):
+ if self.prompt_frame is None:
+ return
+ if not self.controller.is_alive:
+ return self.no_controller_error()
+ cp = list(self.prompt())
+ if cp[0]:
+ try:
+ ret = self.compiler('\n'.join(cp))
+ except:
+ pass
+ else:
+ if ret is None:
+ c = self.prompt_frame.lastCursorPosition()
+ c.insertBlock()
+ self.setTextCursor(c)
+ self.render_current_prompt()
+ return
+ else:
+ self.history.enter(cp)
+ self.execute(cp)
+
+ def text_typed(self, text):
+ if self.prompt_frame is not None:
+ self.move_cursor_to_prompt()
+ self.cursor.insertText(text)
+ self.render_current_prompt(restore_cursor=True)
+ self.history.current = list(self.prompt())
+
+ # }}}
+
+
diff --git a/src/calibre/utils/pyconsole/controller.py b/src/calibre/utils/pyconsole/controller.py
new file mode 100644
index 0000000000..d372cb4ebc
--- /dev/null
+++ b/src/calibre/utils/pyconsole/controller.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import os, cPickle, signal, time
+from Queue import Queue, Empty
+from multiprocessing.connection import Listener, arbitrary_address
+from binascii import hexlify
+
+from PyQt4.Qt import QThread, pyqtSignal
+
+from calibre.utils.pyconsole import Process, iswindows, POLL_TIMEOUT
+
+class Controller(QThread):
+
+ # show_error(is_syntax_error, traceback, self)
+ show_error = pyqtSignal(object, object, object)
+ # write_output(unicode_object, stdout or stderr, self)
+ write_output = pyqtSignal(object, object, object)
+ # Indicates interpreter has finished evaluating current command
+ interpreter_done = pyqtSignal(object, object)
+ # interpreter_died(self, returncode or None if no return code available)
+ interpreter_died = pyqtSignal(object, object)
+
+ def __init__(self, parent):
+ QThread.__init__(self, parent)
+ self.keep_going = True
+ self.current_command = None
+
+ self.out_queue = Queue()
+ self.address = arbitrary_address('AF_PIPE' if iswindows else 'AF_UNIX')
+ self.auth_key = os.urandom(32)
+ if iswindows and self.address[1] == ':':
+ self.address = self.address[2:]
+ self.listener = Listener(address=self.address,
+ authkey=self.auth_key, backlog=4)
+
+ self.env = {
+ 'CALIBRE_LAUNCH_INTERPRETER': '1',
+ 'CALIBRE_WORKER_ADDRESS':
+ hexlify(cPickle.dumps(self.listener.address, -1)),
+ 'CALIBRE_WORKER_KEY': hexlify(self.auth_key)
+ }
+ self.process = Process(self.env)
+ self.output_file_buf = self.process(redirect_output=False)
+ self.conn = self.listener.accept()
+ self.start()
+
+ def run(self):
+ while self.keep_going and self.is_alive:
+ try:
+ self.communicate()
+ except KeyboardInterrupt:
+ pass
+ except EOFError:
+ break
+ self.interpreter_died.emit(self, self.returncode)
+ try:
+ self.listener.close()
+ except:
+ pass
+
+ def communicate(self):
+ if self.conn.poll(POLL_TIMEOUT):
+ self.dispatch_incoming_message(self.conn.recv())
+ try:
+ obj = self.out_queue.get_nowait()
+ except Empty:
+ pass
+ else:
+ try:
+ self.conn.send(obj)
+ except:
+ raise EOFError('controller failed to send')
+
+ def dispatch_incoming_message(self, obj):
+ try:
+ cmd, data = obj
+ except:
+ print 'Controller received invalid message'
+ print repr(obj)
+ return
+ if cmd in ('stdout', 'stderr'):
+ self.write_output.emit(data, cmd, self)
+ elif cmd == 'syntaxerror':
+ self.show_error.emit(True, data, self)
+ elif cmd == 'traceback':
+ self.show_error.emit(False, data, self)
+ elif cmd == 'done':
+ self.current_command = None
+ self.interpreter_done.emit(self, data)
+
+ def runsource(self, cmd):
+ self.current_command = cmd
+ self.out_queue.put(('run', cmd))
+
+ def __nonzero__(self):
+ return self.process.is_alive
+
+ @property
+ def returncode(self):
+ return self.process.returncode
+
+ def interrupt(self):
+ if hasattr(signal, 'SIGINT'):
+ os.kill(self.process.pid, signal.SIGINT)
+ elif hasattr(signal, 'CTRL_C_EVENT'):
+ os.kill(self.process.pid, signal.CTRL_C_EVENT)
+
+ @property
+ def is_alive(self):
+ return self.process.is_alive
+
+ def kill(self):
+ self.out_queue.put(('quit', 0))
+ t = 0
+ while self.is_alive and t < 10:
+ time.sleep(0.1)
+ self.process.kill()
+ self.keep_going = False
+
diff --git a/src/calibre/utils/pyconsole/formatter.py b/src/calibre/utils/pyconsole/formatter.py
new file mode 100644
index 0000000000..17360fecb3
--- /dev/null
+++ b/src/calibre/utils/pyconsole/formatter.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+from PyQt4.Qt import QTextCharFormat, QFont, QBrush, QColor
+
+from pygments.formatter import Formatter as PF
+from pygments.token import Token, Generic, string_to_tokentype
+
+class Formatter(object):
+
+ def __init__(self, prompt, continuation, style='default'):
+ if len(prompt) != len(continuation):
+ raise ValueError('%r does not have the same length as %r' %
+ (prompt, continuation))
+
+ self.prompt, self.continuation = prompt, continuation
+ self.set_style(style)
+
+ def set_style(self, style):
+ pf = PF(style=style)
+ self.styles = {}
+ self.normal = self.base_fmt()
+ self.background_color = pf.style.background_color
+ self.color = 'black'
+
+ for ttype, ndef in pf.style:
+ fmt = self.base_fmt()
+ fmt.setProperty(fmt.UserProperty, str(ttype))
+ if ndef['color']:
+ fmt.setForeground(QBrush(QColor('#%s'%ndef['color'])))
+ fmt.setUnderlineColor(QColor('#%s'%ndef['color']))
+ if ttype == Generic.Output:
+ self.color = '#%s'%ndef['color']
+ if ndef['bold']:
+ fmt.setFontWeight(QFont.Bold)
+ if ndef['italic']:
+ fmt.setFontItalic(True)
+ if ndef['underline']:
+ fmt.setFontUnderline(True)
+ if ndef['bgcolor']:
+ fmt.setBackground(QBrush(QColor('#%s'%ndef['bgcolor'])))
+ if ndef['border']:
+ pass # No support for borders
+
+ self.styles[ttype] = fmt
+
+ def get_fmt(self, token):
+ if type(token) != type(Token.Generic):
+ token = string_to_tokentype(token)
+ fmt = self.styles.get(token, None)
+ if fmt is None:
+ fmt = self.base_fmt()
+ fmt.setProperty(fmt.UserProperty, str(token))
+ return fmt
+
+ def base_fmt(self):
+ fmt = QTextCharFormat()
+ fmt.setFontFamily('monospace')
+ return fmt
+
+ def render_raw(self, raw, cursor):
+ cursor.insertText(raw, self.normal)
+
+ def render_syntax_error(self, tb, cursor):
+ fmt = self.get_fmt(Token.Error)
+ cursor.insertText(tb, fmt)
+
+ def render(self, tokens, cursor):
+ lastval = ''
+ lasttype = None
+
+ for ttype, value in tokens:
+ while ttype not in self.styles:
+ ttype = ttype.parent
+ if ttype == lasttype:
+ lastval += value
+ else:
+ if lastval:
+ fmt = self.styles[lasttype]
+ cursor.insertText(lastval, fmt)
+ lastval = value
+ lasttype = ttype
+
+ if lastval:
+ fmt = self.styles[lasttype]
+ cursor.insertText(lastval, fmt)
+
+ def render_prompt(self, is_continuation, cursor):
+ pr = self.continuation if is_continuation else self.prompt
+ fmt = self.get_fmt(Generic.Prompt)
+ if fmt is None:
+ fmt = self.base_fmt()
+ cursor.insertText(pr, fmt)
+
+
diff --git a/src/calibre/utils/pyconsole/history.py b/src/calibre/utils/pyconsole/history.py
new file mode 100644
index 0000000000..5440e57153
--- /dev/null
+++ b/src/calibre/utils/pyconsole/history.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+from collections import deque
+
+class History(object): # {{{
+
+ def __init__(self, current, entries):
+ self.entries = deque(entries, maxlen=max(2000, len(entries)))
+ self.index = len(self.entries) - 1
+ self.current = self.default = current
+ self.last_was_back = False
+
+ def back(self, amt=1):
+ if self.entries:
+ oidx = self.index
+ ans = self.entries[self.index]
+ self.index = max(0, self.index - amt)
+ self.last_was_back = self.index != oidx
+ return ans
+
+ def forward(self, amt=1):
+ if self.entries:
+ d = self.index
+ if self.last_was_back:
+ d += 1
+ if d >= len(self.entries) - 1:
+ self.index = len(self.entries) - 1
+ self.last_was_back = False
+ return self.current
+ if self.last_was_back:
+ amt += 1
+ self.index = min(len(self.entries)-1, self.index + amt)
+ self.last_was_back = False
+ return self.entries[self.index]
+
+ def enter(self, x):
+ try:
+ self.entries.remove(x)
+ except ValueError:
+ pass
+ self.entries.append(x)
+ self.index = len(self.entries) - 1
+ self.current = self.default
+ self.last_was_back = False
+
+ def serialize(self):
+ return list(self.entries)
+
+# }}}
+
+
diff --git a/src/calibre/utils/pyconsole/interpreter.py b/src/calibre/utils/pyconsole/interpreter.py
new file mode 100644
index 0000000000..3cd0d94711
--- /dev/null
+++ b/src/calibre/utils/pyconsole/interpreter.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import sys, cPickle, os
+from code import InteractiveInterpreter
+from Queue import Queue, Empty
+from threading import Thread
+from binascii import unhexlify
+from multiprocessing.connection import Client
+from repr import repr as safe_repr
+
+from calibre.utils.pyconsole import preferred_encoding, isbytestring, \
+ POLL_TIMEOUT
+
+'''
+Messages sent by client:
+
+ (stdout, unicode)
+ (stderr, unicode)
+ (syntaxerror, unicode)
+ (traceback, unicode)
+ (done, True iff incomplete command)
+
+Messages that can be received by client:
+ (quit, return code)
+ (run, unicode)
+
+'''
+
+def tounicode(raw): # {{{
+ if isbytestring(raw):
+ try:
+ raw = raw.decode(preferred_encoding, 'replace')
+ except:
+ raw = safe_repr(raw)
+
+ if isbytestring(raw):
+ try:
+ raw.decode('utf-8', 'replace')
+ except:
+ raw = u'Undecodable bytestring'
+ return raw
+# }}}
+
+class DummyFile(object): # {{{
+
+ def __init__(self, what, out_queue):
+ self.closed = False
+ self.name = 'console'
+ self.softspace = 0
+ self.what = what
+ self.out_queue = out_queue
+
+ def flush(self):
+ pass
+
+ def close(self):
+ pass
+
+ def write(self, raw):
+ self.out_queue.put((self.what, tounicode(raw)))
+# }}}
+
+class Comm(Thread): # {{{
+
+ def __init__(self, conn, out_queue, in_queue):
+ Thread.__init__(self)
+ self.daemon = True
+ self.conn = conn
+ self.out_queue = out_queue
+ self.in_queue = in_queue
+ self.keep_going = True
+
+ def run(self):
+ while self.keep_going:
+ try:
+ self.communicate()
+ except KeyboardInterrupt:
+ pass
+ except EOFError:
+ pass
+
+ def communicate(self):
+ if self.conn.poll(POLL_TIMEOUT):
+ try:
+ obj = self.conn.recv()
+ except:
+ pass
+ else:
+ self.in_queue.put(obj)
+ try:
+ obj = self.out_queue.get_nowait()
+ except Empty:
+ pass
+ else:
+ try:
+ self.conn.send(obj)
+ except:
+ raise EOFError('interpreter failed to send')
+# }}}
+
+class Interpreter(InteractiveInterpreter): # {{{
+
+ def __init__(self, queue, local={}):
+ if '__name__' not in local:
+ local['__name__'] = '__console__'
+ if '__doc__' not in local:
+ local['__doc__'] = None
+ self.out_queue = queue
+ sys.stdout = DummyFile('stdout', queue)
+ sys.stderr = DummyFile('sdterr', queue)
+ InteractiveInterpreter.__init__(self, locals=local)
+
+ def showtraceback(self, *args, **kwargs):
+ self.is_syntax_error = False
+ InteractiveInterpreter.showtraceback(self, *args, **kwargs)
+
+ def showsyntaxerror(self, *args, **kwargs):
+ self.is_syntax_error = True
+ InteractiveInterpreter.showsyntaxerror(self, *args, **kwargs)
+
+ def write(self, raw):
+ what = 'syntaxerror' if self.is_syntax_error else 'traceback'
+ self.out_queue.put((what, tounicode(raw)))
+
+# }}}
+
+def connect():
+ os.chdir(os.environ['ORIGWD'])
+ address = cPickle.loads(unhexlify(os.environ['CALIBRE_WORKER_ADDRESS']))
+ key = unhexlify(os.environ['CALIBRE_WORKER_KEY'])
+ return Client(address, authkey=key)
+
+def main():
+ out_queue = Queue()
+ in_queue = Queue()
+ conn = connect()
+ comm = Comm(conn, out_queue, in_queue)
+ comm.start()
+ interpreter = Interpreter(out_queue)
+
+ ret = 0
+
+ while True:
+ try:
+ try:
+ cmd, data = in_queue.get(1)
+ except Empty:
+ pass
+ else:
+ if cmd == 'quit':
+ ret = data
+ comm.keep_going = False
+ comm.join()
+ break
+ elif cmd == 'run':
+ if not comm.is_alive():
+ ret = 1
+ break
+ ret = False
+ try:
+ ret = interpreter.runsource(data)
+ except KeyboardInterrupt:
+ pass
+ except SystemExit:
+ out_queue.put(('stderr', 'SystemExit ignored\n'))
+ out_queue.put(('done', ret))
+ except KeyboardInterrupt:
+ pass
+
+ return ret
+
+if __name__ == '__main__':
+ main()
diff --git a/src/calibre/utils/pyconsole/main.py b/src/calibre/utils/pyconsole/main.py
new file mode 100644
index 0000000000..664f41ef2e
--- /dev/null
+++ b/src/calibre/utils/pyconsole/main.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+__version__ = '0.1.0'
+
+from functools import partial
+
+from PyQt4.Qt import QDialog, QToolBar, QStatusBar, QLabel, QFont, Qt, \
+ QApplication, QIcon, QVBoxLayout, QAction
+
+from calibre.utils.pyconsole import dynamic, __appname__, __version__
+from calibre.utils.pyconsole.console import Console
+
+class MainWindow(QDialog):
+
+ def __init__(self,
+ default_status_msg=_('Welcome to') + ' ' + __appname__+' console',
+ parent=None):
+ QDialog.__init__(self, parent)
+
+ self.restart_requested = False
+ self.l = QVBoxLayout()
+ self.setLayout(self.l)
+
+ self.resize(800, 600)
+ geom = dynamic.get('console_window_geometry', None)
+ if geom is not None:
+ self.restoreGeometry(geom)
+
+ # Setup tool bar {{{
+ self.tool_bar = QToolBar(self)
+ self.tool_bar.setToolButtonStyle(Qt.ToolButtonTextOnly)
+ self.l.addWidget(self.tool_bar)
+ # }}}
+
+ # Setup status bar {{{
+ self.status_bar = QStatusBar(self)
+ self.status_bar.defmsg = QLabel(__appname__ + _(' console ') +
+ __version__)
+ self.status_bar._font = QFont()
+ self.status_bar._font.setBold(True)
+ self.status_bar.defmsg.setFont(self.status_bar._font)
+ self.status_bar.addWidget(self.status_bar.defmsg)
+ # }}}
+
+ self.console = Console(parent=self)
+ self.console.running.connect(partial(self.status_bar.showMessage,
+ _('Code is running')))
+ self.console.running_done.connect(self.status_bar.clearMessage)
+ self.l.addWidget(self.console)
+ self.l.addWidget(self.status_bar)
+ self.setWindowTitle(__appname__ + ' console')
+ self.setWindowIcon(QIcon(I('console.png')))
+
+ self.restart_action = QAction(_('Restart console'), self)
+ self.restart_action.setShortcut(_('Ctrl+R'))
+ self.addAction(self.restart_action)
+ self.restart_action.triggered.connect(self.restart)
+ self.console.context_menu.addAction(self.restart_action)
+
+ def restart(self):
+ self.restart_requested = True
+ self.reject()
+
+ def closeEvent(self, *args):
+ dynamic.set('console_window_geometry',
+ bytearray(self.saveGeometry()))
+ self.console.shutdown()
+ return QDialog.closeEvent(self, *args)
+
+
+def show():
+ while True:
+ m = MainWindow()
+ m.exec_()
+ if not m.restart_requested:
+ break
+
+def main():
+ QApplication.setApplicationName(__appname__+' console')
+ QApplication.setOrganizationName('Kovid Goyal')
+ app = QApplication([])
+ app
+ show()
+
+if __name__ == '__main__':
+ main()
+
diff --git a/src/calibre/utils/zipfile.py b/src/calibre/utils/zipfile.py
index 8f22b5f9e2..dbcc125274 100644
--- a/src/calibre/utils/zipfile.py
+++ b/src/calibre/utils/zipfile.py
@@ -1147,28 +1147,27 @@ class ZipFile:
self._writecheck(zinfo)
self._didModify = True
- fp = open(filename, "rb")
- # Must overwrite CRC and sizes with correct data later
- zinfo.CRC = CRC = 0
- zinfo.compress_size = compress_size = 0
- zinfo.file_size = file_size = 0
- self.fp.write(zinfo.FileHeader())
- if zinfo.compress_type == ZIP_DEFLATED:
- cmpr = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION,
- zlib.DEFLATED, -15)
- else:
- cmpr = None
- while 1:
- buf = fp.read(1024 * 8)
- if not buf:
- break
- file_size = file_size + len(buf)
- CRC = crc32(buf, CRC) & 0xffffffff
- if cmpr:
- buf = cmpr.compress(buf)
- compress_size = compress_size + len(buf)
- self.fp.write(buf)
- fp.close()
+ with open(filename, "rb") as fp:
+ # Must overwrite CRC and sizes with correct data later
+ zinfo.CRC = CRC = 0
+ zinfo.compress_size = compress_size = 0
+ zinfo.file_size = file_size = 0
+ self.fp.write(zinfo.FileHeader())
+ if zinfo.compress_type == ZIP_DEFLATED:
+ cmpr = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION,
+ zlib.DEFLATED, -15)
+ else:
+ cmpr = None
+ while 1:
+ buf = fp.read(1024 * 8)
+ if not buf:
+ break
+ file_size = file_size + len(buf)
+ CRC = crc32(buf, CRC) & 0xffffffff
+ if cmpr:
+ buf = cmpr.compress(buf)
+ compress_size = compress_size + len(buf)
+ self.fp.write(buf)
if cmpr:
buf = cmpr.flush()
compress_size = compress_size + len(buf)