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, weakref, shutil, textwrap 10from collections import OrderedDict 11from functools import partial 12from polyglot.builtins import iteritems, itervalues 13 14from qt.core import (QDialog, QGridLayout, QIcon, QCheckBox, QLabel, QFrame, 15 QApplication, QDialogButtonBox, Qt, QSize, QSpacerItem, 16 QSizePolicy, QTimer, QModelIndex, QTextEdit, 17 QInputDialog, QMenu) 18 19from calibre.gui2 import error_dialog, Dispatcher, gprefs, question_dialog 20from calibre.gui2.actions import InterfaceAction 21from calibre.gui2.convert.metadata import create_opf_file 22from calibre.gui2.dialogs.progress import ProgressDialog 23from calibre.ptempfile import PersistentTemporaryDirectory 24from calibre.utils.config_base import tweaks 25 26 27class Polish(QDialog): # {{{ 28 29 def __init__(self, db, book_id_map, parent=None): 30 from calibre.ebooks.oeb.polish.main import HELP 31 QDialog.__init__(self, parent) 32 self.db, self.book_id_map = weakref.ref(db), book_id_map 33 self.setWindowIcon(QIcon(I('polish.png'))) 34 title = _('Polish book') 35 if len(book_id_map) > 1: 36 title = _('Polish %d books')%len(book_id_map) 37 self.setWindowTitle(title) 38 39 self.help_text = { 40 'polish': _('<h3>About Polishing books</h3>%s')%HELP['about'].format( 41 _('''<p>If you have both EPUB and ORIGINAL_EPUB in your book, 42 then polishing will run on ORIGINAL_EPUB (the same for other 43 ORIGINAL_* formats). So if you 44 want Polishing to not run on the ORIGINAL_* format, delete the 45 ORIGINAL_* format before running it.</p>''') 46 ), 47 48 'embed':_('<h3>Embed referenced fonts</h3>%s')%HELP['embed'], 49 'subset':_('<h3>Subsetting fonts</h3>%s')%HELP['subset'], 50 51 'smarten_punctuation': 52 _('<h3>Smarten punctuation</h3>%s')%HELP['smarten_punctuation'], 53 54 'metadata':_('<h3>Updating metadata</h3>' 55 '<p>This will update all metadata <i>except</i> the cover in the' 56 ' e-book files to match the current metadata in the' 57 ' calibre library.</p>' 58 ' <p>Note that most e-book' 59 ' formats are not capable of supporting all the' 60 ' metadata in calibre.</p><p>There is a separate option to' 61 ' update the cover.</p>'), 62 'do_cover': _('<h3>Update cover</h3><p>Update the covers in the e-book files to match the' 63 ' current cover in the calibre library.</p>' 64 '<p>If the e-book file does not have' 65 ' an identifiable cover, a new cover is inserted.</p>' 66 ), 67 'jacket':_('<h3>Book jacket</h3>%s')%HELP['jacket'], 68 'remove_jacket':_('<h3>Remove book jacket</h3>%s')%HELP['remove_jacket'], 69 'remove_unused_css':_('<h3>Remove unused CSS rules</h3>%s')%HELP['remove_unused_css'], 70 'compress_images': _('<h3>Losslessly compress images</h3>%s') % HELP['compress_images'], 71 'add_soft_hyphens': _('<h3>Add soft-hyphens</h3>%s') % HELP['add_soft_hyphens'], 72 'remove_soft_hyphens': _('<h3>Remove soft-hyphens</h3>%s') % HELP['remove_soft_hyphens'], 73 'upgrade_book': _('<h3>Upgrade book internals</h3>%s') % HELP['upgrade_book'], 74 } 75 76 self.l = l = QGridLayout() 77 self.setLayout(l) 78 79 self.la = la = QLabel('<b>'+_('Select actions to perform:')) 80 l.addWidget(la, 0, 0, 1, 2) 81 82 count = 0 83 self.all_actions = OrderedDict([ 84 ('embed', _('&Embed all referenced fonts')), 85 ('subset', _('&Subset all embedded fonts')), 86 ('smarten_punctuation', _('Smarten &punctuation')), 87 ('metadata', _('Update &metadata in the book files')), 88 ('do_cover', _('Update the &cover in the book files')), 89 ('jacket', _('Add/replace metadata as a "book &jacket" page')), 90 ('remove_jacket', _('&Remove a previously inserted book jacket')), 91 ('remove_unused_css', _('Remove &unused CSS rules from the book')), 92 ('compress_images', _('Losslessly &compress images')), 93 ('add_soft_hyphens', _('Add s&oft hyphens')), 94 ('remove_soft_hyphens', _('Remove so&ft hyphens')), 95 ('upgrade_book', _('&Upgrade book internals')), 96 ]) 97 prefs = gprefs.get('polishing_settings', {}) 98 for name, text in iteritems(self.all_actions): 99 count += 1 100 x = QCheckBox(text, self) 101 x.setChecked(prefs.get(name, False)) 102 x.setObjectName(name) 103 connect_lambda(x.stateChanged, self, lambda self, state: self.option_toggled(self.sender().objectName(), state)) 104 l.addWidget(x, count, 0, 1, 1) 105 setattr(self, 'opt_'+name, x) 106 la = QLabel(' <a href="#%s">%s</a>'%(name, _('About'))) 107 setattr(self, 'label_'+name, x) 108 la.linkActivated.connect(self.help_link_activated) 109 l.addWidget(la, count, 1, 1, 1) 110 111 count += 1 112 l.addItem(QSpacerItem(10, 10, vPolicy=QSizePolicy.Policy.Expanding), count, 1, 1, 2) 113 114 la = self.help_label = QLabel('') 115 self.help_link_activated('#polish') 116 la.setWordWrap(True) 117 la.setTextFormat(Qt.TextFormat.RichText) 118 la.setFrameShape(QFrame.Shape.StyledPanel) 119 la.setAlignment(Qt.AlignmentFlag.AlignLeft|Qt.AlignmentFlag.AlignTop) 120 la.setLineWidth(2) 121 la.setStyleSheet('QLabel { margin-left: 75px }') 122 l.addWidget(la, 0, 2, count+1, 1) 123 l.setColumnStretch(2, 1) 124 125 self.show_reports = sr = QCheckBox(_('Show &report'), self) 126 sr.setChecked(gprefs.get('polish_show_reports', True)) 127 sr.setToolTip(textwrap.fill(_('Show a report of all the actions performed' 128 ' after polishing is completed'))) 129 l.addWidget(sr, count+1, 0, 1, 1) 130 self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) 131 bb.accepted.connect(self.accept) 132 bb.rejected.connect(self.reject) 133 self.save_button = sb = bb.addButton(_('&Save settings'), QDialogButtonBox.ButtonRole.ActionRole) 134 sb.clicked.connect(self.save_settings) 135 self.load_button = lb = bb.addButton(_('&Load settings'), QDialogButtonBox.ButtonRole.ActionRole) 136 self.load_menu = QMenu(lb) 137 lb.setMenu(self.load_menu) 138 self.all_button = b = bb.addButton(_('Select &all'), QDialogButtonBox.ButtonRole.ActionRole) 139 connect_lambda(b.clicked, self, lambda self: self.select_all(True)) 140 self.none_button = b = bb.addButton(_('Select &none'), QDialogButtonBox.ButtonRole.ActionRole) 141 connect_lambda(b.clicked, self, lambda self: self.select_all(False)) 142 l.addWidget(bb, count+1, 1, 1, -1) 143 self.setup_load_button() 144 145 self.resize(QSize(950, 600)) 146 147 def select_all(self, enable): 148 for action in self.all_actions: 149 x = getattr(self, 'opt_'+action) 150 x.blockSignals(True) 151 x.setChecked(enable) 152 x.blockSignals(False) 153 154 def save_settings(self): 155 if not self.something_selected: 156 return error_dialog(self, _('No actions selected'), 157 _('You must select at least one action before saving'), 158 show=True) 159 name, ok = QInputDialog.getText(self, _('Choose name'), 160 _('Choose a name for these settings')) 161 if ok: 162 name = str(name).strip() 163 if name: 164 settings = {ac:getattr(self, 'opt_'+ac).isChecked() for ac in 165 self.all_actions} 166 saved = gprefs.get('polish_settings', {}) 167 saved[name] = settings 168 gprefs.set('polish_settings', saved) 169 self.setup_load_button() 170 171 def setup_load_button(self): 172 saved = gprefs.get('polish_settings', {}) 173 m = self.load_menu 174 m.clear() 175 self.__actions = [] 176 a = self.__actions.append 177 for name in sorted(saved): 178 a(m.addAction(name, partial(self.load_settings, name))) 179 m.addSeparator() 180 a(m.addAction(_('Remove saved settings'), self.clear_settings)) 181 self.load_button.setEnabled(bool(saved)) 182 183 def clear_settings(self): 184 gprefs.set('polish_settings', {}) 185 self.setup_load_button() 186 187 def load_settings(self, name): 188 saved = gprefs.get('polish_settings', {}).get(name, {}) 189 for action in self.all_actions: 190 checked = saved.get(action, False) 191 x = getattr(self, 'opt_'+action) 192 x.blockSignals(True) 193 x.setChecked(checked) 194 x.blockSignals(False) 195 196 def option_toggled(self, name, state): 197 if state == Qt.CheckState.Checked: 198 self.help_label.setText(self.help_text[name]) 199 200 def help_link_activated(self, link): 201 link = str(link)[1:] 202 self.help_label.setText(self.help_text[link]) 203 204 @property 205 def something_selected(self): 206 for action in self.all_actions: 207 if getattr(self, 'opt_'+action).isChecked(): 208 return True 209 return False 210 211 def accept(self): 212 self.actions = ac = {} 213 saved_prefs = {} 214 gprefs['polish_show_reports'] = bool(self.show_reports.isChecked()) 215 something = False 216 for action in self.all_actions: 217 ac[action] = saved_prefs[action] = bool(getattr(self, 'opt_'+action).isChecked()) 218 if ac[action]: 219 something = True 220 if ac['jacket'] and not ac['metadata']: 221 if not question_dialog(self, _('Must update metadata'), 222 _('You have selected the option to add metadata as ' 223 'a "book jacket". For this option to work, you ' 224 'must also select the option to update metadata in' 225 ' the book files. Do you want to select it?')): 226 return 227 ac['metadata'] = saved_prefs['metadata'] = True 228 self.opt_metadata.setChecked(True) 229 if ac['jacket'] and ac['remove_jacket']: 230 if not question_dialog(self, _('Add or remove jacket?'), _( 231 'You have chosen to both add and remove the metadata jacket.' 232 ' This will result in the final book having no jacket. Is this' 233 ' what you want?')): 234 return 235 if not something: 236 return error_dialog(self, _('No actions selected'), 237 _('You must select at least one action, or click Cancel.'), 238 show=True) 239 gprefs['polishing_settings'] = saved_prefs 240 self.queue_files() 241 return super().accept() 242 243 def queue_files(self): 244 self.tdir = PersistentTemporaryDirectory('_queue_polish') 245 self.jobs = [] 246 if len(self.book_id_map) <= 5: 247 for i, (book_id, formats) in enumerate(iteritems(self.book_id_map)): 248 self.do_book(i+1, book_id, formats) 249 else: 250 self.queue = [(i+1, id_) for i, id_ in enumerate(self.book_id_map)] 251 self.pd = ProgressDialog(_('Queueing books for polishing'), 252 max=len(self.queue), parent=self) 253 QTimer.singleShot(0, self.do_one) 254 self.pd.exec() 255 256 def do_one(self): 257 if not self.queue: 258 self.pd.accept() 259 return 260 if self.pd.canceled: 261 self.jobs = [] 262 self.pd.reject() 263 return 264 num, book_id = self.queue.pop(0) 265 try: 266 self.do_book(num, book_id, self.book_id_map[book_id]) 267 except: 268 self.pd.reject() 269 raise 270 else: 271 self.pd.set_value(num) 272 QTimer.singleShot(0, self.do_one) 273 274 def do_book(self, num, book_id, formats): 275 base = os.path.join(self.tdir, str(book_id)) 276 os.mkdir(base) 277 db = self.db() 278 opf = os.path.join(base, 'metadata.opf') 279 with open(opf, 'wb') as opf_file: 280 mi = create_opf_file(db, book_id, opf_file=opf_file)[0] 281 data = {'opf':opf, 'files':[]} 282 for action in self.actions: 283 data[action] = bool(getattr(self, 'opt_'+action).isChecked()) 284 cover = os.path.join(base, 'cover.jpg') 285 if db.copy_cover_to(book_id, cover, index_is_id=True): 286 data['cover'] = cover 287 is_orig = {} 288 for fmt in formats: 289 ext = fmt.replace('ORIGINAL_', '').lower() 290 is_orig[ext.upper()] = 'ORIGINAL_' in fmt 291 with open(os.path.join(base, '%s.%s'%(book_id, ext)), 'wb') as f: 292 db.copy_format_to(book_id, fmt, f, index_is_id=True) 293 data['files'].append(f.name) 294 295 nums = num 296 if hasattr(self, 'pd'): 297 nums = self.pd.max - num 298 299 desc = ngettext(_('Polish %s')%mi.title, 300 _('Polish book %(nums)s of %(tot)s (%(title)s)')%dict( 301 nums=nums, tot=len(self.book_id_map), 302 title=mi.title), len(self.book_id_map)) 303 if hasattr(self, 'pd'): 304 self.pd.set_msg(_('Queueing book %(nums)s of %(tot)s (%(title)s)')%dict( 305 nums=num, tot=len(self.book_id_map), title=mi.title)) 306 307 self.jobs.append((desc, data, book_id, base, is_orig)) 308# }}} 309 310 311class Report(QDialog): # {{{ 312 313 def __init__(self, parent): 314 QDialog.__init__(self, parent) 315 self.gui = parent 316 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) 317 self.setWindowIcon(QIcon(I('polish.png'))) 318 self.reports = [] 319 320 self.l = l = QGridLayout() 321 self.setLayout(l) 322 self.view = v = QTextEdit(self) 323 v.setReadOnly(True) 324 l.addWidget(self.view, 0, 0, 1, 2) 325 326 self.backup_msg = la = QLabel('') 327 l.addWidget(la, 1, 0, 1, 2) 328 la.setVisible(False) 329 la.setWordWrap(True) 330 331 self.ign = QCheckBox(_('Ignore remaining reports'), self) 332 l.addWidget(self.ign, 2, 0) 333 334 bb = self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 335 bb.accepted.connect(self.accept) 336 bb.rejected.connect(self.reject) 337 b = self.log_button = bb.addButton(_('View full &log'), QDialogButtonBox.ButtonRole.ActionRole) 338 b.clicked.connect(self.view_log) 339 bb.button(QDialogButtonBox.StandardButton.Close).setDefault(True) 340 l.addWidget(bb, 2, 1) 341 342 self.finished.connect(self.show_next, type=Qt.ConnectionType.QueuedConnection) 343 344 self.resize(QSize(800, 600)) 345 346 def setup_ign(self): 347 self.ign.setText(ngettext( 348 'Ignore remaining report', 'Ignore remaining {} reports', len(self.reports)).format(len(self.reports))) 349 self.ign.setVisible(bool(self.reports)) 350 self.ign.setChecked(False) 351 352 def __call__(self, *args): 353 self.reports.append(args) 354 self.setup_ign() 355 if not self.isVisible(): 356 self.show_next() 357 358 def show_report(self, book_title, book_id, fmts, job, report): 359 from calibre.ebooks.markdown import markdown 360 self.current_log = job.details 361 self.setWindowTitle(_('Polishing of %s')%book_title) 362 self.view.setText(markdown('# %s\n\n'%book_title + report, 363 output_format='html4')) 364 self.bb.button(QDialogButtonBox.StandardButton.Close).setFocus(Qt.FocusReason.OtherFocusReason) 365 self.backup_msg.setVisible(bool(fmts)) 366 if fmts: 367 m = ngettext('The original file has been saved as %s.', 368 'The original files have been saved as %s.', len(fmts))%( 369 _(' and ').join('ORIGINAL_'+f for f in fmts) 370 ) 371 self.backup_msg.setText(m + ' ' + _( 372 'If you polish again, the polishing will run on the originals.')%( 373 )) 374 375 def view_log(self): 376 self.view.setPlainText(self.current_log) 377 self.view.verticalScrollBar().setValue(0) 378 379 def show_next(self, *args): 380 if not self.reports: 381 return 382 if not self.isVisible(): 383 self.show() 384 self.show_report(*self.reports.pop(0)) 385 self.setup_ign() 386 387 def accept(self): 388 if self.ign.isChecked(): 389 self.reports = [] 390 if self.reports: 391 self.show_next() 392 return 393 super().accept() 394 395 def reject(self): 396 if self.ign.isChecked(): 397 self.reports = [] 398 if self.reports: 399 self.show_next() 400 return 401 super().reject() 402# }}} 403 404 405class PolishAction(InterfaceAction): 406 407 name = 'Polish Books' 408 action_spec = (_('Polish books'), 'polish.png', 409 _('Apply the shine of perfection to your books'), _('P')) 410 dont_add_to = frozenset(['context-menu-device']) 411 action_type = 'current' 412 accepts_drops = True 413 414 def accept_enter_event(self, event, mime_data): 415 if mime_data.hasFormat("application/calibre+from_library"): 416 return True 417 return False 418 419 def accept_drag_move_event(self, event, mime_data): 420 if mime_data.hasFormat("application/calibre+from_library"): 421 return True 422 return False 423 424 def drop_event(self, event, mime_data): 425 mime = 'application/calibre+from_library' 426 if mime_data.hasFormat(mime): 427 self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split())) 428 QTimer.singleShot(1, self.do_drop) 429 return True 430 return False 431 432 def do_drop(self): 433 book_id_map = self.get_supported_books(self.dropped_ids) 434 del self.dropped_ids 435 if book_id_map: 436 self.do_polish(book_id_map) 437 438 def genesis(self): 439 self.qaction.triggered.connect(self.polish_books) 440 self.report = Report(self.gui) 441 self.to_be_refreshed = set() 442 self.refresh_debounce_timer = t = QTimer(self.gui) 443 t.setSingleShot(True) 444 t.setInterval(1000) 445 t.timeout.connect(self.refresh_after_polish) 446 447 def shutting_down(self): 448 self.refresh_debounce_timer.stop() 449 450 def location_selected(self, loc): 451 enabled = loc == 'library' 452 self.qaction.setEnabled(enabled) 453 self.menuless_qaction.setEnabled(enabled) 454 455 def get_books_for_polishing(self): 456 rows = [r.row() for r in 457 self.gui.library_view.selectionModel().selectedRows()] 458 if not rows or len(rows) == 0: 459 d = error_dialog(self.gui, _('Cannot polish'), 460 _('No books selected')) 461 d.exec() 462 return None 463 db = self.gui.library_view.model().db 464 ans = (db.id(r) for r in rows) 465 ans = self.get_supported_books(ans) 466 for fmts in itervalues(ans): 467 for x in fmts: 468 if x.startswith('ORIGINAL_'): 469 from calibre.gui2.dialogs.confirm_delete import confirm 470 if not confirm(_( 471 'One of the books you are polishing has an {0} format.' 472 ' Polishing will use this as the source and overwrite' 473 ' any existing {1} format. Are you sure you want to proceed?').format( 474 x, x[len('ORIGINAL_'):]), 'confirm_original_polish', title=_('Are you sure?'), 475 confirm_msg=_('Ask for this confirmation again')): 476 return {} 477 break 478 return ans 479 480 def get_supported_books(self, book_ids): 481 from calibre.ebooks.oeb.polish.main import SUPPORTED 482 db = self.gui.library_view.model().db 483 supported = set(SUPPORTED) 484 for x in SUPPORTED: 485 supported.add('ORIGINAL_'+x) 486 ans = [(x, set((db.formats(x, index_is_id=True) or '').split(',')) 487 .intersection(supported)) for x in book_ids] 488 ans = [x for x in ans if x[1]] 489 if not ans: 490 error_dialog(self.gui, _('Cannot polish'), 491 _('Polishing is only supported for books in the %s' 492 ' formats. Convert to one of those formats before polishing.') 493 %_(' or ').join(sorted(SUPPORTED)), show=True) 494 ans = OrderedDict(ans) 495 for fmts in itervalues(ans): 496 for x in SUPPORTED: 497 if ('ORIGINAL_'+x) in fmts: 498 fmts.discard(x) 499 return ans 500 501 def polish_books(self): 502 book_id_map = self.get_books_for_polishing() 503 if not book_id_map: 504 return 505 self.do_polish(book_id_map) 506 507 def do_polish(self, book_id_map): 508 d = Polish(self.gui.library_view.model().db, book_id_map, parent=self.gui) 509 if d.exec() == QDialog.DialogCode.Accepted and d.jobs: 510 show_reports = bool(d.show_reports.isChecked()) 511 for desc, data, book_id, base, is_orig in reversed(d.jobs): 512 job = self.gui.job_manager.run_job( 513 Dispatcher(self.book_polished), 'gui_polish', args=(data,), 514 description=desc) 515 job.polish_args = (book_id, base, data['files'], show_reports, is_orig) 516 if d.jobs: 517 self.gui.jobs_pointer.start() 518 self.gui.status_bar.show_message( 519 ngettext('Start polishing the book', 'Start polishing of {} books', 520 len(d.jobs)).format(len(d.jobs)), 2000) 521 522 def book_polished(self, job): 523 if job.failed: 524 self.gui.job_exception(job) 525 return 526 db = self.gui.current_db 527 book_id, base, files, show_reports, is_orig = job.polish_args 528 fmts = set() 529 for path in files: 530 fmt = path.rpartition('.')[-1].upper() 531 if tweaks['save_original_format_when_polishing'] and not is_orig[fmt]: 532 fmts.add(fmt) 533 db.save_original_format(book_id, fmt, notify=False) 534 with open(path, 'rb') as f: 535 db.add_format(book_id, fmt, f, index_is_id=True) 536 self.gui.status_bar.show_message(job.description + _(' completed'), 2000) 537 try: 538 shutil.rmtree(base) 539 parent = os.path.dirname(base) 540 os.rmdir(parent) 541 except: 542 pass 543 self.to_be_refreshed.add(book_id) 544 self.refresh_debounce_timer.start() 545 if show_reports: 546 self.report(db.title(book_id, index_is_id=True), book_id, fmts, job, job.result) 547 548 def refresh_after_polish(self): 549 self.refresh_debounce_timer.stop() 550 book_ids = tuple(self.to_be_refreshed) 551 self.to_be_refreshed = set() 552 if self.gui.current_view() is self.gui.library_view: 553 self.gui.library_view.model().refresh_ids(book_ids) 554 current = self.gui.library_view.currentIndex() 555 if current.isValid(): 556 self.gui.library_view.model().current_changed(current, QModelIndex()) 557 self.gui.tags_view.recount() 558 559 560if __name__ == '__main__': 561 app = QApplication([]) 562 app 563 from calibre.library import db 564 d = Polish(db(), {1:{'EPUB'}, 2:{'AZW3'}}) 565 d.exec() 566