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