Enhancement 1984121: User category editor: Hide items not shown in tag browser

I cleaned up the code a lot, making it (I hope) easier to understand and more reliable.
This commit is contained in:
Charles Haley 2022-08-21 16:01:50 +01:00
parent 3bae760705
commit 9a44efc431
2 changed files with 204 additions and 181 deletions

View File

@ -1,48 +1,43 @@
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
from qt.core import ( from qt.core import (Qt, QApplication, QDialog, QIcon, QListWidgetItem)
Qt, QApplication, QDialog, QIcon, QListWidgetItem)
from collections import namedtuple
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2 import error_dialog, warning_dialog
from calibre.constants import islinux from calibre.constants import islinux
from calibre.utils.icu import sort_key, strcmp, primary_contains from calibre.gui2 import error_dialog, warning_dialog
from polyglot.builtins import iteritems from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
from calibre.utils.icu import primary_sort_key, strcmp, primary_contains
class Item:
def __init__(self, name, label, index, icon, exists):
self.name = name
self.label = label
self.index = index
self.icon = icon
self.exists = exists
def __str__(self):
return 'name=%s, label=%s, index=%s, exists=%s'%(self.name, self.label, self.index, self.exists)
class TagCategories(QDialog, Ui_TagCategories): class TagCategories(QDialog, Ui_TagCategories):
''' '''
The structure of user_categories stored in preferences is The structure of user_categories stored in preferences is
{cat_name: [ [name, category, v], [], []}, cat_name [ [name, cat, v] ...} {cat_name: [ [name, category, v], [], [] ]}, cat_name: [ [name, cat, v] ...]}
where name is the item name, category is where it came from (series, etc), where name is the item name, category is where it came from (series, etc),
and v is a scratch area that this editor uses to keep track of categories. and v is a scratch area.
If you add a category, it is permissible to set v to zero. If you delete If you add a category, set v to zero. If you delete a category, ensure that
a category, ensure that both the name and the category match. both the name and the category match.
''' '''
category_labels_orig = ['', 'authors', 'series', 'publisher', 'tags', 'languages']
category_icons = {'authors': QIcon.ic('user_profile.png'),
'series': QIcon.ic('series.png'),
'publisher': QIcon.ic('publisher.png'),
'tags': QIcon.ic('tags.png'),
'languages': QIcon.ic('languages.png')}
ItemTuple = namedtuple('ItemTuple', 'v k')
CategoryNameTuple = namedtuple('CategoryNameTuple', 'n k')
def __init__(self, window, db, on_category=None, book_ids=None): def __init__(self, window, db, on_category=None, book_ids=None):
QDialog.__init__(self, window) QDialog.__init__(self, window)
Ui_TagCategories.__init__(self) Ui_TagCategories.__init__(self)
self.setupUi(self) self.setupUi(self)
self.blank.setText('\xa0')
# I can't figure out how to get these into the .ui file # I can't figure out how to get these into the .ui file
self.gridLayout_2.setColumnMinimumWidth(0, 50) self.gridLayout_2.setColumnMinimumWidth(0, 50)
@ -58,48 +53,41 @@ class TagCategories(QDialog, Ui_TagCategories):
self.db = db self.db = db
self.applied_items = [] self.applied_items = []
self.book_ids = book_ids self.book_ids = book_ids
self.hide_hidden_categories = False
self.filter_by_vl = False
self.category_labels = [] # The label is the lookup key
if self.book_ids is None: if self.book_ids is None:
self.apply_vl_checkbox.setEnabled(False) self.apply_vl_checkbox.setEnabled(False)
cc_icon = QIcon.ic('column.png') self.cc_icon = QIcon.ic('column.png')
self.category_labels = self.category_labels_orig[:]
self.category_icons = [None, QIcon.ic('user_profile.png'), QIcon.ic('series.png'),
QIcon.ic('publisher.png'), QIcon.ic('tags.png'),
QIcon.ic('languages.png')]
self.category_values = [None,
lambda: [t.original_name.replace('|', ',') for t in self.db_categories['authors']],
lambda: [t.original_name for t in self.db_categories['series']],
lambda: [t.original_name for t in self.db_categories['publisher']],
lambda: [t.original_name for t in self.db_categories['tags']],
lambda: [t.original_name for t in self.db_categories['languages']]
]
category_names = ['', _('Authors'), ngettext('Series', 'Series', 2),
_('Publishers'), _('Tags'), _('Languages')]
for key,cc in iteritems(self.db.custom_field_metadata()): # Build a dict of all available items, used when checking and building user cats
if cc['datatype'] in ['text', 'series', 'enumeration']: self.all_items = {}
self.category_labels.append(key) db_categories = self.db.new_api.get_categories()
self.category_icons.append(cc_icon) for key, tag in db_categories.items():
self.category_values.append(lambda col=key: [t.original_name for t in self.db_categories[col]]) self.all_items[key] = {'icon': self.category_icons.get(key, self.cc_icon),
category_names.append(cc['name']) 'name': self.db.field_metadata[key]['name'],
elif cc['datatype'] == 'composite' and \ 'values': {t.original_name for t in tag}
cc['display'].get('make_category', False): }
self.category_labels.append(key)
self.category_icons.append(cc_icon)
category_names.append(cc['name'])
self.category_values.append(lambda col=key: [t.original_name for t in self.db_categories[col]])
self.categories = dict.copy(db.new_api.pref('user_categories', {}))
if self.categories is None:
self.categories = {}
self.initialize_category_lists(book_ids=None)
self.display_filtered_categories(0) # build the list of all user categories. Filter out keys that no longer exist
self.user_categories = {}
for cat_name, values in db.new_api.pref('user_categories', {}).items():
fv = set()
for v in values:
if v[1] in self.db.field_metadata:
fv.add(self.item_tuple(v[1], v[0]))
self.user_categories[cat_name] = fv
for v in category_names: # get the hidden categories
self.category_filter_box.addItem(v) hidden_cats = self.db.new_api.pref('tag_browser_hidden_categories', None)
self.current_cat_name = None self.hidden_categories = set()
# strip non-existent field keys from hidden categories (just in case)
for cat in hidden_cats:
if cat in self.db.field_metadata:
self.hidden_categories.add(cat)
self.copy_category_name_to_clipboard.clicked.connect(self.copy_category_name_to_clipboard_clicked) self.copy_category_name_to_clipboard.clicked.connect(self.copy_category_name_to_clipboard_clicked)
self.apply_button.clicked.connect(self.apply_button_clicked) self.apply_button.clicked.connect(self.apply_button_clicked)
@ -109,20 +97,22 @@ class TagCategories(QDialog, Ui_TagCategories):
self.category_box.currentIndexChanged.connect(self.select_category) self.category_box.currentIndexChanged.connect(self.select_category)
self.category_filter_box.currentIndexChanged.connect( self.category_filter_box.currentIndexChanged.connect(
self.display_filtered_categories) self.display_filtered_categories)
self.item_filter_box.textEdited.connect(self.display_filtered_items) self.item_filter_box.textEdited.connect(self.apply_filter)
self.delete_category_button.clicked.connect(self.del_category) self.delete_category_button.clicked.connect(self.delete_category)
if islinux: if islinux:
self.available_items_box.itemDoubleClicked.connect(self.apply_tags) self.available_items_box.itemDoubleClicked.connect(self.apply_tags)
else: else:
self.available_items_box.itemActivated.connect(self.apply_tags) self.available_items_box.itemActivated.connect(self.apply_tags)
self.applied_items_box.itemActivated.connect(self.unapply_tags) self.applied_items_box.itemActivated.connect(self.unapply_tags)
self.apply_vl_checkbox.clicked.connect(self.apply_vl) self.apply_vl_checkbox.clicked.connect(self.apply_vl_clicked)
self.hide_hidden_categories_checkbox.clicked.connect(self.hide_hidden_categories_clicked)
self.current_cat_name = None
self.initialize_category_lists()
self.display_filtered_categories()
self.populate_category_list() self.populate_category_list()
if on_category is not None: if on_category is not None:
l = self.category_box.findText(on_category) self.category_box.setCurrentIndex(self.category_box.findText(on_category))
if l >= 0:
self.category_box.setCurrentIndex(l)
if self.current_cat_name is None: if self.current_cat_name is None:
self.category_box.setCurrentIndex(0) self.category_box.setCurrentIndex(0)
self.select_category(0) self.select_category(0)
@ -131,66 +121,114 @@ class TagCategories(QDialog, Ui_TagCategories):
t = self.category_box.itemText(self.category_box.currentIndex()) t = self.category_box.itemText(self.category_box.currentIndex())
QApplication.clipboard().setText(t) QApplication.clipboard().setText(t)
def initialize_category_lists(self, book_ids): def item_tuple(self, key, val):
self.db_categories = self.db.new_api.get_categories(book_ids=book_ids) return self.ItemTuple(val, key)
self.all_items = []
self.all_items_dict = {} def category_name_tuple(self, key, name):
for idx,label in enumerate(self.category_labels): return self.CategoryNameTuple(name, key)
if idx == 0:
def initialize_category_lists(self):
cfb = self.category_filter_box
current_cat_filter = (self.category_labels[cfb.currentIndex()]
if self.category_labels and cfb.currentIndex() > 0
else '')
# get the values for each category taking into account the VL, then
# populate the lists taking hidden and filtered categories into account
self.available_items = {}
self.sorted_items = []
sorted_categories = []
item_filter = self.item_filter_box.text()
db_categories = self.db.new_api.get_categories(book_ids=self.book_ids if
self.filter_by_vl else None)
for key, tags in db_categories.items():
if key == 'search' or key.startswith('@'):
continue continue
for n in self.category_values[idx](): if self.hide_hidden_categories and key in self.hidden_categories:
t = Item(name=n, label=label, index=len(self.all_items), continue
icon=self.category_icons[idx], exists=True) av = set()
self.all_items.append(t) for t in tags:
self.all_items_dict[icu_lower(label+':'+n)] = t if item_filter and not primary_contains(item_filter, t.original_name):
continue
av.add(t.original_name)
self.sorted_items.append(self.item_tuple(key, t.original_name))
self.available_items[key] = av
sorted_categories.append(self.category_name_tuple(key, self.all_items[key]['name']))
for cat in self.categories: # Sort the items
for item,l in enumerate(self.categories[cat]): self.sorted_items.sort(key=lambda v: primary_sort_key(v.v + v.k))
key = icu_lower(':'.join([l[1], l[0]]))
t = self.all_items_dict.get(key, None)
if l[1] in self.category_labels:
if t is None:
t = Item(name=l[0], label=l[1], index=len(self.all_items),
icon=self.category_icons[self.category_labels.index(l[1])],
exists=False)
self.all_items.append(t)
self.all_items_dict[key] = t
l[2] = t.index
else:
# remove any references to a category that no longer exists
del self.categories[cat][item]
self.all_items_sorted = sorted(self.all_items, key=lambda x: sort_key(x.name)) # Fill in the category names with visible (not hidden) lookup keys
sorted_categories.sort(key=lambda v: primary_sort_key(v.n + v.k))
cfb.blockSignals(True)
cfb.clear()
cfb.addItem('', '')
for i,v in enumerate(sorted_categories):
cfb.addItem(f'{v.n} ({v.k})', v.k)
if current_cat_filter == v.k:
cfb.setCurrentIndex(i+1)
cfb.blockSignals(False)
def apply_vl(self, checked): def populate_category_list(self):
if checked: self.category_box.blockSignals(True)
self.initialize_category_lists(self.book_ids) self.category_box.clear()
else: self.category_box.addItems(sorted(self.user_categories.keys(), key=primary_sort_key))
self.initialize_category_lists(None) self.category_box.blockSignals(False)
self.fill_applied_items()
def make_list_widget(self, item): def make_available_list_item(self, key, val):
n = item.name if item.exists else item.name + _(' (not on any book)') w = QListWidgetItem(self.all_items[key]['icon'], val)
w = QListWidgetItem(item.icon, n) w.setData(Qt.ItemDataRole.UserRole, self.item_tuple(key, val))
w.setData(Qt.ItemDataRole.UserRole, item.index) w.setToolTip(_('Lookup name: {}').format(key))
w.setToolTip(_('Category lookup name: ') + item.label)
return w return w
def display_filtered_items(self, text): def make_applied_list_item(self, tup):
self.display_filtered_categories(None) if tup.v not in self.all_items[tup.k]['values']:
t = tup.v + ' ' + _('(Not in library)')
elif tup.k not in self.available_items:
t = tup.v + ' ' + _('(Hidden in Tag browser)')
elif tup.v not in self.available_items[tup.k]:
t = tup.v + ' ' + _('(Hidden by Virtual library)')
else:
t = tup.v
w = QListWidgetItem(self.all_items[tup.k]['icon'], t)
w.setData(Qt.ItemDataRole.UserRole, tup)
w.setToolTip(_('Lookup name: {}').format(tup.k))
return w
def display_filtered_categories(self, idx): def hide_hidden_categories_clicked(self, checked):
idx = idx if idx is not None else self.category_filter_box.currentIndex() self.hide_hidden_categories = checked
self.initialize_category_lists()
self.display_filtered_categories()
self.fill_applied_items()
def apply_vl_clicked(self, checked):
self.filter_by_vl = checked
self.initialize_category_lists()
self.fill_applied_items()
def apply_filter(self, _):
self.initialize_category_lists()
self.display_filtered_categories()
def display_filtered_categories(self):
idx = self.category_filter_box.currentIndex()
filter_key = self.category_filter_box.itemData(idx)
self.available_items_box.clear() self.available_items_box.clear()
for it in self.sorted_items:
if idx != 0 and it.k != filter_key:
continue
self.available_items_box.addItem(self.make_available_list_item(it.k, it.v))
def fill_applied_items(self):
ccn = self.current_cat_name
if ccn:
self.applied_items = [v for v in self.user_categories[ccn]]
self.applied_items.sort(key=lambda x:primary_sort_key(x.v + x.k))
else:
self.applied_items = []
self.applied_items_box.clear() self.applied_items_box.clear()
item_filter = self.item_filter_box.text() for tup in self.applied_items:
for item in self.all_items_sorted: self.applied_items_box.addItem(self.make_applied_list_item(tup))
if idx == 0 or item.label == self.category_labels[idx]:
if item.index not in self.applied_items and item.exists:
if primary_contains(item_filter, item.name):
self.available_items_box.addItem(self.make_list_widget(item))
for index in self.applied_items:
self.applied_items_box.addItem(self.make_list_widget(self.all_items[index]))
def apply_button_clicked(self): def apply_button_clicked(self):
self.apply_tags(node=None) self.apply_tags(node=None)
@ -205,16 +243,16 @@ class TagCategories(QDialog, Ui_TagCategories):
show=True, show_copy_button=False) show=True, show_copy_button=False)
return return
for node in nodes: for node in nodes:
index = self.all_items[node.data(Qt.ItemDataRole.UserRole)].index tup = node.data(Qt.ItemDataRole.UserRole)
if index not in self.applied_items: self.user_categories[self.current_cat_name].add(tup)
self.applied_items.append(index) self.fill_applied_items()
self.applied_items.sort(key=lambda x:sort_key(self.all_items[x].name))
self.display_filtered_categories(None)
def unapply_button_clicked(self): def unapply_button_clicked(self):
self.unapply_tags(node=None) self.unapply_tags(node=None)
def unapply_tags(self, node=None): def unapply_tags(self, node=None):
if self.current_cat_name is None:
return
nodes = self.applied_items_box.selectedItems() if node is None else [node] nodes = self.applied_items_box.selectedItems() if node is None else [node]
if len(nodes) == 0: if len(nodes) == 0:
warning_dialog(self, _('No items selected'), warning_dialog(self, _('No items selected'),
@ -222,15 +260,14 @@ class TagCategories(QDialog, Ui_TagCategories):
show=True, show_copy_button=False) show=True, show_copy_button=False)
return return
for node in nodes: for node in nodes:
index = self.all_items[node.data(Qt.ItemDataRole.UserRole)].index tup = node.data(Qt.ItemDataRole.UserRole)
self.applied_items.remove(index) self.user_categories[self.current_cat_name].discard(tup)
self.display_filtered_categories(None) self.fill_applied_items()
def add_category(self): def add_category(self):
self.save_category()
cat_name = str(self.input_box.text()).strip() cat_name = str(self.input_box.text()).strip()
if cat_name == '': if cat_name == '':
return False return
comps = [c.strip() for c in cat_name.split('.') if c.strip()] comps = [c.strip() for c in cat_name.split('.') if c.strip()]
if len(comps) == 0 or '.'.join(comps) != cat_name: if len(comps) == 0 or '.'.join(comps) != cat_name:
error_dialog(self, _('Invalid name'), error_dialog(self, _('Invalid name'),
@ -238,64 +275,66 @@ class TagCategories(QDialog, Ui_TagCategories):
'multiple periods in a row or spaces before ' 'multiple periods in a row or spaces before '
'or after periods.')).exec() 'or after periods.')).exec()
return False return False
for c in sorted(self.categories.keys(), key=sort_key): for c in sorted(self.user_categories.keys(), key=primary_sort_key):
if strcmp(c, cat_name) == 0 or \ if strcmp(c, cat_name) == 0 or \
(icu_lower(cat_name).startswith(icu_lower(c) + '.') and (icu_lower(cat_name).startswith(icu_lower(c) + '.') and
not cat_name.startswith(c + '.')): not cat_name.startswith(c + '.')):
error_dialog(self, _('Name already used'), error_dialog(self, _('Name already used'),
_('That name is already used, perhaps with different case.')).exec() _('That name is already used, perhaps with different case.')).exec()
return False return False
if cat_name not in self.categories: if cat_name not in self.user_categories:
self.user_categories[cat_name] = set()
self.category_box.clear() self.category_box.clear()
self.current_cat_name = cat_name self.current_cat_name = cat_name
self.categories[cat_name] = []
self.applied_items = []
self.populate_category_list() self.populate_category_list()
self.fill_applied_items()
self.input_box.clear() self.input_box.clear()
self.category_box.setCurrentIndex(self.category_box.findText(cat_name)) self.category_box.setCurrentIndex(self.category_box.findText(cat_name))
return True
def rename_category(self): def rename_category(self):
self.save_category()
cat_name = str(self.input_box.text()).strip() cat_name = str(self.input_box.text()).strip()
if cat_name == '': if cat_name == '':
return False return
if not self.current_cat_name: if not self.current_cat_name:
return False return
comps = [c.strip() for c in cat_name.split('.') if c.strip()] comps = [c.strip() for c in cat_name.split('.') if c.strip()]
if len(comps) == 0 or '.'.join(comps) != cat_name: if len(comps) == 0 or '.'.join(comps) != cat_name:
error_dialog(self, _('Invalid name'), error_dialog(self, _('Invalid name'),
_('That name contains leading or trailing periods, ' _('That name contains leading or trailing periods, '
'multiple periods in a row or spaces before ' 'multiple periods in a row or spaces before '
'or after periods.')).exec() 'or after periods.')).exec()
return False return
for c in self.categories: for c in self.user_categories:
if strcmp(c, cat_name) == 0: if strcmp(c, cat_name) == 0:
error_dialog(self, _('Name already used'), error_dialog(self, _('Name already used'),
_('That name is already used, perhaps with different case.')).exec() _('That name is already used, perhaps with different case.')).exec()
return False return
# The order below is important because of signals # The order below is important because of signals
self.categories[cat_name] = self.categories[self.current_cat_name] self.user_categories[cat_name] = self.user_categories[self.current_cat_name]
del self.categories[self.current_cat_name] del self.user_categories[self.current_cat_name]
self.current_cat_name = None self.current_cat_name = None
self.populate_category_list() self.populate_category_list()
self.input_box.clear() self.input_box.clear()
self.category_box.setCurrentIndex(self.category_box.findText(cat_name)) self.category_box.setCurrentIndex(self.category_box.findText(cat_name))
return True return
def del_category(self): def delete_category(self):
if self.current_cat_name is not None: if self.current_cat_name is not None:
if not confirm('<p>'+_('The current User category will be ' if not confirm('<p>'+_('The current User category will be '
'<b>permanently deleted</b>. Are you sure?') + '<b>permanently deleted</b>. Are you sure?') +
'</p>', 'tag_category_delete', self): '</p>', 'tag_category_delete', self):
return return
del self.categories[self.current_cat_name] del self.user_categories[self.current_cat_name]
self.current_cat_name = None # self.category_box.removeItem(self.category_box.currentIndex())
self.category_box.removeItem(self.category_box.currentIndex()) self.populate_category_list()
if self.category_box.count():
self.current_cat_name = self.category_box.itemText(0)
else:
self.current_cat_name = None
self.fill_applied_items()
def select_category(self, idx): def select_category(self, idx):
self.save_category()
s = self.category_box.itemText(idx) s = self.category_box.itemText(idx)
if s: if s:
self.current_cat_name = str(s) self.current_cat_name = str(s)
@ -303,34 +342,13 @@ class TagCategories(QDialog, Ui_TagCategories):
self.current_cat_name = None self.current_cat_name = None
self.fill_applied_items() self.fill_applied_items()
def fill_applied_items(self):
if self.current_cat_name:
self.applied_items = [cat[2] for cat in self.categories.get(self.current_cat_name, [])]
else:
self.applied_items = []
self.applied_items.sort(key=lambda x:sort_key(self.all_items[x].name))
self.display_filtered_categories(None)
def accept(self): def accept(self):
self.save_category() # Reconstruct the pref value
for cat in sorted(self.categories.keys(), key=sort_key): self.categories = {}
components = cat.split('.') for cat in self.user_categories:
for i in range(0,len(components)): cat_values = []
c = '.'.join(components[0:i+1]) for tup in self.user_categories[cat]:
if c not in self.categories: cat_values.append([tup.v, tup.k, 0])
self.categories[c] = [] self.categories[cat] = cat_values
QDialog.accept(self) QDialog.accept(self)
def save_category(self):
if self.current_cat_name is not None:
l = []
for index in self.applied_items:
item = self.all_items[index]
l.append([item.name, item.label, item.index])
self.categories[self.current_cat_name] = l
def populate_category_list(self):
self.category_box.blockSignals(True)
self.category_box.clear()
self.category_box.addItems(sorted(self.categories.keys(), key=sort_key))
self.category_box.blockSignals(False)

