Add tooltips to top level tag browser items. Move tag vategory management to db2

This commit is contained in:
Kovid Goyal 2010-05-24 12:12:59 -06:00
commit 8a488566c1
9 changed files with 316 additions and 118 deletions

View File

@ -88,17 +88,28 @@ CALIBRE_METADATA_FIELDS = frozenset([
]
)
CALIBRE_RESERVED_LABELS = frozenset([
'search', # reserved for saved searches
'date',
'all',
'ondevice',
'inlibrary',
]
)
RESERVED_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)
CALIBRE_METADATA_FIELDS).union(
CALIBRE_RESERVED_LABELS)
assert len(RESERVED_METADATA_FIELDS) == sum(map(len, (
SOCIAL_METADATA_FIELDS, PUBLICATION_METADATA_FIELDS,
BOOK_STRUCTURE_FIELDS, USER_METADATA_FIELDS,
DEVICE_METADATA_FIELDS, CALIBRE_METADATA_FIELDS,
CALIBRE_RESERVED_LABELS
)))
SERIALIZABLE_FIELDS = SOCIAL_METADATA_FIELDS.union(

View File

@ -97,8 +97,6 @@ def _config():
help=_('Overwrite author and title with new metadata'))
c.add_opt('enforce_cpu_limit', default=True,
help=_('Limit max simultaneous jobs to number of CPUs'))
c.add_opt('user_categories', default={},
help=_('User-created tag browser categories'))
return ConfigProxy(c)

View File

@ -7,7 +7,7 @@ from PyQt4.QtCore import SIGNAL, Qt
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
from calibre.gui2 import config
from calibre.utils.config import prefs
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.constants import islinux
@ -22,7 +22,7 @@ class Item:
return 'name=%s, label=%s, index=%s, exists='%(self.name, self.label, self.index, self.exists)
class TagCategories(QDialog, Ui_TagCategories):
category_labels_orig = ['', 'author', 'series', 'publisher', 'tag']
category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags']
def __init__(self, window, db, index=None):
QDialog.__init__(self, window)
@ -64,7 +64,7 @@ class TagCategories(QDialog, Ui_TagCategories):
self.all_items.append(t)
self.all_items_dict[label+':'+n] = t
self.categories = dict.copy(config['user_categories'])
self.categories = dict.copy(prefs['user_categories'])
if self.categories is None:
self.categories = {}
for cat in self.categories:
@ -181,7 +181,7 @@ class TagCategories(QDialog, Ui_TagCategories):
def accept(self):
self.save_category()
config['user_categories'] = self.categories
prefs['user_categories'] = self.categories
QDialog.accept(self)
def save_category(self):

View File

