This commit is contained in:
GRiker 2010-05-27 07:29:28 -06:00
commit 8348c7085d
8 changed files with 375 additions and 112 deletions

View File

@ -5,17 +5,22 @@
22 May 2010 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.constants import isosx, iswindows
from calibre.devices.interface import DevicePlugin from calibre.devices.interface import DevicePlugin
from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.metadata import MetaInformation
from calibre.library.server.utils import strftime
from calibre.utils.config import Config from calibre.utils.config import Config
from calibre.utils.date import parse_date from calibre.utils.date import parse_date
from PIL import Image as PILImage
if isosx: if isosx:
print "running in OSX" print "running in OSX"
import appscript import appscript, osax
if iswindows: if iswindows:
print "running in Windows" print "running in Windows"
@ -58,15 +63,19 @@ class ITUNES(DevicePlugin):
# Properties # Properties
cached_books = {} cached_books = {}
iTunes= None iTunes= None
needs_update = False
path_template = 'iTunes/%s - %s.epub' path_template = 'iTunes/%s - %s.epub'
presync = True
purge_list = None
sources = None sources = None
update_msg = None
update_needed = False
use_thumbnail_as_cover = False
verbose = True verbose = True
# Public methods # 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 Add locations to the booklists. This function must not communicate with
the device. the device.
@ -78,19 +87,22 @@ class ITUNES(DevicePlugin):
L{books}(oncard='cardb')). L{books}(oncard='cardb')).
''' '''
print "ITUNES.add_books_to_metadata()" print "ITUNES.add_books_to_metadata()"
if locations:
for location in locations:
print " location: %s" % location
print "metadata:" self._dump_booklist(booklists[0])
for md in metadata: # Delete any obsolete copies of the book from the booklist
print md if self.purge_list:
print 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]:" # Add new books to booklists[0]
for book in booklists[0]: for new_book in locations[0]:
print " book: '%s'" % book.path booklists[0].append(new_book)
print self._dump_booklist(booklists[0])
def books(self, oncard=None, end_session=True): def books(self, oncard=None, end_session=True):
""" """
@ -124,20 +136,23 @@ class ITUNES(DevicePlugin):
device_books = self._get_device_books() device_books = self._get_device_books()
for book in device_books: for book in device_books:
this_book = Book(book.name(), book.artist()) 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.datetime = parse_date(str(book.date_added())).timetuple()
this_book.db_id = None this_book.db_id = None
this_book.device_collections = [] 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.size = book.size()
this_book.thumbnail = None this_book.thumbnail = self._generate_thumbnail(book)
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)
}
booklist.add_book(this_book, False) 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: if self.verbose:
print print
print "%-40.40s %-12.12s" % ('Device Books','In Library') print "%-40.40s %-12.12s" % ('Device Books','In Library')
@ -151,10 +166,6 @@ class ITUNES(DevicePlugin):
else: else:
# No books installed on this device # No books installed on this device
return [] return []
else: else:
return [] return []
@ -239,14 +250,13 @@ class ITUNES(DevicePlugin):
undeletable_titles = [] undeletable_titles = []
for path in paths: for path in paths:
if self.cached_books[path]['lib_book']: 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: if self.verbose:
print "ITUNES:delete_books(): Deleting '%s' from iTunes library" % (path) print "ITUNES:delete_books(): Deleting '%s' from iTunes library" % (path)
self.iTunes.delete(lib_book) self._remove_iTunes_dir(self.cached_books[path])
self.needs_update = True self.iTunes.delete(self.cached_books[path]['lib_book'])
self.update_needed = True
self.update_msg = "Deleted books from device"
else: else:
undeletable_titles.append(self.cached_books[path]['title']) undeletable_titles.append(self.cached_books[path]['title'])
@ -337,7 +347,7 @@ class ITUNES(DevicePlugin):
self.sources = sources = dict(zip(kinds,names)) self.sources = sources = dict(zip(kinds,names))
# Check to see if Library|Books out of sync with Device|Books # 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()) lb_count = len(self._get_library_books())
db_count = len(self._get_device_books()) db_count = len(self._get_device_books())
pb_count = len(self._get_purchased_book_ids()) 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|Books : %d" % len(self._get_device_books())
print " Devices|iPad|Purchased: %d" % len(self._get_purchased_book_ids()) print " Devices|iPad|Purchased: %d" % len(self._get_purchased_book_ids())
self._update_device(msg="Presyncing iTunes with device, mismatched book count") 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): def post_yank_cleanup(self):
''' '''
@ -367,13 +380,19 @@ class ITUNES(DevicePlugin):
print "ITUNES.remove_books_from_metadata():" print "ITUNES.remove_books_from_metadata():"
for path in paths: for path in paths:
if self.cached_books[path]['lib_book']: if self.cached_books[path]['lib_book']:
print " Removing '%s' from calibre booklist, index: %d" % (path, self.cached_books[path]['bl_index']) # Remove from the booklist
booklists[0].pop(self.cached_books[path]['bl_index']) 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 print " Removing '%s' from self.cached_books" % path
self.cached_books.pop(path) self.cached_books.pop(path)
else: 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, def reset(self, key='-1', log_packets=False, report_progress=None,
detected_device=None) : detected_device=None) :
@ -424,9 +443,9 @@ class ITUNES(DevicePlugin):
L{books}(oncard='cardb')). L{books}(oncard='cardb')).
''' '''
print "ITUNES:sync_booklists():" print "ITUNES:sync_booklists():"
if self.needs_update: if self.update_needed:
self._update_device(msg="sync_booklists responding to self.needs_update") self._update_device(msg=self.update_msg)
self.needs_update = False self.update_needed = False
def total_space(self, end_session=True): 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 be used in preference. The thumbnail attribute is of the form
(width, height, cover_data as jpeg). (width, height, cover_data as jpeg).
''' '''
if self.verbose: if False:
print print
print "ITUNES.upload_books():" print "ITUNES.upload_books():"
for file in files: for file in files:
print "file: %s" % file print " file: %s" % file
print print
print "names:" print "names:"
for name in names: for name in names:
print "name: %s" % name print " name: %s" % name
print print
print "metadata:" print "metadata:"
print dir(metadata[0])
for md in metadata: for md in metadata:
print " title: %s" % md.title print " title: %s" % md.title
print " title_sort: %s" % md.title_sort print " title_sort: %s" % md.title_sort
@ -496,19 +516,108 @@ class ITUNES(DevicePlugin):
print print
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: if isosx:
for (i,file) in enumerate(files): for (i,file) in enumerate(files):
path = self.path_template % (metadata[i].title, metadata[i].author[0]) 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 # 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): def _get_library_books(self):
lib = self.iTunes.sources['library'] lib = self.iTunes.sources['library']
library_books = {} library_books = {}
@ -526,6 +635,41 @@ class ITUNES(DevicePlugin):
if 'Books' in self.iTunes.sources[device].playlists.name(): if 'Books' in self.iTunes.sources[device].playlists.name():
return self.iTunes.sources[device].playlists['Books'].file_tracks() 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): def _get_purchased_book_ids(self):
if 'iPod' in self.sources: if 'iPod' in self.sources:
device = self.sources['iPod'] device = self.sources['iPod']
@ -533,6 +677,16 @@ class ITUNES(DevicePlugin):
if 'Purchased' in self.iTunes.sources[device].playlists.name(): if 'Purchased' in self.iTunes.sources[device].playlists.name():
return [pb.database_ID() for pb in self.iTunes.sources[device].playlists['Purchased'].file_tracks()] 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): def _update_device(self, msg='', wait=True):
''' '''

