summaryrefslogtreecommitdiffstats
path: root/Obok_plugin/action.py
blob: e4ef377d53f6e1e0f91d840ec63954f9a357a332 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai

_license__   = 'GPL v3'
__docformat__ = 'restructuredtext en'


import codecs
import os, traceback, zipfile

try:
    from PyQt5.Qt import QToolButton, QUrl
except ImportError:
    from PyQt4.Qt import QToolButton, QUrl

from calibre.gui2 import open_url, question_dialog
from calibre.gui2.actions import InterfaceAction
from calibre.utils.config import config_dir
from calibre.ptempfile import (PersistentTemporaryDirectory,
                               PersistentTemporaryFile, remove_dir)

from calibre.ebooks.metadata.meta import get_metadata

from calibre_plugins.obok_dedrm.dialogs import (SelectionDialog, DecryptAddProgressDialog,
                                                AddEpubFormatsProgressDialog, ResultsSummaryDialog)
from calibre_plugins.obok_dedrm.config import plugin_prefs as cfg
from calibre_plugins.obok_dedrm.__init__ import (PLUGIN_NAME, PLUGIN_SAFE_NAME,
                                PLUGIN_VERSION, PLUGIN_DESCRIPTION, HELPFILE_NAME)
from calibre_plugins.obok_dedrm.utilities import (
                            get_icon, set_plugin_icon_resources, showErrorDlg, format_plural,
                            debug_print
                            )

from calibre_plugins.obok_dedrm.obok.obok import KoboLibrary
from calibre_plugins.obok_dedrm.obok.legacy_obok import legacy_obok

PLUGIN_ICONS = ['images/obok.png']

try:
    debug_print("obok::action_err.py - loading translations")
    load_translations()
except NameError:
    debug_print("obok::action_err.py - exception when loading translations")
    pass # load_translations() added in calibre 1.9