@ -126,7 +126,7 @@ class TagTreeItem(object): # {{{
TAG = 1
ROOT = 2
def __init__(self, data=None, category_icon=None, icon_map=None, parent=None):
def __init__(self, data=None, category_icon=None, icon_map=None, parent=None, tooltip=None):
self.parent = parent
self.children = []
if self.parent is not None:
@ -144,6 +144,7 @@ class TagTreeItem(object): # {{{
elif self.type == self.TAG:
icon_map[0] = data.icon
self.tag, self.icon_state_map = data, list(map(QVariant, icon_map))
self.tooltip = tooltip
def __str__(self):
if self.type == self.ROOT:
@ -175,6 +176,8 @@ class TagTreeItem(object): # {{{
return self.icon
if role == Qt.FontRole:
return self.bold_font
if role == Qt.ToolTipRole and self.tooltip is not None:
return QVariant(self.tooltip)
return NONE
def tag_data(self, role):
@ -199,31 +202,37 @@ class TagsModel(QAbstractItemModel): # {{{
categories_orig = [_('Authors'), _('Series'), _('Formats'), _('Publishers'),
_('Ratings'), _('News'), _('Tags')]
row_map_orig = ['author', 'series', 'format', 'publisher', 'rating',
'news', 'tag']
tags_categories_start= 7
row_map_orig = ['authors', 'series', 'formats', 'publishers', 'ratings',
'news', 'tags']
search_keys=['search', _('Searches')]
def __init__(self, db, parent=None):
QAbstractItemModel.__init__(self, parent)
self.cat_icon_map_orig = list(map(QIcon, [I('user_profile.svg'),
I('series.svg'), I('book.svg'), I('publisher.png'), I('star.png'),
I('news.svg'), I('tags.svg')]))
# 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'))}
self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))]
self.custcol_icon = QIcon(I('column.svg'))
self.search_icon = QIcon(I('search.svg'))
self.usercat_icon = QIcon(I('drawer.svg'))
self.label_to_icon_map = dict(map(None, self.row_map_orig, self.cat_icon_map_orig))
self.label_to_icon_map['*custom'] = self.custcol_icon
self.db = db
self.search_restriction = ''
self.user_categories = {}
self.ignore_next_search = 0
data = self.get_node_tree(config['sort_by_popularity'])
self.root_item = TagTreeItem()
for i, r in enumerate(self.row_map):
c = TagTreeItem(parent=self.root_item,
data=self.categories[i], category_icon=self.cat_icon_map[i])
data=self.categories[i],
category_icon=self.category_icon_map[r],
tooltip=_('The lookup/search name is "{0}"').format(r))
for tag in data[r]:
TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map)
@ -233,66 +242,19 @@ class TagsModel(QAbstractItemModel): # {{{
def get_node_tree(self, sort):
self.row_map = []
self.categories = []
# strip the icons after the 'standard' categories. We will put them back later
if self.tags_categories_start < len(self.row_map_orig):
self.cat_icon_map = self.cat_icon_map_orig[:self.tags_categories_start-len(self.row_map_orig)]
else:
self.cat_icon_map = self.cat_icon_map_orig[:]
self.user_categories = dict.copy(config['user_categories'])
column_map = config['column_map']
for i in range(0, self.tags_categories_start): # First the standard categories
self.row_map.append(self.row_map_orig[i])
self.categories.append(self.categories_orig[i])
if len(self.search_restriction):
data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map,
data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map,
ids=self.db.search(self.search_restriction, return_matches=True))
else:
data = self.db.get_categories(sort_on_count=sort, icon_map=self.label_to_icon_map)
data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map)
for c in data: # now the custom columns
if c not in self.row_map_orig and c in column_map:
self.row_map.append(c)
self.categories.append(self.db.custom_column_label_map[c]['name'])
self.cat_icon_map.append(self.custcol_icon)
tb_categories = self.db.get_tag_browser_categories()
for category in tb_categories.iterkeys():
if category in data: # They should always be there, but ...
self.row_map.append(category)
self.categories.append(tb_categories[category]['name'])
# Now the rest of the normal tag categories
for i in range(self.tags_categories_start, len(self.row_map_orig)):
self.row_map.append(self.row_map_orig[i])
self.categories.append(self.categories_orig[i])
self.cat_icon_map.append(self.cat_icon_map_orig[i])
# Clean up the author's tags, getting rid of the '|' characters
if data['author'] is not None:
for t in data['author']:
t.name = t.name.replace('|', ',')
# Now do the user-defined categories. There is a time/space tradeoff here.
# By converting the tags into a map, we can do the verification in the category
# loop much faster, at the cost of duplicating the categories lists.
taglist = {}
for c in self.row_map:
taglist[c] = dict(map(lambda t:(t.name, t), data[c]))
for c in self.user_categories:
l = []
for (name,label,ign) in self.user_categories[c]:
if label in taglist and name in taglist[label]: # use same node as the complete category
l.append(taglist[label][name])
# else: do nothing, to eliminate nodes that have zero counts
if config['sort_by_popularity']:
data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.count, y.count)))
else:
data[c+'*'] = sorted(l, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower())))
self.row_map.append(c+'*')
self.categories.append(c)
self.cat_icon_map.append(self.usercat_icon)
data['search'] = self.get_search_nodes(self.search_icon) # Add the search category
self.row_map.append(self.search_keys[0])
self.categories.append(self.search_keys[1])
self.cat_icon_map.append(self.search_icon)
return data
def get_search_nodes(self, icon):

View File

@ -183,7 +183,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
_('Error communicating with device'), ' ')
self.device_error_dialog.setModal(Qt.NonModal)
self.tb_wrapper = textwrap.TextWrapper(width=40)
self.device_connected = False
self.device_connected = None
self.viewers = collections.deque()
self.content_server = None
self.system_tray_icon = SystemTrayIcon(QIcon(I('library.png')), self)
@ -675,6 +675,15 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._sync_menu.fetch_annotations.connect(self.fetch_annotations)
self._sync_menu.connect_to_folder.connect(self.connect_to_folder)
self._sync_menu.disconnect_from_folder.connect(self.disconnect_from_folder)
if self.device_connected:
self._sync_menu.connect_to_folder_action.setEnabled(False)
if self.device_connected == 'folder':
self._sync_menu.disconnect_from_folder_action.setEnabled(True)
else:
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
else:
self._sync_menu.connect_to_folder_action.setEnabled(True)
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
def add_spare_server(self, *args):
self.spare_servers.append(Server(limit=int(config['worker_limit']/2.0)))
@ -944,7 +953,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self.status_bar.showMessage(_('Device: ')+\
self.device_manager.device.__class__.get_gui_name()+\
_(' detected.'), 3000)
self.device_connected = True
self.device_connected = 'device' if not is_folder_device else 'folder'
self._sync_menu.enable_device_actions(True,
self.device_manager.device.card_prefix(),
self.device_manager.device)
@ -955,7 +964,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
self._sync_menu.connect_to_folder_action.setEnabled(True)
self._sync_menu.disconnect_from_folder_action.setEnabled(False)
self.save_device_view_settings()
self.device_connected = False
self.device_connected = None
self._sync_menu.enable_device_actions(False)
self.location_view.model().update_devices()
self.vanity.setText(self.vanity_template%\

View File

@ -141,11 +141,15 @@ class CustomColumns(object):
}
# Create Tag Browser categories for custom columns
for i, v in self.custom_column_num_map.items():
for k in sorted(self.custom_column_label_map.keys()):
v = self.custom_column_label_map[k]
if v['normalized']:
tn = 'custom_column_{0}'.format(i)
self.tag_browser_categories[tn] = [v['label'], 'value']
self.tag_browser_datatype[v['label']] = v['datatype']
tn = 'custom_column_{0}'.format(v['num'])
self.tag_browser_categories[v['label']] = {
'table':tn, 'column':'value',
'type':v['datatype'], 'is_multiple':v['is_multiple'],
'kind':'custom', 'name':v['name']
}
def get_custom(self, idx, label=None, num=None, index_is_id=False):
if label is not None:

