1__license__   = 'GPL v3'
2__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
3
4from qt.core import (
5    Qt, QApplication, QDialog, QIcon, QListWidgetItem)
6
7from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
8from calibre.gui2.dialogs.confirm_delete import confirm
9from calibre.gui2 import error_dialog, warning_dialog
10from calibre.constants import islinux
11from calibre.utils.icu import sort_key, strcmp, primary_contains
12from polyglot.builtins import iteritems
13
14
15class Item:
16
17    def __init__(self, name, label, index, icon, exists):
18        self.name = name
19        self.label = label
20        self.index = index
21        self.icon = icon
22        self.exists = exists
23
24    def __str__(self):
25        return 'name=%s, label=%s, index=%s, exists=%s'%(self.name, self.label, self.index, self.exists)
26
27
28class TagCategories(QDialog, Ui_TagCategories):
29
30    '''
31    The structure of user_categories stored in preferences is
32      {cat_name: [ [name, category, v], [], []}, cat_name [ [name, cat, v] ...}
33    where name is the item name, category is where it came from (series, etc),
34    and v is a scratch area that this editor uses to keep track of categories.
35
36    If you add a category, it is permissible to set v to zero. If you delete
37    a category, ensure that both the name and the category match.
38    '''
39    category_labels_orig =   ['', 'authors', 'series', 'publisher', 'tags', 'languages']
40
41    def __init__(self, window, db, on_category=None, book_ids=None):
42        QDialog.__init__(self, window)
43        Ui_TagCategories.__init__(self)
44        self.setupUi(self)
45        self.blank.setText('\xa0')
46
47        # I can't figure out how to get these into the .ui file
48        self.gridLayout_2.setColumnMinimumWidth(0, 50)
49        self.gridLayout_2.setColumnStretch(0, 1)
50        self.gridLayout_2.setColumnMinimumWidth(2, 50)
51        self.gridLayout_2.setColumnStretch(2, 1)
52
53        # Remove help icon on title bar
54        icon = self.windowIcon()
55        self.setWindowFlags(self.windowFlags()&(~Qt.WindowType.WindowContextHelpButtonHint))
56        self.setWindowIcon(icon)
57
58        self.db = db
59        self.applied_items = []
60        self.book_ids = book_ids
61
62        if self.book_ids is None:
63            self.apply_vl_checkbox.setEnabled(False)
64
65        cc_icon = QIcon(I('column.png'))
66
67        self.category_labels = self.category_labels_orig[:]
68        self.category_icons  = [None, QIcon(I('user_profile.png')), QIcon(I('series.png')),
69                           QIcon(I('publisher.png')), QIcon(I('tags.png')),
70                           QIcon(I('languages.png'))]
71        self.category_values = [None,
72                           lambda: [t.original_name.replace('|', ',') for t in self.db_categories['authors']],
73                           lambda: [t.original_name for t in self.db_categories['series']],
74                           lambda: [t.original_name for t in self.db_categories['publisher']],
75                           lambda: [t.original_name for t in self.db_categories['tags']],
76                           lambda: [t.original_name for t in self.db_categories['languages']]
77                          ]
78        category_names  = ['', _('Authors'), ngettext('Series', 'Series', 2),
79                            _('Publishers'), _('Tags'), _('Languages')]
80
81        for key,cc in iteritems(self.db.custom_field_metadata()):
82            if cc['datatype'] in ['text', 'series', 'enumeration']:
83                self.category_labels.append(key)
84                self.category_icons.append(cc_icon)
85                self.category_values.append(lambda col=key: [t.original_name for t in self.db_categories[col]])
86                category_names.append(cc['name'])
87            elif cc['datatype'] == 'composite' and \
88                    cc['display'].get('make_category', False):
89                self.category_labels.append(key)
90                self.category_icons.append(cc_icon)
91                category_names.append(cc['name'])
92                self.category_values.append(lambda col=key: [t.original_name for t in self.db_categories[col]])
93        self.categories = dict.copy(db.new_api.pref('user_categories', {}))
94        if self.categories is None:
95            self.categories = {}
96        self.initialize_category_lists(book_ids=None)
97
98        self.display_filtered_categories(0)
99
100        for v in category_names:
101            self.category_filter_box.addItem(v)
102        self.current_cat_name = None
103
104        self.copy_category_name_to_clipboard.clicked.connect(self.copy_category_name_to_clipboard_clicked)
105        self.apply_button.clicked.connect(self.apply_button_clicked)
106        self.unapply_button.clicked.connect(self.unapply_button_clicked)
107        self.add_category_button.clicked.connect(self.add_category)
108        self.rename_category_button.clicked.connect(self.rename_category)
109        self.category_box.currentIndexChanged[int].connect(self.select_category)
110        self.category_filter_box.currentIndexChanged[int].connect(
111                                                self.display_filtered_categories)
112        self.item_filter_box.textEdited.connect(self.display_filtered_items)
113        self.delete_category_button.clicked.connect(self.del_category)
114        if islinux:
115            self.available_items_box.itemDoubleClicked.connect(self.apply_tags)
116        else:
117            self.available_items_box.itemActivated.connect(self.apply_tags)
118        self.applied_items_box.itemActivated.connect(self.unapply_tags)
119        self.apply_vl_checkbox.clicked.connect(self.apply_vl)
120
121        self.populate_category_list()
122        if on_category is not None:
123            l = self.category_box.findText(on_category)
124            if l >= 0:
125                self.category_box.setCurrentIndex(l)
126        if self.current_cat_name is None:
127            self.category_box.setCurrentIndex(0)
128            self.select_category(0)
129
130    def copy_category_name_to_clipboard_clicked(self):
131        t = self.category_box.itemText(self.category_box.currentIndex())
132        QApplication.clipboard().setText(t)
133
134    def initialize_category_lists(self, book_ids):
135        self.db_categories = self.db.new_api.get_categories(book_ids=book_ids)
136        self.all_items = []
137        self.all_items_dict = {}
138        for idx,label in enumerate(self.category_labels):
139            if idx == 0:
140                continue
141            for n in self.category_values[idx]():
142                t = Item(name=n, label=label, index=len(self.all_items),
143                         icon=self.category_icons[idx], exists=True)
144                self.all_items.append(t)
145                self.all_items_dict[icu_lower(label+':'+n)] = t
146
147        for cat in self.categories:
148            for item,l in enumerate(self.categories[cat]):
149                key = icu_lower(':'.join([l[1], l[0]]))
150                t = self.all_items_dict.get(key, None)
151                if l[1] in self.category_labels:
152                    if t is None:
153                        t = Item(name=l[0], label=l[1], index=len(self.all_items),
154                                 icon=self.category_icons[self.category_labels.index(l[1])],
155                                 exists=False)
156                        self.all_items.append(t)
157                        self.all_items_dict[key] = t
158                    l[2] = t.index
159                else:
160                    # remove any references to a category that no longer exists
161                    del self.categories[cat][item]
162
163        self.all_items_sorted = sorted(self.all_items, key=lambda x: sort_key(x.name))
164
165    def apply_vl(self, checked):
166        if checked:
167            self.initialize_category_lists(self.book_ids)
168        else:
169            self.initialize_category_lists(None)
170        self.fill_applied_items()
171
172    def make_list_widget(self, item):
173        n = item.name if item.exists else item.name + _(' (not on any book)')
174        w = QListWidgetItem(item.icon, n)
175        w.setData(Qt.ItemDataRole.UserRole, item.index)
176        w.setToolTip(_('Category lookup name: ') + item.label)
177        return w
178
179    def display_filtered_items(self, text):
180        self.display_filtered_categories(None)
181
182    def display_filtered_categories(self, idx):
183        idx = idx if idx is not None else self.category_filter_box.currentIndex()
184        self.available_items_box.clear()
185        self.applied_items_box.clear()
186        item_filter = self.item_filter_box.text()
187        for item in self.all_items_sorted:
188            if idx == 0 or item.label == self.category_labels[idx]:
189                if item.index not in self.applied_items and item.exists:
190                    if primary_contains(item_filter, item.name):
191                        self.available_items_box.addItem(self.make_list_widget(item))
192        for index in self.applied_items:
193            self.applied_items_box.addItem(self.make_list_widget(self.all_items[index]))
194
195    def apply_button_clicked(self):
196        self.apply_tags(node=None)
197
198    def apply_tags(self, node=None):
199        if self.current_cat_name is None:
200            return
201        nodes = self.available_items_box.selectedItems() if node is None else [node]
202        if len(nodes) == 0:
203            warning_dialog(self, _('No items selected'),
204                           _('You must select items to apply'),
205                           show=True, show_copy_button=False)
206            return
207        for node in nodes:
208            index = self.all_items[node.data(Qt.ItemDataRole.UserRole)].index
209            if index not in self.applied_items:
210                self.applied_items.append(index)
211        self.applied_items.sort(key=lambda x:sort_key(self.all_items[x].name))
212        self.display_filtered_categories(None)
213
214    def unapply_button_clicked(self):
215        self.unapply_tags(node=None)
216
217    def unapply_tags(self, node=None):
218        nodes = self.applied_items_box.selectedItems() if node is None else [node]
219        if len(nodes) == 0:
220            warning_dialog(self, _('No items selected'),
221                           _('You must select items to unapply'),
222                           show=True, show_copy_button=False)
223            return
224        for node in nodes:
225            index = self.all_items[node.data(Qt.ItemDataRole.UserRole)].index
226            self.applied_items.remove(index)
227        self.display_filtered_categories(None)
228
229    def add_category(self):
230        self.save_category()
231        cat_name = str(self.input_box.text()).strip()
232        if cat_name == '':
233            return False
234        comps = [c.strip() for c in cat_name.split('.') if c.strip()]
235        if len(comps) == 0 or '.'.join(comps) != cat_name:
236            error_dialog(self, _('Invalid name'),
237                    _('That name contains leading or trailing periods, '
238                      'multiple periods in a row or spaces before '
239                      'or after periods.')).exec()
240            return False
241        for c in sorted(self.categories.keys(), key=sort_key):
242            if strcmp(c, cat_name) == 0 or \
243                    (icu_lower(cat_name).startswith(icu_lower(c) + '.') and
244                     not cat_name.startswith(c + '.')):
245                error_dialog(self, _('Name already used'),
246                        _('That name is already used, perhaps with different case.')).exec()
247                return False
248        if cat_name not in self.categories:
249            self.category_box.clear()
250            self.current_cat_name = cat_name
251            self.categories[cat_name] = []
252            self.applied_items = []
253            self.populate_category_list()
254        self.input_box.clear()
255        self.category_box.setCurrentIndex(self.category_box.findText(cat_name))
256        return True
257
258    def rename_category(self):
259        self.save_category()
260        cat_name = str(self.input_box.text()).strip()
261        if cat_name == '':
262            return False
263        if not self.current_cat_name:
264            return False
265        comps = [c.strip() for c in cat_name.split('.') if c.strip()]
266        if len(comps) == 0 or '.'.join(comps) != cat_name:
267            error_dialog(self, _('Invalid name'),
268                    _('That name contains leading or trailing periods, '
269                      'multiple periods in a row or spaces before '
270                      'or after periods.')).exec()
271            return False
272
273        for c in self.categories:
274            if strcmp(c, cat_name) == 0:
275                error_dialog(self, _('Name already used'),
276                        _('That name is already used, perhaps with different case.')).exec()
277                return False
278        # The order below is important because of signals
279        self.categories[cat_name] = self.categories[self.current_cat_name]
280        del self.categories[self.current_cat_name]
281        self.current_cat_name = None
282        self.populate_category_list()
283        self.input_box.clear()
284        self.category_box.setCurrentIndex(self.category_box.findText(cat_name))
285        return True
286
287    def del_category(self):
288        if self.current_cat_name is not None:
289            if not confirm('<p>'+_('The current User category will be '
290                           '<b>permanently deleted</b>. Are you sure?') +
291                           '</p>', 'tag_category_delete', self):
292                return
293            del self.categories[self.current_cat_name]
294            self.current_cat_name = None
295            self.category_box.removeItem(self.category_box.currentIndex())
296
297    def select_category(self, idx):
298        self.save_category()
299        s = self.category_box.itemText(idx)
300        if s:
301            self.current_cat_name = str(s)
302        else:
303            self.current_cat_name  = None
304        self.fill_applied_items()
305
306    def fill_applied_items(self):
307        if self.current_cat_name:
308            self.applied_items = [cat[2] for cat in self.categories.get(self.current_cat_name, [])]
309        else:
310            self.applied_items = []
311        self.applied_items.sort(key=lambda x:sort_key(self.all_items[x].name))
312        self.display_filtered_categories(None)
313
314    def accept(self):
315        self.save_category()
316        for cat in sorted(self.categories.keys(), key=sort_key):
317            components = cat.split('.')
318            for i in range(0,len(components)):
319                c = '.'.join(components[0:i+1])
320                if c not in self.categories:
321                    self.categories[c] = []
322        QDialog.accept(self)
323
324    def save_category(self):
325        if self.current_cat_name is not None:
326            l = []
327            for index in self.applied_items:
328                item = self.all_items[index]
329                l.append([item.name, item.label, item.index])
330            self.categories[self.current_cat_name] = l
331
332    def populate_category_list(self):
333        self.category_box.blockSignals(True)
334        self.category_box.clear()
335        self.category_box.addItems(sorted(self.categories.keys(), key=sort_key))
336        self.category_box.blockSignals(False)
337