View File

@ -89,11 +89,17 @@ CALIBRE_METADATA_FIELDS = frozenset([
) )
CALIBRE_RESERVED_LABELS = frozenset([ CALIBRE_RESERVED_LABELS = frozenset([
'search', # reserved for saved searches 'all', # search term
'date', 'date', # search term
'all', 'formats', # search term
'ondevice', 'inlibrary', # search term
'inlibrary', '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 ...
] ]
) )

View File

@ -631,7 +631,10 @@ class BooksModel(QAbstractTableModel): # {{{
if section >= len(self.column_map): # same problem as in data, the column_map can be wrong if section >= len(self.column_map): # same problem as in data, the column_map can be wrong
return None return None
if role == Qt.ToolTipRole: 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: if role == Qt.DisplayRole:
return QVariant(self.headers[self.column_map[section]]) return QVariant(self.headers[self.column_map[section]])
return NONE return NONE
@ -730,11 +733,13 @@ class BooksModel(QAbstractTableModel): # {{{
class OnDeviceSearch(SearchQueryParser): # {{{ class OnDeviceSearch(SearchQueryParser): # {{{
USABLE_LOCATIONS = [ USABLE_LOCATIONS = [
'collections',
'title',
'author',
'format',
'all', 'all',
'author',
'authors',
'collections',
'format',
'formats',
'title',
] ]

