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