View File

@ -33,6 +33,9 @@ from calibre.customize.ui import run_plugins_on_import
from calibre.utils.filenames import ascii_filename
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
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
if iswindows:
@ -123,24 +126,33 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if isinstance(self.dbpath, unicode):
self.dbpath = self.dbpath.encode(filesystem_encoding)
self.tag_browser_categories = {
'tags' : ['tag', 'name'],
'series' : ['series', 'name'],
'publishers': ['publisher', 'name'],
'authors' : ['author', 'name'],
'news' : ['news', 'name'],
'ratings' : ['rating', 'rating']
}
self.tag_browser_datatype = {
'tag' : 'textmult',
'series' : None,
'publisher' : 'text',
'author' : 'text',
'news' : None,
'rating' : 'rating',
}
self.tag_browser_formatters = {'rating': lambda x:u'\u2605'*int(round(x/2.))}
# Order as has been customary in the tags pane.
tag_browser_categories_items = [
('authors', {'table':'authors', 'column':'name',
'type':'text', 'is_multiple':False,
'kind':'standard', 'name':_('Authors')}),
('series', {'table':'series', 'column':'name',
'type':None, 'is_multiple':False,
'kind':'standard', 'name':_('Series')}),
('formats', {'table':None, 'column':None,
'type':None, 'is_multiple':False,
'kind':'standard', 'name':_('Formats')}),
('publishers',{'table':'publishers', 'column':'name',
'type':'text', 'is_multiple':False,
'kind':'standard', 'name':_('Publishers')}),
('ratings', {'table':'ratings', 'column':'rating',
'type':'rating', 'is_multiple':False,
'kind':'standard', 'name':_('Ratings')}),
('news', {'table':'news', 'column':'name',
'type':None, 'is_multiple':False,
'kind':'standard', 'name':_('News')}),
('tags', {'table':'tags', 'column':'name',
'type':'text', 'is_multiple':True,
'kind':'standard', 'name':_('Tags')}),
]
self.tag_browser_categories = OrderedDict()
for k,v in tag_browser_categories_items:
self.tag_browser_categories[k] = v
self.connect()
self.is_case_sensitive = not iswindows and not isosx and \
@ -649,36 +661,65 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
def get_recipe(self, id):
return self.conn.get('SELECT script FROM feeds WHERE id=?', (id,), all=False)
def get_tag_browser_categories(self):
return self.tag_browser_categories
def get_categories(self, sort_on_count=False, ids=None, icon_map=None):
self.books_list_filter.change([] if not ids else ids)
categories = {}
for tn, cn in self.tag_browser_categories.items():
#### First, build the standard and custom-column categories ####
for category in self.tag_browser_categories.keys():
tn = self.tag_browser_categories[category]['table']
categories[category] = [] #reserve the position in the ordered list
if tn is None: # Nothing to do for the moment
continue
cn = self.tag_browser_categories[category]['column']
if ids is None:
query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn[1], tn)
query = 'SELECT id, {0}, count FROM tag_browser_{1}'.format(cn, tn)
else:
query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn[1], tn)
query = 'SELECT id, {0}, count FROM tag_browser_filtered_{1}'.format(cn, tn)
if sort_on_count:
query += ' ORDER BY count DESC'
else:
query += ' ORDER BY {0} ASC'.format(cn[1])
query += ' ORDER BY {0} ASC'.format(cn)
data = self.conn.get(query)
category = cn[0]
# icon_map is not None if get_categories is to store an icon and
# possibly a tooltip in the tag structure.
icon, tooltip = None, ''
if icon_map:
if self.tag_browser_categories[category]['kind'] == 'standard':
if category in icon_map:
icon = icon_map[category]
else:
elif self.tag_browser_categories[category]['kind'] == 'custom':
icon = icon_map['*custom']
icon_map[category] = icon_map['*custom']
tooltip = self.custom_column_label_map[category]['name']
datatype = self.tag_browser_datatype[category]
formatter = self.tag_browser_formatters.get(datatype, lambda x: x)
datatype = self.tag_browser_categories[category]['type']
if datatype == 'rating':
item_zero_func = (lambda x: len(formatter(r[1])) > 0)
formatter = (lambda x:u'\u2605'*int(round(x/2.)))
elif category == 'authors':
item_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)
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 r[2] > 0 and
(datatype != 'rating' or len(formatter(r[1])) > 0)]
categories['format'] = []
for r in data if item_zero_func(r)]
# We delayed computing the standard formats category because it does not
# use a view, but is computed dynamically
categories['formats'] = []
icon = None
if icon_map and 'formats' in icon_map:
icon = icon_map['formats']
for fmt in self.conn.get('SELECT DISTINCT format FROM data'):
fmt = fmt[0]
if ids is not None:
@ -693,13 +734,70 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE format="%s"'''%fmt,
all=False)
if count > 0:
categories['format'].append(Tag(fmt, count=count))
categories['formats'].append(Tag(fmt, count=count, icon=icon))
if sort_on_count:
categories['format'].sort(cmp=lambda x,y:cmp(x.count, y.count),
categories['formats'].sort(cmp=lambda x,y:cmp(x.count, y.count),
reverse=True)
else:
categories['format'].sort(cmp=lambda x,y:cmp(x.name, y.name))
categories['formats'].sort(cmp=lambda x,y:cmp(x.name, y.name))
#### Now do the user-defined categories. ####
user_categories = dict.copy(prefs['user_categories'])
# remove all user categories from tag_browser_categories. They can
# easily come and go. We will add all the existing ones in below.
for k in self.tag_browser_categories.keys():
if self.tag_browser_categories[k]['kind'] in ['user', 'search']:
del self.tag_browser_categories[k]
# We want to use same node in the user category as in the source
# category. To do that, we need to find the original Tag node. There is
# a time/space tradeoff here. By converting the tags into a map, we can
# do the verification in the category loop much faster, at the cost of
# temporarily duplicating the categories lists.
taglist = {}
for c in categories.keys():
taglist[c] = dict(map(lambda t:(t.name, t), categories[c]))
for user_cat in sorted(user_categories.keys()):
items = []
for (name,label,ign) in user_categories[user_cat]:
if label in taglist and name in taglist[label]:
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
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']
if sort_on_count:
categories[cat_name] = \
sorted(items, cmp=(lambda x, y: cmp(y.count, x.count)))
else:
categories[cat_name] = \
sorted(items, cmp=(lambda x, y: cmp(x.name.lower(), y.name.lower())))
#### Finally, the saved searches category ####
items = []
icon = None
if icon_map and 'search' in icon_map:
icon = icon_map['search']
for srch in saved_searches.names():
items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon))
if len(items):
self.tag_browser_categories['search'] = {
'table':None, 'column':None,
'type':None, 'is_multiple':False,
'kind':'search', 'name':_('Searches')}
if icon_map is not None:
icon_map['search'] = icon_map['search']
categories['search'] = items
return categories
def tags_older_than(self, tag, delta):

View File

@ -694,8 +694,10 @@ def _prefs():
help=_('Add new formats to existing book records'))
c.add_opt('installation_uuid', default=None, help='Installation UUID')
# this is here instead of the gui preferences because calibredb can execute searches
# these are here instead of the gui preferences because calibredb and
# calibre server can execute searches
c.add_opt('saved_searches', default={}, help=_('List of named saved searches'))
c.add_opt('user_categories', default={}, help=_('User-created tag browser categories'))
c.add_opt('migrated', default=False, help='For Internal use. Don\'t modify.')
return c

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
'''
A ordered dictionary. Use the builtin type on python >= 2.7
'''
try:
from collections import OrderedDict
OrderedDict
except ImportError:
from UserDict import DictMixin
class OrderedDict(dict, DictMixin):
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d' % len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def clear(self):
self.__end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.__end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return (self.__class__, (items,), inst_dict)
return self.__class__, (items,)
def keys(self):
return list(self)
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def copy(self):
return self.__class__(self)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __eq__(self, other):
if isinstance(other, OrderedDict):
return len(self)==len(other) and self.items() == other.items()
return dict.__eq__(self, other)
def __ne__(self, other):
return not self == other