diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index f9d0309003..69762eaa91 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -5,17 +5,22 @@ 22 May 2010 ''' -import datetime, re, sys, time +import cStringIO, datetime, os, re, shutil, sys, time +from calibre import fit_image from calibre.constants import isosx, iswindows from calibre.devices.interface import DevicePlugin from calibre.ebooks.metadata import MetaInformation +from calibre.library.server.utils import strftime from calibre.utils.config import Config from calibre.utils.date import parse_date +from PIL import Image as PILImage + + if isosx: print "running in OSX" - import appscript + import appscript, osax if iswindows: print "running in Windows" @@ -58,15 +63,19 @@ class ITUNES(DevicePlugin): # Properties cached_books = {} iTunes= None - needs_update = False path_template = 'iTunes/%s - %s.epub' + presync = True + purge_list = None sources = None + update_msg = None + update_needed = False + use_thumbnail_as_cover = False verbose = True # Public methods - def add_books_to_metadata(cls, locations, metadata, booklists): + def add_books_to_metadata(self, locations, metadata, booklists): ''' Add locations to the booklists. This function must not communicate with the device. @@ -78,19 +87,22 @@ class ITUNES(DevicePlugin): L{books}(oncard='cardb')). ''' print "ITUNES.add_books_to_metadata()" - if locations: - for location in locations: - print " location: %s" % location - print "metadata:" - for md in metadata: - print md - print + self._dump_booklist(booklists[0]) + # Delete any obsolete copies of the book from the booklist + if self.purge_list: + if self.verbose: + print " purging updated books" + for library_id in self.purge_list: + for i,book in enumerate(booklists[0]): + if book.library_id == library_id: + booklists[0].pop(i) + self.purge_list = [] - print "booklists[0]:" - for book in booklists[0]: - print " book: '%s'" % book.path - print + # Add new books to booklists[0] + for new_book in locations[0]: + booklists[0].append(new_book) + self._dump_booklist(booklists[0]) def books(self, oncard=None, end_session=True): """ @@ -124,20 +136,23 @@ class ITUNES(DevicePlugin): device_books = self._get_device_books() for book in device_books: this_book = Book(book.name(), book.artist()) + this_book.path = self.path_template % (book.name(), book.artist()) this_book.datetime = parse_date(str(book.date_added())).timetuple() this_book.db_id = None this_book.device_collections = [] - this_book.path = self.path_template % (book.name(), book.artist()) + this_book.library_id = library_books[this_book.path] if this_book.path in library_books else None this_book.size = book.size() - this_book.thumbnail = None - cached_books[this_book.path] = { 'title':book.name(), - 'author':book.artist(), - 'lib_book':library_books[this_book.path] if this_book.path in library_books else None, - 'dev_book':book, - 'bl_index':len(booklist) - } + this_book.thumbnail = self._generate_thumbnail(book) + booklist.add_book(this_book, False) + cached_books[this_book.path] = { + 'title':book.name(), + 'author':book.artist(), + 'lib_book':library_books[this_book.path] if this_book.path in library_books else None + } + + if self.verbose: print print "%-40.40s %-12.12s" % ('Device Books','In Library') @@ -151,10 +166,6 @@ class ITUNES(DevicePlugin): else: # No books installed on this device return [] - - - - else: return [] @@ -239,14 +250,13 @@ class ITUNES(DevicePlugin): undeletable_titles = [] for path in paths: if self.cached_books[path]['lib_book']: - title = self.cached_books[path]['title'] - author = self.cached_books[path]['author'] - dev_book = self.cached_books[path]['dev_book'] - lib_book = self.cached_books[path]['lib_book'] if self.verbose: print "ITUNES:delete_books(): Deleting '%s' from iTunes library" % (path) - self.iTunes.delete(lib_book) - self.needs_update = True + self._remove_iTunes_dir(self.cached_books[path]) + self.iTunes.delete(self.cached_books[path]['lib_book']) + self.update_needed = True + self.update_msg = "Deleted books from device" + else: undeletable_titles.append(self.cached_books[path]['title']) @@ -337,7 +347,7 @@ class ITUNES(DevicePlugin): self.sources = sources = dict(zip(kinds,names)) # Check to see if Library|Books out of sync with Device|Books - if 'iPod' in self.sources: + if 'iPod' in self.sources and self.presync: lb_count = len(self._get_library_books()) db_count = len(self._get_device_books()) pb_count = len(self._get_purchased_book_ids()) @@ -348,6 +358,9 @@ class ITUNES(DevicePlugin): print " Devices|iPad|Books : %d" % len(self._get_device_books()) print " Devices|iPad|Purchased: %d" % len(self._get_purchased_book_ids()) self._update_device(msg="Presyncing iTunes with device, mismatched book count") + else: + if self.verbose: + print "Skipping pre-sync check" def post_yank_cleanup(self): ''' @@ -367,13 +380,19 @@ class ITUNES(DevicePlugin): print "ITUNES.remove_books_from_metadata():" for path in paths: if self.cached_books[path]['lib_book']: - print " Removing '%s' from calibre booklist, index: %d" % (path, self.cached_books[path]['bl_index']) - booklists[0].pop(self.cached_books[path]['bl_index']) + # Remove from the booklist + for i,book in enumerate(booklists[0]): + if book.path == path: + print " removing '%s' from calibre booklist, index: %d" % (path, i) + booklists[0].pop(i) + break + # Remove from cached_books print " Removing '%s' from self.cached_books" % path self.cached_books.pop(path) + else: - print " Skipping non-Library book, can't removed via automation interface" + print " skipping purchased book, can't remove via automation interface" def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None) : @@ -424,9 +443,9 @@ class ITUNES(DevicePlugin): L{books}(oncard='cardb')). ''' print "ITUNES:sync_booklists():" - if self.needs_update: - self._update_device(msg="sync_booklists responding to self.needs_update") - self.needs_update = False + if self.update_needed: + self._update_device(msg=self.update_msg) + self.update_needed = False def total_space(self, end_session=True): """ @@ -469,19 +488,20 @@ class ITUNES(DevicePlugin): be used in preference. The thumbnail attribute is of the form (width, height, cover_data as jpeg). ''' - if self.verbose: + if False: print print "ITUNES.upload_books():" for file in files: - print "file: %s" % file + print " file: %s" % file print print "names:" for name in names: - print "name: %s" % name + print " name: %s" % name print print "metadata:" + print dir(metadata[0]) for md in metadata: print " title: %s" % md.title print " title_sort: %s" % md.title_sort @@ -496,19 +516,108 @@ class ITUNES(DevicePlugin): print print + #print "thumbnail: width: %d height: %d" % (metadata[0].thumbnail[0], metadata[0].thumbnail[1]) + #self._hexdump(metadata[0].thumbnail[2]) + + new_booklist = [] + self.purge_list = [] + if isosx: + for (i,file) in enumerate(files): path = self.path_template % (metadata[i].title, metadata[i].author[0]) - print " path: %s" % path - if path in self.cached_books: - print " '%s' already exists in Library" % path - #delete_book, do not sync - else: - print " adding '%s' to Library" % path - # return (list of added books, [], []) + # Delete existing from Library|Books, add to self.purge_list + # for deletion from booklist[0] during add_books_to_metadata + if path in self.cached_books: + self.purge_list.append(self.cached_books[path]) + + if self.verbose: + print " deleting existing '%s' at\n %s" % (path,self.cached_books[path]['lib_book']) + self._remove_iTunes_dir(self.cached_books[path]) + self.iTunes.delete(self.cached_books[path]['lib_book']) + + # Add to iTunes Library|Books + added = self.iTunes.add(appscript.mactypes.File(files[i])) + + thumb = None + if self.use_thumbnail_as_cover: + # Use thumbnail data as artwork + added.artworks[1].data_.set(metadata[i].thumbnail[2]) + thumb = metadata[i].thumbnail[2] + else: + # Use cover data as artwork + cover_data = open(metadata[i].cover,'rb') + added.artworks[1].data_.set(cover_data.read()) + + # Resize for thumb + width = metadata[i].thumbnail[0] + height = metadata[i].thumbnail[1] + im = PILImage.open(metadata[i].cover) + im = im.resize((width, height), PILImage.ANTIALIAS) + of = cStringIO.StringIO() + im.convert('RGB').save(of, 'JPEG') + thumb = of.getvalue() + + + # Create a new Book + this_book = Book(metadata[i].title, metadata[i].author[0]) + this_book.datetime = parse_date(str(added.date_added())).timetuple() + this_book.db_id = None + this_book.device_collections = [] + this_book.library_id = added + this_book.path = path + this_book.size = added.size() # GwR this is wrong, needs to come from device or fake it + this_book.thumbnail = thumb + this_book.iTunes_id = added + + new_booklist.append(this_book) + + # Flesh out the iTunes metadata + added.comment.set("added by calibre %s" % strftime('%Y-%m-%d %H:%M:%S')) + added.rating.set(metadata[i].rating*10) + added.sort_artist.set(metadata[i].author_sort) + added.sort_name.set(this_book.title_sorter) + # Set genre from metadata + # iTunes grabs the first dc:subject from the opf metadata, + # But we can manually override + # added.genre.set(metadata[i].tags[0]) + + # Add new_book to self.cached_paths + self.cached_books[this_book.path] = { + 'title': this_book.title, + 'author': this_book.author, + 'lib_book': this_book.library_id + } + + + # Tell sync_booklists we need a re-sync + self.update_needed = True + self.update_msg = "Added books to device" + + return (new_booklist, [], []) # Private methods + def _dump_booklist(self,booklist, header="booklists[0]"): + print + print header + print "%s" % ('-' * len(header)) + for i,book in enumerate(booklist): + print "%2d %-25.25s %s" % (i,book.title, book.library_id) + print + + def _hexdump(self, src, length=16): + # Diagnostic + FILTER=''.join([(len(repr(chr(x)))==3) and chr(x) or '.' for x in range(256)]) + N=0; result='' + while src: + s,src = src[:length],src[length:] + hexa = ' '.join(["%02X"%ord(x) for x in s]) + s = s.translate(FILTER) + result += "%04X %-*s %s\n" % (N, length*3, hexa, s) + N+=length + print result + def _get_library_books(self): lib = self.iTunes.sources['library'] library_books = {} @@ -526,6 +635,41 @@ class ITUNES(DevicePlugin): if 'Books' in self.iTunes.sources[device].playlists.name(): return self.iTunes.sources[device].playlists['Books'].file_tracks() + def _generate_thumbnail(self, book): + ''' + Convert iTunes artwork to thumbnail + Cache generated thumbnails + ''' + print "ITUNES._generate_thumbnail()" + + try: + n = len(book.artworks()) + print "Library '%s' has %d artwork items" % (book.name(),n) +# for art in book.artworks(): +# print "description: %s" % art.description() +# if str(art.description()) == 'calibre_thumb': +# print "using cached thumb" +# return art.raw_data().data + + + # Resize the cover + data = book.artworks[1].raw_data().data + #self._hexdump(data[:256]) + im = PILImage.open(cStringIO.StringIO(data)) + scaled, width, height = fit_image(im.size[0],im.size[1], 60, 80) + im = im.resize((int(width),int(height)), PILImage.ANTIALIAS) + thumb = cStringIO.StringIO() + im.convert('RGB').save(thumb,'JPEG') + + # Cache the tagged thumb +# print "caching thumb" +# book.artworks[n+1].data_.set(thumb.getvalue()) +# book.artworks[n+1].description.set(u'calibre_thumb') + return thumb.getvalue() + except: + print "Can't generate thumb for '%s'" % book.name() + return None + def _get_purchased_book_ids(self): if 'iPod' in self.sources: device = self.sources['iPod'] @@ -533,6 +677,16 @@ class ITUNES(DevicePlugin): if 'Purchased' in self.iTunes.sources[device].playlists.name(): return [pb.database_ID() for pb in self.iTunes.sources[device].playlists['Purchased'].file_tracks()] + def _remove_iTunes_dir(self, cached_book): + ''' + iTunes does not delete books from storage when removing from database + ''' + storage_path = os.path.split(cached_book['lib_book'].location().path) + if self.verbose: + print "ITUNES._remove_iTunes_dir():" + print " removing storage_path: %s" % storage_path[0] + shutil.rmtree(storage_path[0]) + def _update_device(self, msg='', wait=True): ''' diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 0edf08c405..39fb1920cd 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -89,11 +89,17 @@ CALIBRE_METADATA_FIELDS = frozenset([ ) CALIBRE_RESERVED_LABELS = frozenset([ - 'search', # reserved for saved searches - 'date', - 'all', - 'ondevice', - 'inlibrary', + 'all', # search term + 'date', # search term + 'formats', # search term + 'inlibrary', # search term + 'news', # search term + 'ondevice', # search term + 'search', # search term + 'format', # The next four are here for backwards compatibility + 'tag', # with searching. The terms can be used without the + 'author', # trailing 's'. + 'comment', # Sigh ... ] ) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index bc0367b766..18af6d8560 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -631,7 +631,10 @@ class BooksModel(QAbstractTableModel): # {{{ if section >= len(self.column_map): # same problem as in data, the column_map can be wrong return None if role == Qt.ToolTipRole: - return QVariant(_('The lookup/search name is "{0}"').format(self.column_map[section])) + ht = self.column_map[section] + if ht == 'timestamp': # change help text because users know this field as 'date' + ht = 'date' + return QVariant(_('The lookup/search name is "{0}"').format(ht)) if role == Qt.DisplayRole: return QVariant(self.headers[self.column_map[section]]) return NONE @@ -730,11 +733,13 @@ class BooksModel(QAbstractTableModel): # {{{ class OnDeviceSearch(SearchQueryParser): # {{{ USABLE_LOCATIONS = [ - 'collections', - 'title', - 'author', - 'format', 'all', + 'author', + 'authors', + 'collections', + 'format', + 'formats', + 'title', ] diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 6d1bf5ab28..8bbdc69c62 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -14,8 +14,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QAbstractItemModel, QVariant, QModelIndex from calibre.gui2 import config, NONE from calibre.utils.config import prefs -from calibre.utils.search_query_parser import saved_searches -from calibre.library.database2 import Tag +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS class TagsView(QTreeView): # {{{ @@ -204,17 +203,22 @@ class TagsModel(QAbstractItemModel): # {{{ QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication - # before a QPaintDevice' - self.category_icon_map = {'authors': QIcon(I('user_profile.svg')), - 'series': QIcon(I('series.svg')), - 'formats':QIcon(I('book.svg')), - 'publishers': QIcon(I('publisher.png')), - 'ratings':QIcon(I('star.png')), - 'news':QIcon(I('news.svg')), - 'tags':QIcon(I('tags.svg')), - '*custom':QIcon(I('column.svg')), - '*user':QIcon(I('drawer.svg')), - 'search':QIcon(I('search.svg'))} + # before a QPaintDevice'. The ':' in front avoids polluting either the + # user-defined categories (':' at end) or columns namespaces (no ':'). + self.category_icon_map = { + 'authors' : QIcon(I('user_profile.svg')), + 'series' : QIcon(I('series.svg')), + 'formats' : QIcon(I('book.svg')), + 'publisher' : QIcon(I('publisher.png')), + 'rating' : QIcon(I('star.png')), + 'news' : QIcon(I('news.svg')), + 'tags' : QIcon(I('tags.svg')), + ':custom' : QIcon(I('column.svg')), + ':user' : QIcon(I('drawer.svg')), + 'search' : QIcon(I('search.svg'))} + for k in self.category_icon_map.keys(): + if not k.startswith(':') and k not in RESERVED_METADATA_FIELDS: + raise ValueError('Tag category [%s] is not a reserved word.' %(k)) self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db self.search_restriction = '' @@ -381,9 +385,9 @@ class TagsModel(QAbstractItemModel): # {{{ def tokens(self): ans = [] - tags_seen = [] + tags_seen = set() for i, key in enumerate(self.row_map): - if key.endswith('*'): # User category, so skip it. The tag will be marked in its real category + if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category continue category_item = self.root_item.children[i] for tag_item in category_item.children: @@ -394,10 +398,10 @@ class TagsModel(QAbstractItemModel): # {{{ if tag.name[0] == u'\u2605': # char is a star. Assume rating ans.append('%s%s:%s'%(prefix, category, len(tag.name))) else: - if category == 'tag': + if category == 'tags': if tag.name in tags_seen: continue - tags_seen.append(tag.name) + tags_seen.add(tag.name) ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) return ans diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 8278386b8e..84124b6ce9 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -37,6 +37,7 @@ from calibre.utils.ordered_dict import OrderedDict from calibre.utils.config import prefs from calibre.utils.search_query_parser import saved_searches from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS if iswindows: import calibre.utils.winshell as winshell @@ -137,10 +138,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ('formats', {'table':None, 'column':None, 'type':None, 'is_multiple':False, 'kind':'standard', 'name':_('Formats')}), - ('publishers',{'table':'publishers', 'column':'name', + ('publisher', {'table':'publishers', 'column':'name', 'type':'text', 'is_multiple':False, 'kind':'standard', 'name':_('Publishers')}), - ('ratings', {'table':'ratings', 'column':'rating', + ('rating', {'table':'ratings', 'column':'rating', 'type':'rating', 'is_multiple':False, 'kind':'standard', 'name':_('Ratings')}), ('news', {'table':'news', 'column':'name', @@ -152,6 +153,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ] self.tag_browser_categories = OrderedDict() for k,v in tag_browser_categories_items: + if k not in RESERVED_METADATA_FIELDS: + raise ValueError('Tag category [%s] is not a reserved word.' %(k)) self.tag_browser_categories[k] = v self.connect() @@ -694,25 +697,25 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if category in icon_map: icon = icon_map[category] elif self.tag_browser_categories[category]['kind'] == 'custom': - icon = icon_map['*custom'] - icon_map[category] = icon_map['*custom'] + icon = icon_map[':custom'] + icon_map[category] = icon tooltip = self.custom_column_label_map[category]['name'] datatype = self.tag_browser_categories[category]['type'] if datatype == 'rating': - item_zero_func = (lambda x: len(formatter(r[1])) > 0) + item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0) formatter = (lambda x:u'\u2605'*int(round(x/2.))) elif category == 'authors': - item_zero_func = (lambda x: x[2] > 0) + item_not_zero_func = (lambda x: x[2] > 0) # Clean up the authors strings to human-readable form formatter = (lambda x: x.replace('|', ',')) else: - item_zero_func = (lambda x: x[2] > 0) + item_not_zero_func = (lambda x: x[2] > 0) formatter = (lambda x:x) categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip) - for r in data if item_zero_func(r)] + for r in data if item_not_zero_func(r)] # We delayed computing the standard formats category because it does not # use a view, but is computed dynamically @@ -767,14 +770,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): items.append(taglist[label][name]) # else: do nothing, to not include nodes w zero counts if len(items): - cat_name = user_cat+'*' # add the * to avoid name collision + cat_name = user_cat+':' # add the ':' to avoid name collision self.tag_browser_categories[cat_name] = { 'table':None, 'column':None, 'type':None, 'is_multiple':False, 'kind':'user', 'name':user_cat} # Not a problem if we accumulate entries in the icon map if icon_map is not None: - icon_map[cat_name] = icon_map['*user'] + icon_map[cat_name] = icon_map[':user'] if sort_on_count: categories[cat_name] = \ sorted(items, cmp=(lambda x, y: cmp(y.count, x.count))) diff --git a/src/calibre/library/server/cache.py b/src/calibre/library/server/cache.py index 5c9be367d0..f8de28a735 100644 --- a/src/calibre/library/server/cache.py +++ b/src/calibre/library/server/cache.py @@ -17,10 +17,14 @@ class Cache(object): def search_cache(self, search): old = self._search_cache.get(search, None) if old is None or old[0] <= self.db.last_modified(): - matches = self.db.data.search(search) - self._search_cache[search] = frozenset(matches) + matches = self.db.data.search(search, return_matches=True, + ignore_search_restriction=True) + if not matches: + matches = [] + self._search_cache[search] = (utcnow(), frozenset(matches)) if len(self._search_cache) > 10: self._search_cache.popitem(last=False) + return self._search_cache[search][1] def categories_cache(self, restrict_to=frozenset([])): diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py index d6702cbe75..149f12644c 100644 --- a/src/calibre/library/server/opds.py +++ b/src/calibre/library/server/opds.py @@ -7,18 +7,24 @@ __docformat__ = 'restructuredtext en' import hashlib, binascii from functools import partial +from itertools import repeat -from lxml import etree +from lxml import etree, html from lxml.builder import ElementMaker import cherrypy from calibre.constants import __appname__ +from calibre.ebooks.metadata import fmt_sidx +from calibre.library.comments import comments_to_html +from calibre import guess_type BASE_HREFS = { 0 : '/stanza', 1 : '/opds', } +STANZA_FORMATS = frozenset(['epub', 'pdb']) + # Vocabulary for building OPDS feeds {{{ E = ElementMaker(namespace='http://www.w3.org/2005/Atom', nsmap={ @@ -71,9 +77,72 @@ LAST_LINK = partial(NAVLINK, rel='last') NEXT_LINK = partial(NAVLINK, rel='next') PREVIOUS_LINK = partial(NAVLINK, rel='previous') +def html_to_lxml(raw): + raw = u'
%s
'%raw + root = html.fragment_fromstring(raw) + root.set('xmlns', "http://www.w3.org/1999/xhtml") + raw = etree.tostring(root, encoding=None) + return etree.fromstring(raw) + +def ACQUISITION_ENTRY(item, version, FM, updated): + title = item[FM['title']] + if not title: + title = _('Unknown') + authors = item[FM['authors']] + if not authors: + authors = _('Unknown') + authors = ' & '.join([i.replace('|', ',') for i in + authors.split(',')]) + extra = [] + rating = item[FM['rating']] + if rating > 0: + rating = u''.join(repeat(u'\u2605', int(rating/2.))) + extra.append(_('RATING: %s
')%rating) + tags = item[FM['tags']] + if tags: + extra.append(_('TAGS: %s
')%\ + ', '.join(tags.split(','))) + series = item[FM['series']] + if series: + extra.append(_('SERIES: %s [%s]
')%\ + (series, + fmt_sidx(float(item[FM['series_index']])))) + comments = item[FM['comments']] + if comments: + comments = comments_to_html(comments) + extra.append(comments) + if extra: + extra = html_to_lxml('\n'.join(extra)) + idm = 'calibre' if version == 0 else 'uuid' + id_ = 'urn:%s:%s'%(idm, item[FM['uuid']]) + ans = E.entry(TITLE(title), E.author(E.name(authors)), ID(id_), + UPDATED(updated)) + if extra: + ans.append(E.content(extra, type='xhtml')) + formats = item[FM['formats']] + if formats: + for fmt in formats.split(','): + fmt = fmt.lower() + mt = guess_type('a.'+fmt)[0] + href = '/get/%s/%s'%(fmt, item[FM['id']]) + if mt: + link = E.link(type=mt, href=href) + if version > 0: + link.set('rel', "http://opds-spec.org/acquisition") + ans.append(link) + ans.append(E.link(type='image/jpeg', href='/get/cover/%s'%item[FM['id']], + rel="x-stanza-cover-image" if version == 0 else + "http://opds-spec.org/cover")) + ans.append(E.link(type='image/jpeg', href='/get/thumb/%s'%item[FM['id']], + rel="x-stanza-cover-image-thumbnail" if version == 0 else + "http://opds-spec.org/thumbnail")) + + return ans + + # }}} -class Feed(object): +class Feed(object): # {{{ def __init__(self, id_, updated, version, subtitle=None, title=__appname__ + ' ' + _('Library'), @@ -106,6 +175,7 @@ class Feed(object): def __str__(self): return etree.tostring(self.root, pretty_print=True, encoding='utf-8', xml_declaration=True) + # }}} class TopLevel(Feed): # {{{ @@ -126,9 +196,9 @@ class TopLevel(Feed): # {{{ self.root.append(x) # }}} -class AcquisitionFeed(Feed): +class NavFeed(Feed): - def __init__(self, updated, id_, items, offsets, page_url, up_url, version): + def __init__(self, id_, updated, version, offsets, page_url, up_url): kwargs = {'up_link': up_url} kwargs['first_link'] = page_url kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset @@ -140,7 +210,14 @@ class AcquisitionFeed(Feed): page_url+'?offset=%d'%offsets.next_offset Feed.__init__(self, id_, updated, version, **kwargs) -STANZA_FORMATS = frozenset(['epub', 'pdb']) +class AcquisitionFeed(NavFeed): + + def __init__(self, updated, id_, items, offsets, page_url, up_url, version, + FM): + NavFeed.__init__(self, id_, updated, version, offsets, page_url, up_url) + for item in items: + self.root.append(ACQUISITION_ENTRY(item, version, FM, updated)) + class OPDSOffsets(object): @@ -176,9 +253,10 @@ class OPDSServer(object): self.opds_search, version=version) def get_opds_allowed_ids_for_version(self, version): - search = '' if version > 0 else ' '.join(['format:='+x for x in + search = '' if version > 0 else ' or '.join(['format:='+x for x in STANZA_FORMATS]) - self.search_cache(search) + ids = self.search_cache(search) + return ids def get_opds_acquisition_feed(self, ids, offset, page_url, up_url, id_, sort_by='title', ascending=True, version=0): @@ -189,7 +267,8 @@ class OPDSServer(object): max_items = self.opts.max_opds_items offsets = OPDSOffsets(offset, max_items, len(items)) items = items[offsets.offset:offsets.next_offset] - return str(AcquisitionFeed(self.db.last_modified(), id_, items, offsets, page_url, up_url, version)) + return str(AcquisitionFeed(self.db.last_modified(), id_, items, offsets, + page_url, up_url, version, self.db.FIELD_MAP)) def opds_search(self, query=None, version=0, offset=0): try: @@ -203,8 +282,9 @@ class OPDSServer(object): ids = self.search_cache(query) except: raise cherrypy.HTTPError(404, 'Search: %r not understood'%query) - return self.get_opds_acquisition_feed(ids, - sort_by='title', version=version) + return self.get_opds_acquisition_feed(ids, offset, '/search/'+query, + BASE_HREFS[version], 'calibre-search:'+query, + version=version) def opds_navcatalog(self, which=None, version=0): version = int(version) diff --git a/src/calibre/utils/search_query_parser.py b/src/calibre/utils/search_query_parser.py index 11991727b7..5fe0a242f8 100644 --- a/src/calibre/utils/search_query_parser.py +++ b/src/calibre/utils/search_query_parser.py @@ -22,7 +22,7 @@ from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppres OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException from calibre.constants import preferred_encoding from calibre.utils.config import prefs - +from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS ''' This class manages access to the preference holding the saved search queries. @@ -87,21 +87,25 @@ class SearchQueryParser(object): ''' DEFAULT_LOCATIONS = [ - 'tag', - 'title', - 'author', + 'all', + 'author', # compatibility + 'authors', + 'comment', # compatibility + 'comments', + 'cover', + 'date', + 'format', # compatibility + 'formats', + 'isbn', + 'ondevice', + 'pubdate', 'publisher', + 'search', 'series', 'rating', - 'cover', - 'comments', - 'format', - 'isbn', - 'search', - 'date', - 'pubdate', - 'ondevice', - 'all', + 'tag', # compatibility + 'tags', + 'title', ] @staticmethod @@ -118,6 +122,9 @@ class SearchQueryParser(object): return failed def __init__(self, locations=None, test=False): + for k in self.DEFAULT_LOCATIONS: + if k not in RESERVED_METADATA_FIELDS: + raise ValueError('Search location [%s] is not a reserved word.' %(k)) if locations is None: locations = self.DEFAULT_LOCATIONS self._tests_failed = False