1#!/usr/local/bin/python3.8
2
3
4__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
5__docformat__ = 'restructuredtext en'
6__license__   = 'GPL v3'
7
8from functools import partial
9
10from qt.core import (Qt, QDialog, QTableWidgetItem, QAbstractItemView, QIcon,
11                  QDialogButtonBox, QFrame, QLabel, QTimer, QMenu, QApplication,
12                  QByteArray, QItemDelegate, QAction)
13
14from calibre.ebooks.metadata import author_to_author_sort, string_to_authors
15from calibre.gui2 import error_dialog, gprefs
16from calibre.gui2.dialogs.edit_authors_dialog_ui import Ui_EditAuthorsDialog
17from calibre.utils.config import prefs
18from calibre.utils.config_base import tweaks
19from calibre.utils.icu import sort_key, primary_contains, contains, primary_startswith
20
21QT_HIDDEN_CLEAR_ACTION = '_q_qlineeditclearaction'
22
23
24class tableItem(QTableWidgetItem):
25
26    def __init__(self, txt):
27        QTableWidgetItem.__init__(self, txt)
28        self.sort_key = sort_key(str(txt))
29
30    def setText(self, txt):
31        self.sort_key = sort_key(str(txt))
32        QTableWidgetItem.setText(self, txt)
33
34    def set_sort_key(self):
35        self.sort_key = sort_key(str(self.text()))
36
37    def __ge__(self, other):
38        return self.sort_key >= other.sort_key
39
40    def __lt__(self, other):
41        return self.sort_key < other.sort_key
42
43
44class EditColumnDelegate(QItemDelegate):
45
46    def __init__(self, completion_data):
47        QItemDelegate.__init__(self)
48        self.completion_data = completion_data
49
50    def createEditor(self, parent, option, index):
51        if index.column() == 0:
52            if self.completion_data:
53                from calibre.gui2.complete2 import EditWithComplete
54                editor = EditWithComplete(parent)
55                editor.set_separator(None)
56                editor.update_items_cache(self.completion_data)
57            else:
58                from calibre.gui2.widgets import EnLineEdit
59                editor = EnLineEdit(parent)
60            return editor
61        return QItemDelegate.createEditor(self, parent, option, index)
62
63
64class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog):
65
66    def __init__(self, parent, db, id_to_select, select_sort, select_link,
67                 find_aut_func, is_first_letter=False):
68        QDialog.__init__(self, parent)
69        Ui_EditAuthorsDialog.__init__(self)
70        self.setupUi(self)
71
72        # Remove help icon on title bar
73        icon = self.windowIcon()
74        self.setWindowFlags(self.windowFlags()&(~Qt.WindowType.WindowContextHelpButtonHint))
75        self.setWindowIcon(icon)
76
77        try:
78            self.table_column_widths = \
79                        gprefs.get('manage_authors_table_widths', None)
80            geom = gprefs.get('manage_authors_dialog_geometry', None)
81            if geom:
82                QApplication.instance().safe_restore_geometry(self, QByteArray(geom))
83        except Exception:
84            pass
85
86        self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText(_('&OK'))
87        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setText(_('&Cancel'))
88        self.buttonBox.accepted.connect(self.accepted)
89        self.apply_vl_checkbox.stateChanged.connect(self.use_vl_changed)
90
91        # Set up the heading for sorting
92        self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
93
94        self.find_aut_func = find_aut_func
95        self.table.resizeColumnsToContents()
96        if self.table.columnWidth(2) < 200:
97            self.table.setColumnWidth(2, 200)
98
99        # set up the cellChanged signal only after the table is filled
100        self.table.cellChanged.connect(self.cell_changed)
101
102        self.recalc_author_sort.clicked.connect(self.do_recalc_author_sort)
103        self.auth_sort_to_author.clicked.connect(self.do_auth_sort_to_author)
104
105        # Capture clicks on the horizontal header to sort the table columns
106        hh = self.table.horizontalHeader()
107        hh.sectionResized.connect(self.table_column_resized)
108        hh.setSectionsClickable(True)
109        hh.sectionClicked.connect(self.do_sort)
110        hh.setSortIndicatorShown(True)
111
112        # set up the search & filter boxes
113        self.find_box.initialize('manage_authors_search')
114        le = self.find_box.lineEdit()
115        ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION)
116        if ac is not None:
117            ac.triggered.connect(self.clear_find)
118        le.returnPressed.connect(self.do_find)
119        self.find_box.editTextChanged.connect(self.find_text_changed)
120        self.find_button.clicked.connect(self.do_find)
121        self.find_button.setDefault(True)
122
123        self.filter_box.initialize('manage_authors_filter')
124        le = self.filter_box.lineEdit()
125        ac = le.findChild(QAction, QT_HIDDEN_CLEAR_ACTION)
126        if ac is not None:
127            ac.triggered.connect(self.clear_filter)
128        self.filter_box.lineEdit().returnPressed.connect(self.do_filter)
129        self.filter_button.clicked.connect(self.do_filter)
130
131        self.not_found_label = l = QLabel(self.table)
132        l.setFrameStyle(QFrame.Shape.StyledPanel)
133        l.setAutoFillBackground(True)
134        l.setText(_('No matches found'))
135        l.setAlignment(Qt.AlignmentFlag.AlignVCenter)
136        l.resize(l.sizeHint())
137        l.move(10, 2)
138        l.setVisible(False)
139        self.not_found_label_timer = QTimer()
140        self.not_found_label_timer.setSingleShot(True)
141        self.not_found_label_timer.timeout.connect(
142                self.not_found_label_timer_event, type=Qt.ConnectionType.QueuedConnection)
143
144        self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
145        self.table.customContextMenuRequested.connect(self.show_context_menu)
146
147        # Fetch the data
148        self.authors = {}
149        self.original_authors = {}
150        auts = db.new_api.author_data()
151        self.completion_data = []
152        for id_, v in auts.items():
153            name = v['name']
154            name = name.replace('|', ',')
155            self.completion_data.append(name)
156            self.authors[id_] = {'name': name, 'sort': v['sort'], 'link': v['link']}
157            self.original_authors[id_] = {'name': name, 'sort': v['sort'],
158                                          'link': v['link']}
159
160        self.edited_icon = QIcon(I('modified.png'))
161        self.empty_icon = QIcon()
162        if prefs['use_primary_find_in_search']:
163            self.string_contains = primary_contains
164        else:
165            self.string_contains = contains
166
167        self.last_sorted_by = 'sort'
168        self.author_order = 1
169        self.author_sort_order = 0
170        self.link_order = 1
171        self.show_table(id_to_select, select_sort, select_link, is_first_letter)
172
173    def use_vl_changed(self, x):
174        self.show_table(None, None, None, False)
175
176    def clear_filter(self):
177        self.filter_box.setText('')
178        self.show_table(None, None, None, False)
179
180    def do_filter(self):
181        self.show_table(None, None, None, False)
182
183    def show_table(self, id_to_select, select_sort, select_link, is_first_letter):
184        auts_to_show = {t[0] for t in
185                   self.find_aut_func(use_virtual_library=self.apply_vl_checkbox.isChecked())}
186        filter_text = icu_lower(str(self.filter_box.text()))
187        if filter_text:
188            auts_to_show = {id_ for id_ in auts_to_show
189                if self.string_contains(filter_text, icu_lower(self.authors[id_]['name']))}
190
191        self.table.blockSignals(True)
192        self.table.clear()
193        self.table.setColumnCount(3)
194
195        self.table.setRowCount(len(auts_to_show))
196        row = 0
197        for id_, v in self.authors.items():
198            if id_ not in auts_to_show:
199                continue
200            name, sort, link = (v['name'], v['sort'], v['link'])
201            name = name.replace('|', ',')
202
203            name_item = tableItem(name)
204            name_item.setData(Qt.ItemDataRole.UserRole, id_)
205            sort_item = tableItem(sort)
206            link_item = tableItem(link)
207
208            self.table.setItem(row, 0, name_item)
209            self.table.setItem(row, 1, sort_item)
210            self.table.setItem(row, 2, link_item)
211
212            self.set_icon(name_item, id_)
213            self.set_icon(sort_item, id_)
214            self.set_icon(link_item, id_)
215            row += 1
216
217        self.table.setItemDelegate(EditColumnDelegate(self.completion_data))
218        self.table.setHorizontalHeaderLabels([_('Author'), _('Author sort'), _('Link')])
219
220        if self.last_sorted_by == 'sort':
221            self.author_sort_order = 1 - self.author_sort_order
222            self.do_sort_by_author_sort()
223        elif self.last_sorted_by == 'author':
224            self.author_order = 1 - self.author_order
225            self.do_sort_by_author()
226        else:
227            self.link_order = 1 - self.link_order
228            self.do_sort_by_link()
229
230        # Position on the desired item
231        select_item = None
232        if id_to_select:
233            use_as = tweaks['categories_use_field_for_author_name'] == 'author_sort'
234            for row in range(0, len(auts_to_show)):
235                if is_first_letter:
236                    item_txt = str(self.table.item(row, 1).text() if use_as
237                                                else self.table.item(row, 0).text())
238                    if primary_startswith(item_txt, id_to_select):
239                        select_item = self.table.item(row, 1 if use_as else 0)
240                        break
241                elif id_to_select == self.table.item(row, 0).data(Qt.ItemDataRole.UserRole):
242                    if select_sort:
243                        select_item = self.table.item(row, 1)
244                    elif select_link:
245                        select_item = self.table.item(row, 2)
246                    else:
247                        select_item = (self.table.item(row, 1) if use_as
248                                        else self.table.item(row, 0))
249                    break
250        if select_item:
251            self.table.setCurrentItem(select_item)
252            self.table.setFocus(Qt.FocusReason.OtherFocusReason)
253            if select_sort or select_link:
254                self.table.editItem(select_item)
255            self.start_find_pos = select_item.row() * 2 + select_item.column()
256        else:
257            self.table.setCurrentCell(0, 0)
258            self.find_box.setFocus()
259            self.start_find_pos = -1
260        self.table.blockSignals(False)
261
262    def save_state(self):
263        self.table_column_widths = []
264        for c in range(0, self.table.columnCount()):
265            self.table_column_widths.append(self.table.columnWidth(c))
266        gprefs['manage_authors_table_widths'] = self.table_column_widths
267        gprefs['manage_authors_dialog_geometry'] = bytearray(self.saveGeometry())
268
269    def table_column_resized(self, col, old, new):
270        self.table_column_widths = []
271        for c in range(0, self.table.columnCount()):
272            self.table_column_widths.append(self.table.columnWidth(c))
273
274    def resizeEvent(self, *args):
275        QDialog.resizeEvent(self, *args)
276        if self.table_column_widths is not None:
277            for c,w in enumerate(self.table_column_widths):
278                self.table.setColumnWidth(c, w)
279        else:
280            # the vertical scroll bar might not be rendered, so might not yet
281            # have a width. Assume 25. Not a problem because user-changed column
282            # widths will be remembered
283            w = self.table.width() - 25 - self.table.verticalHeader().width()
284            w //= self.table.columnCount()
285            for c in range(0, self.table.columnCount()):
286                self.table.setColumnWidth(c, w)
287        self.save_state()
288
289    def get_column_name(self, column):
290        return ['name', 'sort', 'link'][column]
291
292    def show_context_menu(self, point):
293        self.context_item = self.table.itemAt(point)
294        case_menu = QMenu(_('Change case'))
295        case_menu.setIcon(QIcon(I('font_size_larger.png')))
296        action_upper_case = case_menu.addAction(_('Upper case'))
297        action_lower_case = case_menu.addAction(_('Lower case'))
298        action_swap_case = case_menu.addAction(_('Swap case'))
299        action_title_case = case_menu.addAction(_('Title case'))
300        action_capitalize = case_menu.addAction(_('Capitalize'))
301
302        action_upper_case.triggered.connect(self.upper_case)
303        action_lower_case.triggered.connect(self.lower_case)
304        action_swap_case.triggered.connect(self.swap_case)
305        action_title_case.triggered.connect(self.title_case)
306        action_capitalize.triggered.connect(self.capitalize)
307
308        m = self.au_context_menu = QMenu(self)
309        idx = self.table.indexAt(point)
310        id_ = int(self.table.item(idx.row(), 0).data(Qt.ItemDataRole.UserRole))
311        sub = self.get_column_name(idx.column())
312        if self.context_item.text() != self.original_authors[id_][sub]:
313            ca = m.addAction(QIcon(I('undo.png')), _('Undo'))
314            ca.triggered.connect(partial(self.undo_cell,
315                                         old_value=self.original_authors[id_][sub]))
316            m.addSeparator()
317        ca = m.addAction(QIcon(I('edit-copy.png')), _('Copy'))
318        ca.triggered.connect(self.copy_to_clipboard)
319        ca = m.addAction(QIcon(I('edit-paste.png')), _('Paste'))
320        ca.triggered.connect(self.paste_from_clipboard)
321        m.addSeparator()
322        if self.context_item is not None and self.context_item.column() == 0:
323            ca = m.addAction(_('Copy to author sort'))
324            ca.triggered.connect(self.copy_au_to_aus)
325            m.addSeparator()
326            ca = m.addAction(QIcon(I('lt.png')), _("Show books by author in book list"))
327            ca.triggered.connect(self.search_in_book_list)
328        else:
329            ca = m.addAction(_('Copy to author'))
330            ca.triggered.connect(self.copy_aus_to_au)
331        m.addSeparator()
332        m.addMenu(case_menu)
333        m.exec(self.table.mapToGlobal(point))
334
335    def undo_cell(self, old_value):
336        self.context_item.setText(old_value)
337
338    def search_in_book_list(self):
339        from calibre.gui2.ui import get_gui
340        row = self.context_item.row()
341        get_gui().search.set_search_string('authors:="%s"' %
342                           str(self.table.item(row, 0).text()).replace(r'"', r'\"'))
343
344    def copy_to_clipboard(self):
345        cb = QApplication.clipboard()
346        cb.setText(str(self.context_item.text()))
347
348    def paste_from_clipboard(self):
349        cb = QApplication.clipboard()
350        self.context_item.setText(cb.text())
351
352    def upper_case(self):
353        self.context_item.setText(icu_upper(str(self.context_item.text())))
354
355    def lower_case(self):
356        self.context_item.setText(icu_lower(str(self.context_item.text())))
357
358    def swap_case(self):
359        self.context_item.setText(str(self.context_item.text()).swapcase())
360
361    def title_case(self):
362        from calibre.utils.titlecase import titlecase
363        self.context_item.setText(titlecase(str(self.context_item.text())))
364
365    def capitalize(self):
366        from calibre.utils.icu import capitalize
367        self.context_item.setText(capitalize(str(self.context_item.text())))
368
369    def copy_aus_to_au(self):
370        row = self.context_item.row()
371        dest = self.table.item(row, 0)
372        dest.setText(self.context_item.text())
373
374    def copy_au_to_aus(self):
375        row = self.context_item.row()
376        dest = self.table.item(row, 1)
377        dest.setText(self.context_item.text())
378
379    def not_found_label_timer_event(self):
380        self.not_found_label.setVisible(False)
381
382    def clear_find(self):
383        self.find_box.setText('')
384        self.start_find_pos = -1
385        self.do_find()
386
387    def find_text_changed(self):
388        self.start_find_pos = -1
389
390    def do_find(self):
391        self.not_found_label.setVisible(False)
392        # For some reason the button box keeps stealing the RETURN shortcut.
393        # Steal it back
394        self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setDefault(False)
395        self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setAutoDefault(False)
396        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(False)
397        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setAutoDefault(False)
398
399        st = icu_lower(str(self.find_box.currentText()))
400        if not st:
401            return
402        for _ in range(0, self.table.rowCount()*2):
403            self.start_find_pos = (self.start_find_pos + 1) % (self.table.rowCount()*2)
404            r = (self.start_find_pos//2) % self.table.rowCount()
405            c = self.start_find_pos % 2
406            item = self.table.item(r, c)
407            text = icu_lower(str(item.text()))
408            if st in text:
409                self.table.setCurrentItem(item)
410                self.table.setFocus(Qt.FocusReason.OtherFocusReason)
411                return
412        # Nothing found. Pop up the little dialog for 1.5 seconds
413        self.not_found_label.setVisible(True)
414        self.not_found_label_timer.start(1500)
415
416    def do_sort(self, section):
417        (self.do_sort_by_author, self.do_sort_by_author_sort, self.do_sort_by_link)[section]()
418
419    def do_sort_by_author(self):
420        self.last_sorted_by = 'author'
421        self.author_order = 1 - self.author_order
422        self.table.sortByColumn(0, self.author_order)
423
424    def do_sort_by_author_sort(self):
425        self.last_sorted_by = 'sort'
426        self.author_sort_order = 1 - self.author_sort_order
427        self.table.sortByColumn(1, self.author_sort_order)
428
429    def do_sort_by_link(self):
430        self.last_sorted_by = 'link'
431        self.link_order = 1 - self.link_order
432        self.table.sortByColumn(2, self.link_order)
433
434    def accepted(self):
435        self.save_state()
436        self.result = []
437        for id_, v in self.authors.items():
438            orig = self.original_authors[id_]
439            if orig != v:
440                self.result.append((id_, orig['name'], v['name'], v['sort'], v['link']))
441
442    def do_recalc_author_sort(self):
443        self.table.cellChanged.disconnect()
444        for row in range(0,self.table.rowCount()):
445            item_aut = self.table.item(row, 0)
446            id_ = int(item_aut.data(Qt.ItemDataRole.UserRole))
447            aut  = str(item_aut.text()).strip()
448            item_aus = self.table.item(row, 1)
449            # Sometimes trailing commas are left by changing between copy algs
450            aus = str(author_to_author_sort(aut)).rstrip(',')
451            item_aus.setText(aus)
452            self.authors[id_]['sort'] = aus
453            self.set_icon(item_aus, id_)
454        self.table.setFocus(Qt.FocusReason.OtherFocusReason)
455        self.table.cellChanged.connect(self.cell_changed)
456
457    def do_auth_sort_to_author(self):
458        self.table.cellChanged.disconnect()
459        for row in range(0,self.table.rowCount()):
460            aus  = str(self.table.item(row, 1).text()).strip()
461            item_aut = self.table.item(row, 0)
462            id_ = int(item_aut.data(Qt.ItemDataRole.UserRole))
463            item_aut.setText(aus)
464            self.authors[id_]['name'] = aus
465            self.set_icon(item_aut, id_)
466        self.table.setFocus(Qt.FocusReason.OtherFocusReason)
467        self.table.cellChanged.connect(self.cell_changed)
468
469    def set_icon(self, item, id_):
470        col_name = self.get_column_name(item.column())
471        if str(item.text()) != self.original_authors[id_][col_name]:
472            item.setIcon(self.edited_icon)
473        else:
474            item.setIcon(self.empty_icon)
475
476    def cell_changed(self, row, col):
477        id_ = int(self.table.item(row, 0).data(Qt.ItemDataRole.UserRole))
478        if col == 0:
479            item = self.table.item(row, 0)
480            aut  = str(item.text()).strip()
481            aut_list = string_to_authors(aut)
482            if len(aut_list) != 1:
483                error_dialog(self.parent(), _('Invalid author name'),
484                        _('You cannot change an author to multiple authors.')).exec()
485                aut = ' % '.join(aut_list)
486                self.table.item(row, 0).setText(aut)
487            item.set_sort_key()
488            self.authors[id_]['name'] = aut
489            self.set_icon(item, id_)
490            c = self.table.item(row, 1)
491            txt = author_to_author_sort(aut)
492            self.authors[id_]['sort'] = txt
493            c.setText(txt)  # This triggers another cellChanged event
494            item = c
495        else:
496            item  = self.table.item(row, col)
497            item.set_sort_key()
498            self.set_icon(item, id_)
499            self.authors[id_][self.get_column_name(col)] = str(item.text())
500        self.table.setCurrentItem(item)
501        self.table.scrollToItem(item)
502