1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:fdm=marker:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import os 10from collections import OrderedDict 11from qt.core import ( 12 QCheckBox, QDialog, QDialogButtonBox, QGridLayout, QIcon, QLabel, QTimer 13) 14 15from calibre.gui2 import error_dialog, gprefs, question_dialog 16from calibre.gui2.actions import InterfaceAction 17from calibre.utils.monotonic import monotonic 18from polyglot.builtins import iteritems 19 20SUPPORTED = {'EPUB', 'AZW3'} 21 22 23class ChooseFormat(QDialog): # {{{ 24 25 def __init__(self, formats, parent=None): 26 QDialog.__init__(self, parent) 27 self.setWindowTitle(_('Choose format to edit')) 28 self.setWindowIcon(QIcon(I('dialog_question.png'))) 29 l = self.l = QGridLayout() 30 self.setLayout(l) 31 la = self.la = QLabel(_('Choose which format you want to edit:')) 32 formats = sorted(formats) 33 l.addWidget(la, 0, 0, 1, -1) 34 self.buttons = [] 35 for i, f in enumerate(formats): 36 b = QCheckBox('&' + f, self) 37 l.addWidget(b, 1, i) 38 self.buttons.append(b) 39 self.formats = gprefs.get('edit_toc_last_selected_formats', ['EPUB',]) 40 bb = self.bb = QDialogButtonBox( 41 QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) 42 bb.addButton(_('&All formats'), 43 QDialogButtonBox.ButtonRole.ActionRole).clicked.connect(self.do_all) 44 bb.accepted.connect(self.accept) 45 bb.rejected.connect(self.reject) 46 l.addWidget(bb, l.rowCount(), 0, 1, -1) 47 self.resize(self.sizeHint()) 48 connect_lambda(self.finished, self, lambda self, code:gprefs.set('edit_toc_last_selected_formats', list(self.formats))) 49 50 def do_all(self): 51 for b in self.buttons: 52 b.setChecked(True) 53 self.accept() 54 55 @property 56 def formats(self): 57 for b in self.buttons: 58 if b.isChecked(): 59 yield str(b.text())[1:] 60 61 @formats.setter 62 def formats(self, formats): 63 formats = {x.upper() for x in formats} 64 for b in self.buttons: 65 b.setChecked(b.text()[1:] in formats) 66 67# }}} 68 69 70class ToCEditAction(InterfaceAction): 71 72 name = 'Edit ToC' 73 action_spec = (_('Edit ToC'), 'toc.png', 74 _('Edit the Table of Contents in your books'), _('K')) 75 dont_add_to = frozenset(['context-menu-device']) 76 action_type = 'current' 77 accepts_drops = True 78 79 def accept_enter_event(self, event, mime_data): 80 if mime_data.hasFormat("application/calibre+from_library"): 81 return True 82 return False 83 84 def accept_drag_move_event(self, event, mime_data): 85 if mime_data.hasFormat("application/calibre+from_library"): 86 return True 87 return False 88 89 def drop_event(self, event, mime_data): 90 mime = 'application/calibre+from_library' 91 if mime_data.hasFormat(mime): 92 self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split())) 93 QTimer.singleShot(1, self.do_drop) 94 return True 95 return False 96 97 def do_drop(self): 98 book_id_map = self.get_supported_books(self.dropped_ids) 99 del self.dropped_ids 100 if book_id_map: 101 self.do_edit(book_id_map) 102 103 def genesis(self): 104 self.qaction.triggered.connect(self.edit_books) 105 self.jobs = [] 106 107 def get_supported_books(self, book_ids): 108 db = self.gui.library_view.model().db 109 supported = set(SUPPORTED) 110 ans = [(x, set((db.formats(x, index_is_id=True) or '').split(',')) 111 .intersection(supported)) for x in book_ids] 112 ans = [x for x in ans if x[1]] 113 if not ans: 114 error_dialog(self.gui, _('Cannot edit ToC'), 115 _('Editing Table of Contents is only supported for books in the %s' 116 ' formats. Convert to one of those formats before polishing.') 117 %_(' or ').join(sorted(supported)), show=True) 118 ans = OrderedDict(ans) 119 if len(ans) > 5: 120 if not question_dialog(self.gui, _('Are you sure?'), _( 121 'You have chosen to edit the Table of Contents of {} books at once.' 122 ' Doing so will likely slow your computer to a crawl. Are you sure?' 123 ).format(len(ans))): 124 return 125 return ans 126 127 def get_books_for_editing(self): 128 rows = [r.row() for r in 129 self.gui.library_view.selectionModel().selectedRows()] 130 if not rows or len(rows) == 0: 131 d = error_dialog(self.gui, _('Cannot edit ToC'), 132 _('No books selected')) 133 d.exec() 134 return None 135 db = self.gui.current_db 136 ans = (db.id(r) for r in rows) 137 return self.get_supported_books(ans) 138 139 def do_edit(self, book_id_map): 140 for book_id, fmts in iteritems(book_id_map): 141 if len(fmts) > 1: 142 d = ChooseFormat(fmts, self.gui) 143 if d.exec() != QDialog.DialogCode.Accepted: 144 return 145 fmts = d.formats 146 for fmt in fmts: 147 self.do_one(book_id, fmt) 148 149 def do_one(self, book_id, fmt): 150 db = self.gui.current_db 151 path = db.format(book_id, fmt, index_is_id=True, as_path=True) 152 title = db.title(book_id, index_is_id=True) + ' [%s]'%fmt 153 data = {'path': path, 'title': title} 154 self.gui.job_manager.launch_gui_app('toc-dialog', kwargs=data) 155 job = data.copy() 156 job.update({'book_id': book_id, 'fmt': fmt, 'library_id': db.new_api.library_id, 'started': False, 'start_time': monotonic()}) 157 self.jobs.append(job) 158 self.check_for_completions() 159 160 def check_for_completions(self): 161 from calibre.utils.filenames import retry_on_fail 162 for job in tuple(self.jobs): 163 started_path = job['path'] + '.started' 164 result_path = job['path'] + '.result' 165 if job['started'] and os.path.exists(result_path): 166 self.jobs.remove(job) 167 ret = -1 168 169 def read(result_path): 170 nonlocal ret 171 with open(result_path) as f: 172 ret = int(f.read().strip()) 173 174 retry_on_fail(read, result_path) 175 retry_on_fail(os.remove, result_path) 176 if ret == 0: 177 db = self.gui.current_db 178 if db.new_api.library_id != job['library_id']: 179 error_dialog(self.gui, _('Library changed'), _( 180 'Cannot save changes made to {0} by the ToC editor as' 181 ' the calibre library has changed.').format(job['title']), show=True) 182 else: 183 db.new_api.add_format(job['book_id'], job['fmt'], job['path'], run_hooks=False) 184 os.remove(job['path']) 185 else: 186 if monotonic() - job['start_time'] > 120: 187 self.jobs.remove(job) 188 continue 189 if os.path.exists(started_path): 190 job['started'] = True 191 retry_on_fail(os.remove, started_path) 192 if self.jobs: 193 QTimer.singleShot(100, self.check_for_completions) 194 195 def edit_books(self): 196 book_id_map = self.get_books_for_editing() 197 if not book_id_map: 198 return 199 self.do_edit(book_id_map) 200