10
10
from itertools import izip
12
from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
13
QFont, SIGNAL, QSize, QIcon, QPoint, \
14
QAbstractItemModel, QVariant, QModelIndex
11
from functools import partial
13
from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QCheckBox, \
14
QFont, QSize, QIcon, QPoint, QVBoxLayout, QComboBox, \
15
QAbstractItemModel, QVariant, QModelIndex, QMenu, \
15
18
from calibre.gui2 import config, NONE
19
from calibre.utils.config import prefs
20
from calibre.library.field_metadata import TagsIcons
16
21
from calibre.utils.search_query_parser import saved_searches
17
from calibre.library.database2 import Tag
19
class TagsView(QTreeView):
21
need_refresh = pyqtSignal()
23
def __init__(self, *args):
24
QTreeView.__init__(self, *args)
22
from calibre.gui2 import error_dialog
23
from calibre.gui2.dialogs.tag_categories import TagCategories
24
from calibre.gui2.dialogs.tag_list_editor import TagListEditor
26
class TagsView(QTreeView): # {{{
28
refresh_required = pyqtSignal()
29
tags_marked = pyqtSignal(object, object)
30
user_category_edit = pyqtSignal(object)
31
tag_list_edit = pyqtSignal(object, object)
32
saved_search_edit = pyqtSignal(object)
33
tag_item_renamed = pyqtSignal()
34
search_item_renamed = pyqtSignal()
36
def __init__(self, parent=None):
37
QTreeView.__init__(self, parent=None)
25
39
self.setUniformRowHeights(True)
26
40
self.setCursor(Qt.PointingHandCursor)
27
41
self.setIconSize(QSize(30, 30))
42
self.setTabKeyNavigation(True)
43
self.setAlternatingRowColors(True)
44
self.setAnimated(True)
45
self.setHeaderHidden(True)
30
47
def set_database(self, db, tag_match, popularity):
31
self._model = TagsModel(db, parent=self)
48
self.hidden_categories = config['tag_browser_hidden_categories']
49
self._model = TagsModel(db, parent=self,
50
hidden_categories=self.hidden_categories,
51
search_restriction=None)
32
52
self.popularity = popularity
33
53
self.tag_match = tag_match
55
self.search_restriction = None
34
56
self.setModel(self._model)
35
self.connect(self, SIGNAL('clicked(QModelIndex)'), self.toggle)
57
self.setContextMenuPolicy(Qt.CustomContextMenu)
58
self.clicked.connect(self.toggle)
59
self.customContextMenuRequested.connect(self.show_context_menu)
36
60
self.popularity.setChecked(config['sort_by_popularity'])
37
self.connect(self.popularity, SIGNAL('stateChanged(int)'), self.sort_changed)
38
self.need_refresh.connect(self.recount, type=Qt.QueuedConnection)
61
self.popularity.stateChanged.connect(self.sort_changed)
62
self.refresh_required.connect(self.recount, type=Qt.QueuedConnection)
39
63
db.add_listener(self.database_changed)
41
65
def database_changed(self, event, ids):
42
self.need_refresh.emit()
66
self.refresh_required.emit()
45
69
def match_all(self):
48
72
def sort_changed(self, state):
49
73
config.set('sort_by_popularity', state == Qt.Checked)
50
self.model().refresh()
76
def set_search_restriction(self, s):
78
self.search_restriction = s
80
self.search_restriction = None
83
def mouseReleaseEvent(self, event):
84
# Swallow everything except leftButton so context menus work correctly
85
if event.button() == Qt.LeftButton:
86
QTreeView.mouseReleaseEvent(self, event)
88
def mouseDoubleClickEvent(self, event):
89
# swallow these to avoid toggling and editing at the same time
52
92
def toggle(self, index):
53
93
modifiers = int(QApplication.keyboardModifiers())
54
94
exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT)
55
95
if self._model.toggle(index, exclusive):
56
self.emit(SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'),
57
self._model.tokens(), self.match_all)
96
self.tags_marked.emit(self._model.tokens(), self.match_all)
98
def context_menu_handler(self, action=None, category=None,
99
key=None, index=None):
103
if action == 'edit_item':
106
if action == 'open_editor':
107
self.tag_list_edit.emit(category, key)
109
if action == 'manage_categories':
110
self.user_category_edit.emit(category)
112
if action == 'manage_searches':
113
self.saved_search_edit.emit(category)
116
self.hidden_categories.add(category)
117
elif action == 'show':
118
self.hidden_categories.discard(category)
119
elif action == 'defaults':
120
self.hidden_categories.clear()
121
config.set('tag_browser_hidden_categories', self.hidden_categories)
126
def show_context_menu(self, point):
127
index = self.indexAt(point)
128
if not index.isValid():
130
item = index.internalPointer()
132
if item.type == TagTreeItem.TAG:
134
tag_name = item.tag.name
136
if item.type == TagTreeItem.CATEGORY:
137
category = unicode(item.name.toString())
138
key = item.category_key
139
# Verify that we are working with a field that we know something about
140
if key not in self.db.field_metadata:
143
self.context_menu = QMenu(self)
144
# If the user right-clicked on an editable item, then offer
145
# the possibility of renaming that item
147
(key in ['authors', 'tags', 'series', 'publisher', 'search'] or \
148
self.db.field_metadata[key]['is_custom'] and \
149
self.db.field_metadata[key]['datatype'] != 'rating'):
150
self.context_menu.addAction(_('Rename') + " '" + tag_name + "'",
151
partial(self.context_menu_handler, action='edit_item',
152
category=tag_item, index=index))
153
self.context_menu.addSeparator()
154
# Hide/Show/Restore categories
155
self.context_menu.addAction(_('Hide category %s') % category,
156
partial(self.context_menu_handler, action='hide', category=category))
157
if self.hidden_categories:
158
m = self.context_menu.addMenu(_('Show category'))
159
for col in sorted(self.hidden_categories, cmp=lambda x,y: cmp(x.lower(), y.lower())):
161
partial(self.context_menu_handler, action='show', category=col))
162
self.context_menu.addAction(_('Show all categories'),
163
partial(self.context_menu_handler, action='defaults'))
165
# Offer specific editors for tags/series/publishers/saved searches
166
self.context_menu.addSeparator()
167
if key in ['tags', 'publisher', 'series'] or \
168
self.db.field_metadata[key]['is_custom']:
169
self.context_menu.addAction(_('Manage ') + category,
170
partial(self.context_menu_handler, action='open_editor',
171
category=tag_name, key=key))
172
elif key == 'search':
173
self.context_menu.addAction(_('Manage Saved Searches'),
174
partial(self.context_menu_handler, action='manage_searches',
177
# Always show the user categories editor
178
self.context_menu.addSeparator()
179
if category in prefs['user_categories'].keys():
180
self.context_menu.addAction(_('Manage User Categories'),
181
partial(self.context_menu_handler, action='manage_categories',
184
self.context_menu.addAction(_('Manage User Categories'),
185
partial(self.context_menu_handler, action='manage_categories',
188
self.context_menu.popup(self.mapToGlobal(point))
60
self.model().clear_state()
193
self.model().clear_state()
195
def is_visible(self, idx):
196
item = idx.internalPointer()
197
if getattr(item, 'type', None) == TagTreeItem.TAG:
199
return self.isExpanded(idx)
62
201
def recount(self, *args):
63
202
ci = self.currentIndex()
64
203
if not ci.isValid():
65
204
ci = self.indexAt(QPoint(10, 10))
66
path = self.model().path_for_index(ci)
205
path = self.model().path_for_index(ci) if self.is_visible(ci) else None
68
self.model().refresh()
207
if not self.model().refresh(): # categories changed!
69
210
except: #Database connection could be closed if an integrity check is happening
74
215
self.setCurrentIndex(idx)
75
216
self.scrollTo(idx, QTreeView.PositionAtCenter)
77
class TagTreeItem(object):
218
# If the number of user categories changed, if custom columns have come or
219
# gone, or if columns have been hidden or restored, we must rebuild the
220
# model. Reason: it is much easier than reconstructing the browser tree.
221
def set_new_model(self):
223
self._model = TagsModel(self.db, parent=self,
224
hidden_categories=self.hidden_categories,
225
search_restriction=self.search_restriction)
226
self.setModel(self._model)
228
# The DB must be gone. Set the model to None and hope that someone
229
# will call set_database later. I don't know if this in fact works
234
class TagTreeItem(object): # {{{
83
def __init__(self, data=None, tag=None, category_icon=None, icon_map=None, parent=None):
240
def __init__(self, data=None, category_icon=None, icon_map=None,
241
parent=None, tooltip=None, category_key=None):
84
242
self.parent = parent
85
243
self.children = []
86
244
if self.parent is not None:
146
311
if self.type == self.TAG:
147
312
self.tag.state = (self.tag.state + 1)%3
150
class TagsModel(QAbstractItemModel):
151
categories = [_('Authors'), _('Series'), _('Formats'), _('Publishers'), _('News'), _('Tags'), _('Searches')]
152
row_map = ['author', 'series', 'format', 'publisher', 'news', 'tag', 'search']
154
def __init__(self, db, parent=None):
316
class TagsModel(QAbstractItemModel): # {{{
318
def __init__(self, db, parent, hidden_categories=None, search_restriction=None):
155
319
QAbstractItemModel.__init__(self, parent)
156
self.cmap = tuple(map(QIcon, [I('user_profile.svg'),
157
I('series.svg'), I('book.svg'), I('publisher.png'),
158
I('news.svg'), I('tags.svg'), I('search.svg')]))
159
self.icon_map = [QIcon(), QIcon(I('plus.svg')),
160
QIcon(I('minus.svg'))]
321
# must do this here because 'QPixmap: Must construct a QApplication
322
# before a QPaintDevice'. The ':' in front avoids polluting either the
323
# user-defined categories (':' at end) or columns namespaces (no ':').
324
self.category_icon_map = TagsIcons({
325
'authors' : QIcon(I('user_profile.svg')),
326
'series' : QIcon(I('series.svg')),
327
'formats' : QIcon(I('book.svg')),
328
'publisher' : QIcon(I('publisher.png')),
329
'rating' : QIcon(I('star.png')),
330
'news' : QIcon(I('news.svg')),
331
'tags' : QIcon(I('tags.svg')),
332
':custom' : QIcon(I('column.svg')),
333
':user' : QIcon(I('drawer.svg')),
334
'search' : QIcon(I('search.svg'))})
336
self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))]
162
self.ignore_next_search = 0
338
self.tags_view = parent
339
self.hidden_categories = hidden_categories
340
self.search_restriction = search_restriction
343
# get_node_tree cannot return None here, because row_map is empty
344
data = self.get_node_tree(config['sort_by_popularity'])
163
345
self.root_item = TagTreeItem()
164
data = self.db.get_categories(config['sort_by_popularity'])
165
data['search'] = self.get_search_nodes()
167
346
for i, r in enumerate(self.row_map):
347
if self.hidden_categories and self.categories[i] in self.hidden_categories:
349
if self.db.field_metadata[r]['kind'] != 'user':
350
tt = _('The lookup/search name is "{0}"').format(r)
168
353
c = TagTreeItem(parent=self.root_item,
169
data=self.categories[i], category_icon=self.cmap[i])
354
data=self.categories[i],
355
category_icon=self.category_icon_map[r],
356
tooltip=tt, category_key=r)
170
357
for tag in data[r]:
171
TagTreeItem(parent=c, data=tag, icon_map=self.icon_map)
174
def get_search_nodes(self):
176
for i in saved_searches.names():
177
l.append(Tag(i, tooltip=saved_searches.lookup(i)))
358
TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map)
360
def set_search_restriction(self, s):
361
self.search_restriction = s
363
def get_node_tree(self, sort):
364
old_row_map = self.row_map[:]
368
# Reconstruct the user categories, putting them into metadata
369
tb_cats = self.db.field_metadata
370
for k in tb_cats.keys():
371
if tb_cats[k]['kind'] in ['user', 'search']:
373
for user_cat in sorted(prefs['user_categories'].keys()):
374
cat_name = user_cat+':' # add the ':' to avoid name collision
375
tb_cats.add_user_category(label=cat_name, name=user_cat)
376
if len(saved_searches.names()):
377
tb_cats.add_search_category(label='search', name=_('Searches'))
379
# Now get the categories
380
if self.search_restriction:
381
data = self.db.get_categories(sort_on_count=sort,
382
icon_map=self.category_icon_map,
383
ids=self.db.search('', return_matches=True))
385
data = self.db.get_categories(sort_on_count=sort, icon_map=self.category_icon_map)
387
tb_categories = self.db.field_metadata
388
for category in tb_categories:
389
if category in data: # The search category can come and go
390
self.row_map.append(category)
391
self.categories.append(tb_categories[category]['name'])
392
if len(old_row_map) != 0 and len(old_row_map) != len(self.row_map):
393
# A category has been added or removed. We must force a rebuild of
180
398
def refresh(self):
181
data = self.db.get_categories(config['sort_by_popularity'])
182
data['search'] = self.get_search_nodes()
399
data = self.get_node_tree(config['sort_by_popularity']) # get category data
183
403
for i, r in enumerate(self.row_map):
184
category = self.root_item.children[i]
404
if self.hidden_categories and self.categories[i] in self.hidden_categories:
407
category = self.root_item.children[row_index]
185
408
names = [t.tag.name for t in category.children]
186
409
states = [t.tag.state for t in category.children]
187
410
state_map = dict(izip(names, states))
188
category_index = self.index(i, 0, QModelIndex())
411
category_index = self.index(row_index, 0, QModelIndex())
189
412
if len(category.children) > 0:
190
413
self.beginRemoveRows(category_index, 0,
191
414
len(category.children)-1)
209
431
item = index.internalPointer()
210
432
return item.data(role)
434
def setData(self, index, value, role=Qt.EditRole):
435
if not index.isValid():
437
# set up to position at the category label
438
path = self.path_for_index(self.parent(index))
439
val = unicode(value.toString())
441
error_dialog(self.tags_view, _('Item is blank'),
442
_('An item cannot be set to nothing. Delete it instead.')).exec_()
444
item = index.internalPointer()
445
key = item.parent.category_key
446
# make certain we know about the item's category
447
if key not in self.db.field_metadata:
450
if val in saved_searches.names():
451
error_dialog(self.tags_view, _('Duplicate search name'),
452
_('The saved search name %s is already used.')%val).exec_()
454
saved_searches.rename(unicode(item.data(role).toString()), val)
455
self.tags_view.search_item_renamed.emit()
458
self.db.rename_series(item.tag.id, val)
459
elif key == 'publisher':
460
self.db.rename_publisher(item.tag.id, val)
462
self.db.rename_tag(item.tag.id, val)
463
elif key == 'authors':
464
self.db.rename_author(item.tag.id, val)
465
elif self.db.field_metadata[key]['is_custom']:
466
self.db.rename_custom_item(item.tag.id, val,
467
label=self.db.field_metadata[key]['label'])
468
self.tags_view.tag_item_renamed.emit()
470
self.refresh() # Should work, because no categories can have disappeared
472
idx = self.index_for_path(path)
474
self.tags_view.setCurrentIndex(idx)
475
self.tags_view.scrollTo(idx, QTreeView.PositionAtCenter)
212
478
def headerData(self, *args):
215
481
def flags(self, *args):
216
return Qt.ItemIsEnabled|Qt.ItemIsSelectable
482
return Qt.ItemIsEnabled|Qt.ItemIsSelectable|Qt.ItemIsEditable
218
484
def path_for_index(self, index):
273
539
return len(parent_item.children)
275
541
def reset_all_states(self, except_=None):
276
543
for i in xrange(self.rowCount(QModelIndex())):
277
544
category_index = self.index(i, 0, QModelIndex())
278
545
for j in xrange(self.rowCount(category_index)):
279
546
tag_index = self.index(j, 0, category_index)
280
547
tag_item = tag_index.internalPointer()
281
if tag_item is except_:
550
self.dataChanged.emit(tag_index, tag_index)
552
if tag.state != 0 or tag in update_list:
286
self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'),
287
tag_index, tag_index)
554
update_list.append(tag)
555
self.dataChanged.emit(tag_index, tag_index)
289
557
def clear_state(self):
290
558
self.reset_all_states()
292
def reinit(self, *args, **kwargs):
293
if self.ignore_next_search == 0:
294
self.reset_all_states()
296
self.ignore_next_search -= 1
298
560
def toggle(self, index, exclusive):
299
561
if not index.isValid(): return False
300
562
item = index.internalPointer()
301
563
if item.type == TagTreeItem.TAG:
303
self.reset_all_states(except_=item)
305
self.ignore_next_search = 2
306
self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), index, index)
566
self.reset_all_states(except_=item.tag)
567
self.dataChanged.emit(index, index)
310
571
def tokens(self):
312
575
for i, key in enumerate(self.row_map):
313
category_item = self.root_item.children[i]
576
if self.hidden_categories and self.categories[i] in self.hidden_categories:
579
if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category
581
category_item = self.root_item.children[row_index]
314
582
for tag_item in category_item.children:
315
583
tag = tag_item.tag
316
category = key if key != 'news' else 'tag'
317
584
if tag.state > 0:
318
585
prefix = ' not ' if tag.state == 2 else ''
319
ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
586
category = key if key != 'news' else 'tag'
587
if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating
588
ans.append('%s%s:%s'%(prefix, category, len(tag.name)))
590
if category == 'tags':
591
if tag.name in tags_seen:
593
tags_seen.add(tag.name)
594
ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
599
class TagBrowserMixin(object): # {{{
601
def __init__(self, db):
602
self.library_view.model().count_changed_signal.connect(self.tags_view.recount)
603
self.tags_view.set_database(self.library_view.model().db,
604
self.tag_match, self.popularity)
605
self.tags_view.tags_marked.connect(self.search.search_from_tags)
606
self.tags_view.tags_marked.connect(self.saved_search.clear_to_help)
607
self.tags_view.tag_list_edit.connect(self.do_tags_list_edit)
608
self.tags_view.user_category_edit.connect(self.do_user_categories_edit)
609
self.tags_view.saved_search_edit.connect(self.do_saved_search_edit)
610
self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed)
611
self.tags_view.search_item_renamed.connect(self.saved_search.clear_to_help)
612
self.edit_categories.clicked.connect(lambda x:
613
self.do_user_categories_edit())
615
def do_user_categories_edit(self, on_category=None):
616
d = TagCategories(self, self.library_view.model().db, on_category)
618
if d.result() == d.Accepted:
619
self.tags_view.set_new_model()
620
self.tags_view.recount()
622
def do_tags_list_edit(self, tag, category):
623
d = TagListEditor(self, self.library_view.model().db, tag, category)
625
if d.result() == d.Accepted:
626
# Clean up everything, as information could have changed for many books.
627
self.library_view.model().refresh()
628
self.tags_view.set_new_model()
629
self.tags_view.recount()
630
self.saved_search.clear_to_help()
631
self.search.clear_to_help()
633
def do_tag_item_renamed(self):
634
# Clean up library view and search
635
self.library_view.model().refresh()
636
self.saved_search.clear_to_help()
637
self.search.clear_to_help()
641
class TagBrowserWidget(QWidget): # {{{
643
def __init__(self, parent):
644
QWidget.__init__(self, parent)
645
self._layout = QVBoxLayout()
646
self.setLayout(self._layout)
648
parent.tags_view = TagsView(parent)
649
self._layout.addWidget(parent.tags_view)
651
parent.popularity = QCheckBox(parent)
652
parent.popularity.setText(_('Sort by &popularity'))
653
self._layout.addWidget(parent.popularity)
655
parent.tag_match = QComboBox(parent)
656
for x in (_('Match any'), _('Match all')):
657
parent.tag_match.addItem(x)
658
parent.tag_match.setCurrentIndex(0)
659
self._layout.addWidget(parent.tag_match)
661
parent.edit_categories = QPushButton(_('Manage &user categories'), parent)
662
self._layout.addWidget(parent.edit_categories)