View File

@ -25,7 +25,7 @@
<item row="0" column="0"> <item row="0" column="0">
<widget class="QLabel" name="label_3"> <widget class="QLabel" name="label_3">
<property name="text"> <property name="text">
<string>Category &amp;name: </string> <string>&amp;User category name: </string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set> <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
@ -177,19 +177,24 @@
<item row="1" column="0" colspan="3"> <item row="1" column="0" colspan="3">
<widget class="QCheckBox" name="apply_vl_checkbox"> <widget class="QCheckBox" name="apply_vl_checkbox">
<property name="toolTip"> <property name="toolTip">
<string>&lt;p&gt;Show items in the Available items box only if they appear in the <string>&lt;p&gt;Only show items that are visible in current Virtual
current Virtual library. Applied items not in the Virtual library will be marked library. Applied items not shown in the Virtual library will be
&quot;not on any book&quot;.&lt;/p&gt;</string> marked &quot;Hidden by Virtual library&quot;.&lt;/p&gt;</string>
</property> </property>
<property name="text"> <property name="text">
<string>&amp;Show only available items in current Virtual library</string> <string>&amp;Only show items visible in current Virtual library</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0"> <item row="2" column="0" colspan="3">
<widget class="QLabel" name="blank"> <widget class="QCheckBox" name="hide_hidden_categories_checkbox">
<property name="toolTip">
<string>&lt;p&gt;Don't show items that are hidden in the Tag browser.
Applied items not shown in the Tag browser will be marked
&quot;Hidden in Tag browser&quot;.&lt;/p&gt;</string>
</property>
<property name="text"> <property name="text">
<string/> <string>&amp;Don't show items hidden in the Tag browser</string>
</property> </property>
</widget> </widget>
</item> </item>