1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
3
4
5__license__   = 'GPL v3'
6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
7__docformat__ = 'restructuredtext en'
8
9import os, weakref, shutil
10
11from qt.core import (QDialog, QVBoxLayout, QHBoxLayout, QRadioButton, QFrame,
12        QPushButton, QLabel, QGroupBox, QGridLayout, QIcon, QSize, QTimer)
13
14from calibre import as_unicode
15from calibre.constants import ismacos
16from calibre.gui2 import error_dialog, question_dialog, open_local_file, gprefs
17from calibre.gui2.actions import InterfaceAction
18from calibre.ptempfile import (PersistentTemporaryDirectory,
19        PersistentTemporaryFile)
20from calibre.utils.config import prefs, tweaks
21
22
23class UnpackBook(QDialog):
24
25    def __init__(self, parent, book_id, fmts, db):
26        QDialog.__init__(self, parent)
27        self.setWindowIcon(QIcon(I('unpack-book.png')))
28        self.book_id, self.fmts, self.db_ref = book_id, fmts, weakref.ref(db)
29        self._exploded = None
30        self._cleanup_dirs = []
31        self._cleanup_files = []
32
33        self.setup_ui()
34        self.setWindowTitle(_('Unpack book') + ' - ' + db.title(book_id,
35            index_is_id=True))
36
37        button = self.fmt_choice_buttons[0]
38        button_map = {str(x.text()):x for x in self.fmt_choice_buttons}
39        of = prefs['output_format'].upper()
40        df = tweaks.get('default_tweak_format', None)
41        lf = gprefs.get('last_tweak_format', None)
42        if df and df.lower() == 'remember' and lf in button_map:
43            button = button_map[lf]
44        elif df and df.upper() in button_map:
45            button = button_map[df.upper()]
46        elif of in button_map:
47            button = button_map[of]
48        button.setChecked(True)
49
50        self.init_state()
51        for button in self.fmt_choice_buttons:
52            button.toggled.connect(self.init_state)
53
54    def init_state(self, *args):
55        self._exploded = None
56        self.preview_button.setEnabled(False)
57        self.rebuild_button.setEnabled(False)
58        self.explode_button.setEnabled(True)
59
60    def setup_ui(self):  # {{{
61        self._g = g = QHBoxLayout(self)
62        self.setLayout(g)
63        self._l = l = QVBoxLayout()
64        g.addLayout(l)
65
66        fmts = sorted(x.upper() for x in self.fmts)
67        self.fmt_choice_box = QGroupBox(_('Choose the format to unpack:'), self)
68        self._fl = fl = QHBoxLayout()
69        self.fmt_choice_box.setLayout(self._fl)
70        self.fmt_choice_buttons = [QRadioButton(y, self) for y in fmts]
71        for x in self.fmt_choice_buttons:
72            fl.addWidget(x, stretch=10 if x is self.fmt_choice_buttons[-1] else
73                    0)
74        l.addWidget(self.fmt_choice_box)
75        self.fmt_choice_box.setVisible(len(fmts) > 1)
76
77        self.help_label = QLabel(_('''\
78            <h2>About Unpack book</h2>
79            <p>Unpack book allows you to fine tune the appearance of an e-book by
80            making small changes to its internals. In order to use Unpack book,
81            you need to know a little bit about HTML and CSS, technologies that
82            are used in e-books. Follow the steps:</p>
83            <br>
84            <ol>
85            <li>Click "Explode book": This will "explode" the book into its
86            individual internal components.<br></li>
87            <li>Right click on any individual file and select "Open with..." to
88            edit it in your favorite text editor.<br></li>
89            <li>When you are done: <b>close the file browser window
90            and the editor windows you used to make your tweaks</b>. Then click
91            the "Rebuild book" button, to update the book in your calibre
92            library.</li>
93            </ol>'''))
94        self.help_label.setWordWrap(True)
95        self._fr = QFrame()
96        self._fr.setFrameShape(QFrame.Shape.VLine)
97        g.addWidget(self._fr)
98        g.addWidget(self.help_label)
99
100        self._b = b = QGridLayout()
101        left, top, right, bottom = b.getContentsMargins()
102        top += top
103        b.setContentsMargins(left, top, right, bottom)
104        l.addLayout(b, stretch=10)
105
106        self.explode_button = QPushButton(QIcon(I('wizard.png')), _('&Explode book'))
107        self.preview_button = QPushButton(QIcon(I('view.png')), _('&Preview book'))
108        self.cancel_button  = QPushButton(QIcon(I('window-close.png')), _('&Cancel'))
109        self.rebuild_button = QPushButton(QIcon(I('exec.png')), _('&Rebuild book'))
110
111        self.explode_button.setToolTip(
112                _('Explode the book to edit its components'))
113        self.preview_button.setToolTip(
114                _('Preview the result of your changes'))
115        self.cancel_button.setToolTip(
116                _('Abort without saving any changes'))
117        self.rebuild_button.setToolTip(
118            _('Save your changes and update the book in the calibre library'))
119
120        a = b.addWidget
121        a(self.explode_button, 0, 0, 1, 1)
122        a(self.preview_button, 0, 1, 1, 1)
123        a(self.cancel_button,  1, 0, 1, 1)
124        a(self.rebuild_button, 1, 1, 1, 1)
125
126        for x in ('explode', 'preview', 'cancel', 'rebuild'):
127            getattr(self, x+'_button').clicked.connect(getattr(self, x))
128
129        self.msg = QLabel('dummy', self)
130        self.msg.setVisible(False)
131        self.msg.setStyleSheet('''
132        QLabel {
133            text-align: center;
134            background-color: white;
135            color: black;
136            border-width: 1px;
137            border-style: solid;
138            border-radius: 20px;
139            font-size: x-large;
140            font-weight: bold;
141        }
142        ''')
143
144        self.resize(self.sizeHint() + QSize(40, 10))
145    # }}}
146
147    def show_msg(self, msg):
148        self.msg.setText(msg)
149        self.msg.resize(self.size() - QSize(50, 25))
150        self.msg.move((self.width() - self.msg.width())//2,
151                (self.height() - self.msg.height())//2)
152        self.msg.setVisible(True)
153
154    def hide_msg(self):
155        self.msg.setVisible(False)
156
157    def explode(self):
158        self.show_msg(_('Exploding, please wait...'))
159        if len(self.fmt_choice_buttons) > 1:
160            gprefs.set('last_tweak_format', self.current_format.upper())
161        QTimer.singleShot(5, self.do_explode)
162
163    def ask_question(self, msg):
164        return question_dialog(self, _('Are you sure?'), msg)
165
166    def do_explode(self):
167        from calibre.ebooks.tweak import get_tools, Error, WorkerError
168        tdir = PersistentTemporaryDirectory('_tweak_explode')
169        self._cleanup_dirs.append(tdir)
170        det_msg = None
171        try:
172            src = self.db.format(self.book_id, self.current_format,
173                    index_is_id=True, as_path=True)
174            self._cleanup_files.append(src)
175            exploder = get_tools(self.current_format)[0]
176            opf = exploder(src, tdir, question=self.ask_question)
177        except WorkerError as e:
178            det_msg = e.orig_tb
179        except Error as e:
180            return error_dialog(self, _('Failed to unpack'),
181                (_('Could not explode the %s file.')%self.current_format) + ' ' + as_unicode(e), show=True)
182        except:
183            import traceback
184            det_msg = traceback.format_exc()
185        finally:
186            self.hide_msg()
187
188        if det_msg is not None:
189            return error_dialog(self, _('Failed to unpack'),
190                _('Could not explode the %s file. Click "Show details" for '
191                    'more information.')%self.current_format, det_msg=det_msg,
192                show=True)
193
194        if opf is None:
195            # The question was answered with No
196            return
197
198        self._exploded = tdir
199        self.explode_button.setEnabled(False)
200        self.preview_button.setEnabled(True)
201        self.rebuild_button.setEnabled(True)
202        open_local_file(tdir)
203
204    def rebuild_it(self):
205        from calibre.ebooks.tweak import get_tools, WorkerError
206        src_dir = self._exploded
207        det_msg = None
208        of = PersistentTemporaryFile('_tweak_rebuild.'+self.current_format.lower())
209        of.close()
210        of = of.name
211        self._cleanup_files.append(of)
212        try:
213            rebuilder = get_tools(self.current_format)[1]
214            rebuilder(src_dir, of)
215        except WorkerError as e:
216            det_msg = e.orig_tb
217        except:
218            import traceback
219            det_msg = traceback.format_exc()
220        finally:
221            self.hide_msg()
222
223        if det_msg is not None:
224            error_dialog(self, _('Failed to rebuild file'),
225                    _('Failed to rebuild %s. For more information, click '
226                        '"Show details".')%self.current_format,
227                    det_msg=det_msg, show=True)
228            return None
229
230        return of
231
232    def preview(self):
233        self.show_msg(_('Rebuilding, please wait...'))
234        QTimer.singleShot(5, self.do_preview)
235
236    def do_preview(self):
237        rebuilt = self.rebuild_it()
238        if rebuilt is not None:
239            self.parent().iactions['View']._view_file(rebuilt)
240
241    def rebuild(self):
242        self.show_msg(_('Rebuilding, please wait...'))
243        QTimer.singleShot(5, self.do_rebuild)
244
245    def do_rebuild(self):
246        rebuilt = self.rebuild_it()
247        if rebuilt is not None:
248            fmt = os.path.splitext(rebuilt)[1][1:].upper()
249            with open(rebuilt, 'rb') as f:
250                self.db.add_format(self.book_id, fmt, f, index_is_id=True)
251            self.accept()
252
253    def cancel(self):
254        self.reject()
255
256    def cleanup(self):
257        if ismacos and self._exploded:
258            try:
259                import appscript
260                self.finder = appscript.app('Finder')
261                self.finder.Finder_windows[os.path.basename(self._exploded)].close()
262            except:
263                pass
264
265        for f in self._cleanup_files:
266            try:
267                os.remove(f)
268            except:
269                pass
270
271        for d in self._cleanup_dirs:
272            try:
273                shutil.rmtree(d)
274            except:
275                pass
276
277    @property
278    def db(self):
279        return self.db_ref()
280
281    @property
282    def current_format(self):
283        for b in self.fmt_choice_buttons:
284            if b.isChecked():
285                return str(b.text())
286
287
288class UnpackBookAction(InterfaceAction):
289
290    name = 'Unpack Book'
291    action_spec = (_('Unpack book'), 'unpack-book.png',
292            _('Unpack books in the EPUB, AZW3, HTMLZ formats into their individual components'), 'U')
293    dont_add_to = frozenset(['context-menu-device'])
294    action_type = 'current'
295
296    accepts_drops = True
297
298    def accept_enter_event(self, event, mime_data):
299        if mime_data.hasFormat("application/calibre+from_library"):
300            return True
301        return False
302
303    def accept_drag_move_event(self, event, mime_data):
304        if mime_data.hasFormat("application/calibre+from_library"):
305            return True
306        return False
307
308    def drop_event(self, event, mime_data):
309        mime = 'application/calibre+from_library'
310        if mime_data.hasFormat(mime):
311            self.dropped_ids = tuple(map(int, mime_data.data(mime).data().split()))
312            QTimer.singleShot(1, self.do_drop)
313            return True
314        return False
315
316    def do_drop(self):
317        book_ids = self.dropped_ids
318        del self.dropped_ids
319        if book_ids:
320            self.do_tweak(book_ids[0])
321
322    def genesis(self):
323        self.qaction.triggered.connect(self.tweak_book)
324
325    def tweak_book(self):
326        row = self.gui.library_view.currentIndex()
327        if not row.isValid():
328            return error_dialog(self.gui, _('Cannot unpack book'),
329                    _('No book selected'), show=True)
330
331        book_id = self.gui.library_view.model().id(row)
332        self.do_tweak(book_id)
333
334    def do_tweak(self, book_id):
335        db = self.gui.library_view.model().db
336        fmts = db.formats(book_id, index_is_id=True) or ''
337        fmts = [x.lower().strip() for x in fmts.split(',')]
338        tweakable_fmts = set(fmts).intersection({'epub', 'htmlz', 'azw3',
339            'mobi', 'azw'})
340        if not tweakable_fmts:
341            return error_dialog(self.gui, _('Cannot unpack book'),
342                    _('The book must be in ePub, HTMLZ or AZW3 formats to unpack.'
343                        '\n\nFirst convert the book to one of these formats.'),
344                    show=True)
345        dlg = UnpackBook(self.gui, book_id, tweakable_fmts, db)
346        dlg.exec()
347        dlg.cleanup()
348