class InterfacePluginAction(InterfaceAction):
    name = PLUGIN_NAME
    action_spec = (PLUGIN_NAME, None,
            _(PLUGIN_DESCRIPTION), None)
    popup_type = QToolButton.InstantPopup
    action_type = 'current'

    def genesis(self):
        icon_resources = self.load_resources(PLUGIN_ICONS)
        set_plugin_icon_resources(PLUGIN_NAME, icon_resources)

        self.qaction.setIcon(get_icon(PLUGIN_ICONS[0]))
        self.qaction.triggered.connect(self.launchObok)
        self.gui.keyboard.finalize()

    def launchObok(self):
        '''
        Main processing/distribution method
        '''
        self.count = 0
        self.books_to_add = []
        self.formats_to_add = []
        self.add_books_cancelled = False
        self.decryption_errors = []
        self.userkeys = []
        self.duplicate_book_list = []
        self.no_home_for_book = []
        self.ids_of_new_books = []
        self.successful_format_adds =[]
        self.add_formats_cancelled = False
        self.tdir = PersistentTemporaryDirectory('_obok', prefix='')
        self.db = self.gui.current_db.new_api
        self.current_idx = self.gui.library_view.currentIndex()

        print ('Running {}'.format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
        #
        # search for connected device in case serials are saved
        tmpserials = cfg['kobo_serials']
        device_path = None
        try:
            device = self.parent().device_manager.connected_device
            if (device):
                device_path = device._main_prefix
                debug_print("get_device_settings - device_path=", device_path)
            else:
                debug_print("didn't find device")
        except:
            debug_print("Exception getting device path. Probably not an E-Ink Kobo device")

        # Get the Kobo Library object (obok v3.01)
        self.library = KoboLibrary(tmpserials, device_path, cfg['kobo_directory'])
        debug_print ("got kobodir %s" % self.library.kobodir)
        if (self.library.kobodir == ''):
            # linux and no device connected, but could be extended
            # to the case where on Windows/Mac the prog is not installed
            msg = _('<p>Could not find Kobo Library\n<p>Windows/Mac: do you have Kobo Desktop installed?\n<p>Windows/Mac/Linux: In case you have an Kobo eInk device, connect the device.')
            showErrorDlg(msg, None)
            return


        # Get a list of Kobo titles
        books = self.build_book_list()
        if len(books) < 1:
            msg = _('<p>No books found in Kobo Library\nAre you sure it\'s installed/configured/synchronized?')
            showErrorDlg(msg, None)
            return

        # Check to see if a key can be retrieved using the legacy obok method.
        legacy_key = legacy_obok().get_legacy_cookie_id
        if legacy_key is not None:
            print (_('Legacy key found: '), legacy_key.encode('hex_codec'))
            self.userkeys.append(legacy_key)
        # Add userkeys found through the normal obok method to the list to try.
        try:
            candidate_keys = self.library.userkeys
        except:
            print (_('Trouble retrieving keys with newer obok method.'))
            traceback.print_exc()
        else:
            if len(candidate_keys):
                self.userkeys.extend(candidate_keys)
                print (_('Found {0} possible keys to try.').format(len(self.userkeys)))
        if not len(self.userkeys):
            msg = _('<p>No userkeys found to decrypt books with. No point in proceeding.')
            showErrorDlg(msg, None)
            return

        # Launch the Dialog so the user can select titles.
        dlg = SelectionDialog(self.gui, self, books)
        if dlg.exec_():
            books_to_import = dlg.getBooks()
            self.count = len(books_to_import)
            debug_print("InterfacePluginAction::launchObok - number of books to decrypt: %d" % self.count)
            # Feed the titles, the callback function (self.get_decrypted_kobo_books)
            # and the Kobo library object to the ProgressDialog dispatcher.
            d = DecryptAddProgressDialog(self.gui, books_to_import, self.get_decrypted_kobo_books, self.library, 'kobo',
                               status_msg_type='Kobo books', action_type=('Decrypting', 'Decryption'))
            # Canceled the decryption process; clean up and exit.
            if d.wasCanceled():
                print (_('{} - Decryption canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
                self.library.close()
                remove_dir(self.tdir)
                return
        else:
            # Canceled the selection process; clean up and exit.
            self.library.close()
            remove_dir(self.tdir)
            return
        # Close Kobo Library object
        self.library.close()

        # If we have decrypted books to work with, feed the list of decrypted books details
        # and the callback function (self.add_new_books) to the ProgressDialog dispatcher.
        if len(self.books_to_add):
            d = DecryptAddProgressDialog(self.gui, self.books_to_add, self.add_new_books, self.db, 'calibre',
                               status_msg_type='new calibre books', action_type=('Adding','Addition'))
            # Canceled the "add new books to calibre" process;
            # show the results of what got added before cancellation.
            if d.wasCanceled():
                print (_('{} - "Add books" canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
                self.add_books_cancelled = True
                print (_('{} - wrapping up results.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
                self.wrap_up_results()
                remove_dir(self.tdir)
                return
        # If books couldn't be added because of duplicate entries in calibre, ask
        # if we should try to add the decrypted epubs to existing calibre library entries.
        if len(self.duplicate_book_list):
            if cfg['finding_homes_for_formats'] == 'Always':
                self.process_epub_formats()
            elif cfg['finding_homes_for_formats'] == 'Never':
                self.no_home_for_book.extend([entry[0] for entry in self.duplicate_book_list])
            else:
                if self.ask_about_inserting_epubs():
                    # Find homes for the epub decrypted formats in existing calibre library entries.
                    self.process_epub_formats()
                else:
                    print (_('{} - User opted not to try to insert EPUB formats').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
                    self.no_home_for_book.extend([entry[0] for entry in self.duplicate_book_list])

        print (_('{} - wrapping up results.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
        self.wrap_up_results()
        remove_dir(self.tdir)
        return

    def show_help(self):
        '''
        Extract on demand the help file resource
        '''
        def get_help_file_resource():
            # We will write the help file out every time, in case the user upgrades the plugin zip
            # and there is a newer help file contained within it.
            file_path = os.path.join(config_dir, 'plugins', HELPFILE_NAME)
            file_data = self.load_resources(HELPFILE_NAME)[HELPFILE_NAME].decode('utf-8')
            with open(file_path,'w') as f:
                f.write(file_data)
            return file_path
        url = 'file:///' + get_help_file_resource()
        open_url(QUrl(url))

    def build_book_list(self):
        '''
        Connect to Kobo db and get titles.
        '''
        return self.library.books

    def get_decrypted_kobo_books(self, book):
        '''
        This method is a call-back function used by DecryptAddProgressDialog in dialogs.py to decrypt Kobo books

        :param book: A KoboBook object that is to be decrypted.
        '''
        print (_('{0} - Decrypting {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, book.title))
        decrypted = self.decryptBook(book)
        if decrypted['success']:
            # Build a list of calibre "book maps" for calibre's add_book function.
            mi = get_metadata(decrypted['fileobj'], 'epub')
            bookmap = {'EPUB':decrypted['fileobj'].name}
            self.books_to_add.append((mi, bookmap))
        else:
            # Book is probably still encrypted.
            print (_('{0} - Couldn\'t decrypt {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, book.title))
            self.decryption_errors.append((book.title, _('decryption errors')))
            return False
        return True

    def add_new_books(self, books_to_add):
        '''
        This method is a call-back function used by DecryptAddProgressDialog in dialogs.py to add books to calibre
        (It's set up to handle multiple books, but will only be fed books one at a time by DecryptAddProgressDialog)

        :param books_to_add: List of calibre bookmaps (created in get_decrypted_kobo_books)
        '''

        cfg_add_duplicates = (cfg['finding_homes_for_formats'] == 'Add new entry')

        added = self.db.add_books(books_to_add, add_duplicates=cfg_add_duplicates, run_hooks=False)
        if len(added[0]):
            # Record the id(s) that got added
            for id in added[0]:
                print (_('{0} - Added {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, books_to_add[0][0].title))
                self.ids_of_new_books.append((id, books_to_add[0][0]))
        if len(added[1]):
            # Build a list of details about the books that didn't get added because duplicate were detected.
            for mi, map in added[1]:
                print (_('{0} - {1} already exists. Will try to add format later.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title))
                self.duplicate_book_list.append((mi, map['EPUB'], _('duplicate detected')))
            return False
        return True

    def add_epub_format(self, book_id, mi, path):
        '''
        This method is a call-back function used by AddEpubFormatsProgressDialog in dialogs.py

        :param book_id: calibre ID of the book to add the encrypted epub to.
        :param mi: calibre metadata object
        :param path: path to the decrypted epub (temp file)
        '''
        if self.db.add_format(book_id, 'EPUB', path, replace=False, run_hooks=False):
            self.successful_format_adds.append((book_id, mi))
            print (_('{0} - Successfully added EPUB format to existing {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title))
            return True
        # we really shouldn't get here.
        print (_('{0} - Error adding EPUB format to existing {1}. This really shouldn\'t happen.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title))
        self.no_home_for_book.append(mi)
        return False

    def process_epub_formats(self):
        '''
        Ask the user if they want to try to find homes for those books that already had an entry in calibre
        '''
        for book in self.duplicate_book_list:
            mi, tmp_file = book[0], book[1]
            dup_ids = self.db.find_identical_books(mi)
            home_id = self.find_a_home(dup_ids)
            if home_id is not None:
                # Found an epub-free duplicate to add the epub to.
                # build a list for the add_epub_format method to use.
                self.formats_to_add.append((home_id, mi, tmp_file))
            else:
                self.no_home_for_book.append(mi)
        # If we found homes for decrypted epubs in existing calibre entries, feed the list of decrypted book
        # details and the callback function (self.add_epub_format) to the ProgressDialog dispatcher.
        if self.formats_to_add:
            d = AddEpubFormatsProgressDialog(self.gui, self.formats_to_add, self.add_epub_format)
            if d.wasCanceled():
                print (_('{} - "Insert formats" canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION))
                self.add_formats_cancelled = True
                return
            #return
        return

    def wrap_up_results(self):
        '''
        Present the results
        '''
        caption = PLUGIN_NAME + ' v' + PLUGIN_VERSION
        # Refresh the gui and highlight new entries/modified entries.
        if len(self.ids_of_new_books) or len(self.successful_format_adds):
            self.refresh_gui_lib()

        msg, log = self.build_report()

        sd = ResultsSummaryDialog(self.gui, caption, msg, log)
        sd.exec_()
        return

    def ask_about_inserting_epubs(self):
        '''
        Build question dialog with details about kobo books
        that couldn't be added to calibre as new books.
        '''
        ''' Terisa: Improve the message
        '''
        caption = PLUGIN_NAME + ' v' + PLUGIN_VERSION
        plural = format_plural(len(self.ids_of_new_books))
        det_msg = ''
        if self.count > 1:
            msg = _('<p><b>{0}</b> EPUB{2} successfully added to library.<br /><br /><b>{1}</b> ').format(len(self.ids_of_new_books), len(self.duplicate_book_list), plural)
            msg += _('not added because books with the same title/author were detected.<br /><br />Would you like to try and add the EPUB format{0}').format(plural)
            msg += _(' to those existing entries?<br /><br />NOTE: no pre-existing EPUBs will be overwritten.')
            for entry in self.duplicate_book_list:
                det_msg += _('{0} -- not added because of {1} in your library.\n\n').format(entry[0].title, entry[2])
        else:
            msg = _('<p><b>{0}</b> -- not added because of {1} in your library.<br /><br />').format(self.duplicate_book_list[0][0].title, self.duplicate_book_list[0][2])
            msg += _('Would you like to try and add the EPUB format to an available calibre duplicate?<br /><br />')
            msg += _('NOTE: no pre-existing EPUB will be overwritten.')

        return question_dialog(self.gui, caption, msg, det_msg)

    def find_a_home(self, ids):
        '''
        Find the ID of the first EPUB-Free duplicate available

        :param ids: List of calibre IDs that might serve as a home.
        '''
        for id in ids:
            # Find the first entry that matches the incoming book that doesn't have an EPUB format.
            if not self.db.has_format(id, 'EPUB'):
                return id
                break
        return None

    def refresh_gui_lib(self):
        '''
        Update the GUI; highlight the books that were added/modified
        '''
        if self.current_idx.isValid():
            self.gui.library_view.model().current_changed(self.current_idx, self.current_idx)
        new_entries = [id for id, mi in self.ids_of_new_books]
        if new_entries:
            self.gui.library_view.model().db.data.books_added(new_entries)
            self.gui.library_view.model().books_added(len(new_entries))
        new_entries.extend([id for id, mi in self.successful_format_adds])
        self.gui.db_images.reset()
        self.gui.tags_view.recount()
        self.gui.library_view.model().set_highlight_only(True)
        self.gui.library_view.select_rows(new_entries)
        return

    def decryptBook(self, book):
        '''
        Decrypt Kobo book

        :param book: obok file object
        '''
        result = {}
        result['success'] = False
        result['fileobj'] = None

        zin = zipfile.ZipFile(book.filename, 'r')
        #print ('Kobo library filename: {0}'.format(book.filename))
        for userkey in self.userkeys:
            print (_('Trying key: '), codecs.encode(userkey, 'hex'))
            try:
                fileout = PersistentTemporaryFile('.epub', dir=self.tdir)
                #print ('Temp file: {0}'.format(fileout.name))
                # modify the output file to be compressed by default
                zout = zipfile.ZipFile(fileout.name, "w", zipfile.ZIP_DEFLATED)
                # ensure that the mimetype file is the first written to the epub container
                # and is stored with no compression
                members = zin.namelist();
                try:
                    members.remove('mimetype')
                except Exception:
                    pass
                zout.writestr('mimetype', 'application/epub+zip', zipfile.ZIP_STORED)
                # end of mimetype mod
                for filename in members:
                    contents = zin.read(filename)
                    if filename in book.encryptedfiles:
                        file = book.encryptedfiles[filename]
                        contents = file.decrypt(userkey, contents)
                        # Parse failures mean the key is probably wrong.
                        file.check(contents)
                    zout.writestr(filename, contents)
                zout.close()
                zin.close()
                result['success'] = True
                result['fileobj'] = fileout
                print ('Success!')
                return result
            except ValueError:
                print (_('Decryption failed, trying next key.'))
                zout.close()
                continue
            except Exception:
                print (_('Unknown Error decrypting, trying next key..'))
                zout.close()
                continue
        result['fileobj'] = book.filename
        zin.close()
        return result

    def build_report(self):
        log = ''
        processed = len(self.ids_of_new_books) + len(self.successful_format_adds)

        if processed == self.count:
            if self.count > 1:
                msg = _('<p>All selected Kobo books added as new calibre books or inserted into existing calibre ebooks.<br /><br />No issues.')
            else:
                # Single book ... don't get fancy.
                title = self.ids_of_new_books[0][1].title if self.ids_of_new_books else self.successful_format_adds[0][1].title
                msg = _('<p>{0} successfully added.').format(title)
            return (msg, log)
        else:
            if self.count != 1:
                msg = _('<p>Not all selected Kobo books made it into calibre.<br /><br />View report for details.')
                log += _('<p><b>Total attempted:</b> {}</p>\n').format(self.count)
                log += _('<p><b>Decryption errors:</b> {}</p>\n').format(len(self.decryption_errors))
                if self.decryption_errors:
                    log += '<ul>\n'
                    for title, reason in self.decryption_errors:
                        log += '<li>{}</li>\n'.format(title)
                    log += '</ul>\n'
                log += _('<p><b>New Books created:</b> {}</p>\n').format(len(self.ids_of_new_books))
                if self.ids_of_new_books:
                    log += '<ul>\n'
                    for id, mi in self.ids_of_new_books:
                        log += '<li>{}</li>\n'.format(mi.title)
                    log += '</ul>\n'
                if self.add_books_cancelled:
                    log += _('<p><b>Duplicates that weren\'t added:</b> {}</p>\n').format(len(self.duplicate_book_list))
                    if self.duplicate_book_list:
                        log += '<ul>\n'
                        for book in self.duplicate_book_list:
                            log += '<li>{}</li>\n'.format(book[0].title)
                        log += '</ul>\n'
                    cancelled_count = self.count - (len(self.decryption_errors) + len(self.ids_of_new_books) + len(self.duplicate_book_list))
                    if cancelled_count > 0:
                        log += _('<p><b>Book imports cancelled by user:</b> {}</p>\n').format(cancelled_count)
                    return (msg, log)
                log += _('<p><b>New EPUB formats inserted in existing calibre books:</b> {0}</p>\n').format(len(self.successful_format_adds))
                if self.successful_format_adds:
                    log += '<ul>\n'
                    for id, mi in self.successful_format_adds:
                        log += '<li>{}</li>\n'.format(mi.title)
                    log += '</ul>\n'
                log += _('<p><b>EPUB formats NOT inserted into existing calibre books:</b> {}<br />\n').format(len(self.no_home_for_book))
                log += _('(Either because the user <i>chose</i> not to insert them, or because all duplicates already had an EPUB format)')
                if self.no_home_for_book:
                    log += '<ul>\n'
                    for mi in self.no_home_for_book:
                        log += '<li>{}</li>\n'.format(mi.title)
                    log += '</ul>\n'
                if self.add_formats_cancelled:
                    cancelled_count = self.count - (len(self.decryption_errors) + len(self.ids_of_new_books) + len(self.successful_format_adds) + len(self.no_home_for_book))
                    if cancelled_count > 0:
                        log += _('<p><b>Format imports cancelled by user:</b> {}</p>\n').format(cancelled_count)
                return (msg, log)
            else:

                # Single book ... don't get fancy.
                if self.ids_of_new_books:
                    title = self.ids_of_new_books[0][1].title
                elif self.successful_format_adds:
                    title = self.successful_format_adds[0][1].title
                elif self.no_home_for_book:
                    title = self.no_home_for_book[0].title
                elif self.decryption_errors:
                    title = self.decryption_errors[0][0]
                else:
                    title = _('Unknown Book Title')
                if self.decryption_errors:
                    reason = _('it couldn\'t be decrypted.')
                elif self.no_home_for_book:
                    reason = _('user CHOSE not to insert the new EPUB format, or all existing calibre entries HAD an EPUB format already.')
                else:
                    reason = _('of unknown reasons. Gosh I\'m embarrassed!')
                msg = _('<p>{0} not added because {1}').format(title, reason)
                return (msg, log)