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