View File

@ -14,8 +14,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
QAbstractItemModel, QVariant, QModelIndex QAbstractItemModel, QVariant, QModelIndex
from calibre.gui2 import config, NONE from calibre.gui2 import config, NONE
from calibre.utils.config import prefs from calibre.utils.config import prefs
from calibre.utils.search_query_parser import saved_searches from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
from calibre.library.database2 import Tag
class TagsView(QTreeView): # {{{ class TagsView(QTreeView): # {{{
@ -204,17 +203,22 @@ class TagsModel(QAbstractItemModel): # {{{
QAbstractItemModel.__init__(self, parent) QAbstractItemModel.__init__(self, parent)
# must do this here because 'QPixmap: Must construct a QApplication # must do this here because 'QPixmap: Must construct a QApplication
# before a QPaintDevice' # before a QPaintDevice'. The ':' in front avoids polluting either the
self.category_icon_map = {'authors': QIcon(I('user_profile.svg')), # user-defined categories (':' at end) or columns namespaces (no ':').
'series': QIcon(I('series.svg')), self.category_icon_map = {
'formats':QIcon(I('book.svg')), 'authors' : QIcon(I('user_profile.svg')),
'publishers': QIcon(I('publisher.png')), 'series' : QIcon(I('series.svg')),
'ratings':QIcon(I('star.png')), 'formats' : QIcon(I('book.svg')),
'news':QIcon(I('news.svg')), 'publisher' : QIcon(I('publisher.png')),
'tags':QIcon(I('tags.svg')), 'rating' : QIcon(I('star.png')),
'*custom':QIcon(I('column.svg')), 'news' : QIcon(I('news.svg')),
'*user':QIcon(I('drawer.svg')), 'tags' : QIcon(I('tags.svg')),
'search':QIcon(I('search.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.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))]
self.db = db self.db = db
self.search_restriction = '' self.search_restriction = ''
@ -381,9 +385,9 @@ class TagsModel(QAbstractItemModel): # {{{
def tokens(self): def tokens(self):
ans = [] ans = []
tags_seen = [] tags_seen = set()
for i, key in enumerate(self.row_map): 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 continue
category_item = self.root_item.children[i] category_item = self.root_item.children[i]
for tag_item in category_item.children: 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 if tag.name[0] == u'\u2605': # char is a star. Assume rating
ans.append('%s%s:%s'%(prefix, category, len(tag.name))) ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
else: else:
if category == 'tag': if category == 'tags':
if tag.name in tags_seen: if tag.name in tags_seen:
continue continue
tags_seen.append(tag.name) tags_seen.add(tag.name)
ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
return ans return ans

View File

@ -37,6 +37,7 @@ from calibre.utils.ordered_dict import OrderedDict
from calibre.utils.config import prefs from calibre.utils.config import prefs
from calibre.utils.search_query_parser import saved_searches from calibre.utils.search_query_parser import saved_searches
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
from calibre.ebooks.metadata.book import RESERVED_METADATA_FIELDS
if iswindows: if iswindows:
import calibre.utils.winshell as winshell import calibre.utils.winshell as winshell
@ -137,10 +138,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
('formats', {'table':None, 'column':None, ('formats', {'table':None, 'column':None,
'type':None, 'is_multiple':False, 'type':None, 'is_multiple':False,
'kind':'standard', 'name':_('Formats')}), 'kind':'standard', 'name':_('Formats')}),
('publishers',{'table':'publishers', 'column':'name', ('publisher', {'table':'publishers', 'column':'name',
'type':'text', 'is_multiple':False, 'type':'text', 'is_multiple':False,
'kind':'standard', 'name':_('Publishers')}), 'kind':'standard', 'name':_('Publishers')}),
('ratings', {'table':'ratings', 'column':'rating', ('rating', {'table':'ratings', 'column':'rating',
'type':'rating', 'is_multiple':False, 'type':'rating', 'is_multiple':False,
'kind':'standard', 'name':_('Ratings')}), 'kind':'standard', 'name':_('Ratings')}),
('news', {'table':'news', 'column':'name', ('news', {'table':'news', 'column':'name',
@ -152,6 +153,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
] ]
self.tag_browser_categories = OrderedDict() self.tag_browser_categories = OrderedDict()
for k,v in tag_browser_categories_items: 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.tag_browser_categories[k] = v
self.connect() self.connect()
@ -694,25 +697,25 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if category in icon_map: if category in icon_map:
icon = icon_map[category] icon = icon_map[category]
elif self.tag_browser_categories[category]['kind'] == 'custom': elif self.tag_browser_categories[category]['kind'] == 'custom':
icon = icon_map['*custom'] icon = icon_map[':custom']
icon_map[category] = icon_map['*custom'] icon_map[category] = icon
tooltip = self.custom_column_label_map[category]['name'] tooltip = self.custom_column_label_map[category]['name']
datatype = self.tag_browser_categories[category]['type'] datatype = self.tag_browser_categories[category]['type']
if datatype == 'rating': 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.))) formatter = (lambda x:u'\u2605'*int(round(x/2.)))
elif category == 'authors': 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 # Clean up the authors strings to human-readable form
formatter = (lambda x: x.replace('|', ',')) formatter = (lambda x: x.replace('|', ','))
else: else:
item_zero_func = (lambda x: x[2] > 0) item_not_zero_func = (lambda x: x[2] > 0)
formatter = (lambda x:x) formatter = (lambda x:x)
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
icon=icon, tooltip = tooltip) 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 # We delayed computing the standard formats category because it does not
# use a view, but is computed dynamically # use a view, but is computed dynamically
@ -767,14 +770,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
items.append(taglist[label][name]) items.append(taglist[label][name])
# else: do nothing, to not include nodes w zero counts # else: do nothing, to not include nodes w zero counts
if len(items): 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] = { self.tag_browser_categories[cat_name] = {
'table':None, 'column':None, 'table':None, 'column':None,
'type':None, 'is_multiple':False, 'type':None, 'is_multiple':False,
'kind':'user', 'name':user_cat} 'kind':'user', 'name':user_cat}
# Not a problem if we accumulate entries in the icon map # Not a problem if we accumulate entries in the icon map
if icon_map is not None: if icon_map is not None:
icon_map[cat_name] = icon_map['*user'] icon_map[cat_name] = icon_map[':user']
if sort_on_count: if sort_on_count:
categories[cat_name] = \ categories[cat_name] = \
sorted(items, cmp=(lambda x, y: cmp(y.count, x.count))) sorted(items, cmp=(lambda x, y: cmp(y.count, x.count)))

View File

@ -17,10 +17,14 @@ class Cache(object):
def search_cache(self, search): def search_cache(self, search):
old = self._search_cache.get(search, None) old = self._search_cache.get(search, None)
if old is None or old[0] <= self.db.last_modified(): if old is None or old[0] <= self.db.last_modified():
matches = self.db.data.search(search) matches = self.db.data.search(search, return_matches=True,
self._search_cache[search] = frozenset(matches) ignore_search_restriction=True)
if not matches:
matches = []
self._search_cache[search] = (utcnow(), frozenset(matches))
if len(self._search_cache) > 10: if len(self._search_cache) > 10:
self._search_cache.popitem(last=False) self._search_cache.popitem(last=False)
return self._search_cache[search][1]
def categories_cache(self, restrict_to=frozenset([])): def categories_cache(self, restrict_to=frozenset([])):

View File

@ -7,18 +7,24 @@ __docformat__ = 'restructuredtext en'
import hashlib, binascii import hashlib, binascii
from functools import partial from functools import partial
from itertools import repeat
from lxml import etree from lxml import etree, html
from lxml.builder import ElementMaker from lxml.builder import ElementMaker
import cherrypy import cherrypy
from calibre.constants import __appname__ 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 = { BASE_HREFS = {
0 : '/stanza', 0 : '/stanza',
1 : '/opds', 1 : '/opds',
} }
STANZA_FORMATS = frozenset(['epub', 'pdb'])
# Vocabulary for building OPDS feeds {{{ # Vocabulary for building OPDS feeds {{{
E = ElementMaker(namespace='http://www.w3.org/2005/Atom', E = ElementMaker(namespace='http://www.w3.org/2005/Atom',
nsmap={ nsmap={
@ -71,9 +77,72 @@ LAST_LINK = partial(NAVLINK, rel='last')
NEXT_LINK = partial(NAVLINK, rel='next') NEXT_LINK = partial(NAVLINK, rel='next')
PREVIOUS_LINK = partial(NAVLINK, rel='previous') PREVIOUS_LINK = partial(NAVLINK, rel='previous')
def html_to_lxml(raw):
raw = u'<div>%s</div>'%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<br />')%rating)
tags = item[FM['tags']]
if tags:
extra.append(_('TAGS: %s<br />')%\
', '.join(tags.split(',')))
series = item[FM['series']]
if series:
extra.append(_('SERIES: %s [%s]<br />')%\
(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, def __init__(self, id_, updated, version, subtitle=None,
title=__appname__ + ' ' + _('Library'), title=__appname__ + ' ' + _('Library'),
@ -106,6 +175,7 @@ class Feed(object):
def __str__(self): def __str__(self):
return etree.tostring(self.root, pretty_print=True, encoding='utf-8', return etree.tostring(self.root, pretty_print=True, encoding='utf-8',
xml_declaration=True) xml_declaration=True)
# }}}
class TopLevel(Feed): # {{{ class TopLevel(Feed): # {{{
@ -126,9 +196,9 @@ class TopLevel(Feed): # {{{
self.root.append(x) 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 = {'up_link': up_url}
kwargs['first_link'] = page_url kwargs['first_link'] = page_url
kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset kwargs['last_link'] = page_url+'?offset=%d'%offsets.last_offset
@ -140,7 +210,14 @@ class AcquisitionFeed(Feed):
page_url+'?offset=%d'%offsets.next_offset page_url+'?offset=%d'%offsets.next_offset
Feed.__init__(self, id_, updated, version, **kwargs) 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): class OPDSOffsets(object):
@ -176,9 +253,10 @@ class OPDSServer(object):
self.opds_search, version=version) self.opds_search, version=version)
def get_opds_allowed_ids_for_version(self, 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]) 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_, def get_opds_acquisition_feed(self, ids, offset, page_url, up_url, id_,
sort_by='title', ascending=True, version=0): sort_by='title', ascending=True, version=0):
@ -189,7 +267,8 @@ class OPDSServer(object):
max_items = self.opts.max_opds_items max_items = self.opts.max_opds_items
offsets = OPDSOffsets(offset, max_items, len(items)) offsets = OPDSOffsets(offset, max_items, len(items))
items = items[offsets.offset:offsets.next_offset] 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): def opds_search(self, query=None, version=0, offset=0):
try: try:
@ -203,8 +282,9 @@ class OPDSServer(object):
ids = self.search_cache(query) ids = self.search_cache(query)
except: except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%query) raise cherrypy.HTTPError(404, 'Search: %r not understood'%query)
return self.get_opds_acquisition_feed(ids, return self.get_opds_acquisition_feed(ids, offset, '/search/'+query,
sort_by='title', version=version) BASE_HREFS[version], 'calibre-search:'+query,
version=version)
def opds_navcatalog(self, which=None, version=0): def opds_navcatalog(self, which=None, version=0):
version = int(version) version = int(version)

View File

@ -22,7 +22,7 @@ from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppres
OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException
from calibre.constants import preferred_encoding from calibre.constants import preferred_encoding
from calibre.utils.config import prefs 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. This class manages access to the preference holding the saved search queries.
@ -87,21 +87,25 @@ class SearchQueryParser(object):
''' '''
DEFAULT_LOCATIONS = [ DEFAULT_LOCATIONS = [
'tag', 'all',
'title', 'author', # compatibility
'author', 'authors',
'comment', # compatibility
'comments',
'cover',
'date',
'format', # compatibility
'formats',
'isbn',
'ondevice',
'pubdate',
'publisher', 'publisher',
'search',
'series', 'series',
'rating', 'rating',
'cover', 'tag', # compatibility
'comments', 'tags',
'format', 'title',
'isbn',
'search',
'date',
'pubdate',
'ondevice',
'all',
] ]
@staticmethod @staticmethod
@ -118,6 +122,9 @@ class SearchQueryParser(object):
return failed return failed
def __init__(self, locations=None, test=False): 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: if locations is None:
locations = self.DEFAULT_LOCATIONS locations = self.DEFAULT_LOCATIONS
self._tests_failed = False self._tests_failed = False