Beispiel #1
0
class BookSettings(object):
    '''Holds book specific settings'''
    def __init__(self, database, book_id, connections):
        self._connections = connections

        book_path = database.field_for('path', book_id).replace('/', os.sep)

        self._prefs = JSONConfig(os.path.join(book_path, 'book_settings'),
                                 base_path=LIBRARY)
        self._prefs.setdefault('asin', '')
        self._prefs.setdefault('goodreads_url', '')
        self._prefs.setdefault('aliases', {})
        self._prefs.setdefault('sample_xray', '')
        self._prefs.commit()

        self._title = database.field_for('title', book_id)
        self._author = ' & '.join(database.field_for('authors', book_id))

        self._asin = self._prefs['asin'] if self._prefs['asin'] != '' else None
        self._goodreads_url = self._prefs['goodreads_url']
        self._sample_xray = self._prefs['sample_xray']

        if not self._asin:
            identifiers = database.field_for('identifiers', book_id)
            if 'mobi-asin' in list(identifiers.keys()):
                self._asin = database.field_for('identifiers',
                                                book_id)['mobi-asin']
                self._prefs['asin'] = self._asin
            else:
                self._asin = self.search_for_asin_on_amazon(
                    self.title_and_author)
                if self._asin:
                    metadata = database.get_metadata(book_id)
                    identifiers = metadata.get_identifiers()
                    identifiers['mobi-asin'] = self._asin
                    metadata.set_identifiers(identifiers)
                    database.set_metadata(book_id, metadata)
                    self._prefs['asin'] = self._asin

        if self._goodreads_url == '':
            url = None
            if self._asin:
                url = self.search_for_goodreads_url(self._asin)
            if not url and self._title != 'Unknown' and self._author != 'Unknown':
                url = self.search_for_goodreads_url(self.title_and_author)

            if url:
                self._goodreads_url = url
                self._prefs['goodreads_url'] = self._goodreads_url
                if not self._asin:
                    self._asin = self.search_for_asin_on_goodreads(
                        self._goodreads_url)
                    if self._asin:
                        metadata = database.get_metadata(book_id)
                        identifiers = metadata.get_identifiers()
                        identifiers['mobi-asin'] = self._asin
                        metadata.set_identifiers(identifiers)
                        database.set_metadata(book_id, metadata)
                        self._prefs['asin'] = self._asin

        self._aliases = self._prefs['aliases']

        self.save()

    @property
    def prefs(self):
        return self._prefs

    @property
    def asin(self):
        return self._asin

    @asin.setter
    def asin(self, val):
        self._asin = val

    @property
    def sample_xray(self):
        return self._sample_xray

    @sample_xray.setter
    def sample_xray(self, value):
        self._sample_xray = value

    @property
    def title(self):
        return self._title

    @property
    def author(self):
        return self._author

    @property
    def title_and_author(self):
        return '{0} - {1}'.format(self._title, self._author)

    @property
    def goodreads_url(self):
        return self._goodreads_url

    @goodreads_url.setter
    def goodreads_url(self, val):
        self._goodreads_url = val

    @property
    def aliases(self):
        return self._aliases

    def set_aliases(self, label, aliases):
        '''Sets label's aliases to aliases'''

        # 'aliases' is a string containing a comma separated list of aliases.

        # Split it, remove whitespace from each element, drop empty strings (strangely,
        # split only does this if you don't specify a separator)

        # so "" -> []  "foo,bar" and " foo   , bar " -> ["foo", "bar"]
        aliases = [x.strip() for x in aliases.split(",") if x.strip()]
        self._aliases[label] = aliases

    def save(self):
        '''Saves current settings in book's settings file'''
        self._prefs['asin'] = self._asin
        self._prefs['goodreads_url'] = self._goodreads_url
        self._prefs['aliases'] = self._aliases
        self._prefs['sample_xray'] = self._sample_xray

    def search_for_asin_on_amazon(self, query):
        '''Search for book's asin on amazon using given query'''
        query = urlencode({'keywords': query})
        url = '/s/ref=sr_qz_back?sf=qz&rh=i%3Adigital-text%2Cn%3A154606011%2Ck%3A' + query[
            9:] + '&' + query
        try:
            response = open_url(self._connections['amazon'], url)
        except PageDoesNotExist:
            return None

        # check to make sure there are results
        if ('did not match any products' in response
                and 'Did you mean:' not in response
                and 'so we searched in All Departments' not in response):
            return None

        soup = BeautifulSoup(response)
        results = soup.findAll('div', {'class': 's-result-list'})

        if not results:
            return None

        for result in results:
            if 'Buy now with 1-Click' in str(result):
                asin_search = AMAZON_ASIN_PAT.search(str(result))
                if asin_search:
                    return asin_search.group(1)

        return None

    def search_for_goodreads_url(self, keywords):
        '''Searches for book's goodreads url using given keywords'''
        query = urlencode({'q': keywords})
        try:
            response = open_url(self._connections['goodreads'],
                                '/search?' + query)
        except PageDoesNotExist:
            return None

        # check to make sure there are results
        if 'No results' in response:
            return None

        urlsearch = GOODREADS_URL_PAT.search(response)
        if not urlsearch:
            return None

        # return the full URL with the query parameters removed
        url = 'https://www.goodreads.com' + urlsearch.group(1)
        return urlparse(url)._replace(query=None).geturl()

    def search_for_asin_on_goodreads(self, url):
        '''Searches for ASIN of book at given url'''
        book_id_search = BOOK_ID_PAT.search(url)
        if not book_id_search:
            return None

        book_id = book_id_search.group(1)

        try:
            response = open_url(self._connections['goodreads'],
                                '/buttons/glide/' + book_id)
        except PageDoesNotExist:
            return None

        book_asin_search = GOODREADS_ASIN_PAT.search(response)
        if not book_asin_search:
            return None

        return book_asin_search.group(1)

    def update_aliases(self, source, source_type='url'):
        if source_type.lower() == 'url':
            self.update_aliases_from_url(source)
            return
        if source_type.lower() == 'asc':
            self.update_aliases_from_asc(source)
            return
        if source_type.lower() == 'json':
            self.update_aliases_from_json(source)

    def update_aliases_from_asc(self, filename):
        '''Gets aliases from sample x-ray file and expands them if users settings say to do so'''
        cursor = connect(filename).cursor()
        characters = {
            x[1]: [x[1]]
            for x in cursor.execute('SELECT * FROM entity').fetchall()
            if x[3] == 1
        }

        self._aliases = {}
        for alias, fullname in list(auto_expand_aliases(characters).items()):
            if fullname not in list(self._aliases.keys()):
                self._aliases[fullname] = [alias]
                continue
            self._aliases[fullname].append(alias)

    def update_aliases_from_json(self, filename):
        '''Gets aliases from json file'''
        data = json.load(open(filename))
        self._aliases = {
            name: char['aliases']
            for name, char in list(data['characters'].items())
        } if 'characters' in data else {}
        if 'settings' in data:
            self._aliases.update({
                name: setting['aliases']
                for name, setting in list(data['settings'].items())
            })

    def update_aliases_from_url(self, url):
        '''Gets aliases from Goodreads and expands them if users settings say to do so'''
        try:
            goodreads_parser = GoodreadsParser(url,
                                               self._connections['goodreads'],
                                               self._asin)
            goodreads_chars = goodreads_parser.get_characters(1)
            goodreads_settings = goodreads_parser.get_settings(
                len(goodreads_chars))
        except PageDoesNotExist:
            goodreads_chars = {}
            goodreads_settings = {}

        self._aliases = {}
        for char_data in list(goodreads_chars.values()) + list(
                goodreads_settings.values()):
            if char_data['label'] not in list(self._aliases.keys()):
                self._aliases[char_data['label']] = char_data['aliases']
Beispiel #2
0
class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog):

    s_r_functions = {''              : lambda x: x,
                            _('Lower Case') : lambda x: icu_lower(x),
                            _('Upper Case') : lambda x: icu_upper(x),
                            _('Title Case') : lambda x: titlecase(x),
                            _('Capitalize') : lambda x: capitalize(x),
                    }

    s_r_match_modes = [_('Character match'),
                            _('Regular Expression'),
                      ]

    s_r_replace_modes = [_('Replace field'),
                            _('Prepend to field'),
                            _('Append to field'),
                        ]

    def __init__(self, window, rows, model, tab, refresh_books):
        ResizableDialog.__init__(self, window)
        Ui_MetadataBulkDialog.__init__(self)
        self.model = model
        self.db = model.db
        self.refresh_book_list.setChecked(gprefs['refresh_book_list_on_bulk_edit'])
        self.refresh_book_list.toggled.connect(self.save_refresh_booklist)
        self.ids = [self.db.id(r) for r in rows]
        self.first_title = self.db.title(self.ids[0], index_is_id=True)
        self.cover_clone.setToolTip(unicode(self.cover_clone.toolTip()) + ' (%s)' % self.first_title)
        self.box_title.setText('<p>' +
                _('Editing meta information for <b>%d books</b>') %
                len(rows))
        self.write_series = False
        self.changed = False
        self.refresh_books = refresh_books
        self.comments = null
        self.comments_button.clicked.connect(self.set_comments)

        all_tags = self.db.all_tags()
        self.tags.update_items_cache(all_tags)
        self.remove_tags.update_items_cache(all_tags)

        self.initialize_combos()

        for f in sorted(self.db.all_formats()):
            self.remove_format.addItem(f)

        self.remove_format.setCurrentIndex(-1)

        self.series.currentIndexChanged[int].connect(self.series_changed)
        self.series.editTextChanged.connect(self.series_changed)
        self.tag_editor_button.clicked.connect(self.tag_editor)
        self.autonumber_series.stateChanged[int].connect(self.auto_number_changed)
        self.pubdate.setMinimumDateTime(UNDEFINED_QDATETIME)
        self.pubdate_cw = CalendarWidget(self.pubdate)
        self.pubdate.setCalendarWidget(self.pubdate_cw)
        pubdate_format = tweaks['gui_pubdate_display_format']
        if pubdate_format is not None:
            self.pubdate.setDisplayFormat(pubdate_format)
        self.pubdate.setSpecialValueText(_('Undefined'))
        self.clear_pubdate_button.clicked.connect(self.clear_pubdate)
        self.pubdate.dateTimeChanged.connect(self.do_apply_pubdate)
        self.adddate.setDateTime(QDateTime.currentDateTime())
        self.adddate.setMinimumDateTime(UNDEFINED_QDATETIME)
        adddate_format = tweaks['gui_timestamp_display_format']
        if adddate_format is not None:
            self.adddate.setDisplayFormat(adddate_format)
        self.adddate.setSpecialValueText(_('Undefined'))
        self.clear_adddate_button.clicked.connect(self.clear_adddate)
        self.adddate.dateTimeChanged.connect(self.do_apply_adddate)

        if len(self.db.custom_field_keys(include_composites=False)) == 0:
            self.central_widget.removeTab(1)
        else:
            self.create_custom_column_editors()

        self.prepare_search_and_replace()

        self.button_box.clicked.connect(self.button_clicked)
        self.button_box.button(QDialogButtonBox.Apply).setToolTip(_(
            'Immediately make all changes without closing the dialog. '
            'This operation cannot be canceled or undone'))
        self.do_again = False
        self.central_widget.setCurrentIndex(tab)
        geom = gprefs.get('bulk_metadata_window_geometry', None)
        if geom is not None:
            self.restoreGeometry(bytes(geom))
        ct = gprefs.get('bulk_metadata_window_tab', 0)
        self.central_widget.setCurrentIndex(ct)
        self.languages.init_langs(self.db)
        self.languages.setEditText('')
        self.authors.setFocus(Qt.OtherFocusReason)
        self.exec_()

    def set_comments(self):
        from calibre.gui2.dialogs.comments_dialog import CommentsDialog
        d = CommentsDialog(self, '' if self.comments is null else (self.comments or ''), _('Comments'))
        if d.exec_() == d.Accepted:
            self.comments = d.textbox.html
            b = self.comments_button
            b.setStyleSheet('QPushButton { font-weight: bold }')
            if unicode(b.text())[-1] != '*':
                b.setText(unicode(b.text()) + ' *')

    def save_refresh_booklist(self, *args):
        gprefs['refresh_book_list_on_bulk_edit'] = bool(self.refresh_book_list.isChecked())

    def save_state(self, *args):
        gprefs['bulk_metadata_window_geometry'] = \
            bytearray(self.saveGeometry())
        gprefs['bulk_metadata_window_tab'] = self.central_widget.currentIndex()

    def do_apply_pubdate(self, *args):
        self.apply_pubdate.setChecked(True)

    def clear_pubdate(self, *args):
        self.pubdate.setDateTime(UNDEFINED_QDATETIME)

    def do_apply_adddate(self, *args):
        self.apply_adddate.setChecked(True)

    def clear_adddate(self, *args):
        self.adddate.setDateTime(UNDEFINED_QDATETIME)

    def button_clicked(self, which):
        if which == self.button_box.button(QDialogButtonBox.Apply):
            self.do_again = True
            self.accept()

    # S&R {{{
    def prepare_search_and_replace(self):
        self.search_for.initialize('bulk_edit_search_for')
        self.replace_with.initialize('bulk_edit_replace_with')
        self.s_r_template.initialize('bulk_edit_template')
        self.test_text.initialize('bulk_edit_test_test')
        self.all_fields = ['']
        self.writable_fields = ['']
        fm = self.db.field_metadata
        for f in fm:
            if (f in ['author_sort'] or
                    (fm[f]['datatype'] in ['text', 'series', 'enumeration', 'comments']
                     and fm[f].get('search_terms', None)
                     and f not in ['formats', 'ondevice', 'series_sort']) or
                    (fm[f]['datatype'] in ['int', 'float', 'bool', 'datetime'] and
                     f not in ['id', 'timestamp'])):
                self.all_fields.append(f)
                self.writable_fields.append(f)
            if fm[f]['datatype'] == 'composite':
                self.all_fields.append(f)
        self.all_fields.sort()
        self.all_fields.insert(1, '{template}')
        self.writable_fields.sort()
        self.search_field.setMaxVisibleItems(25)
        self.destination_field.setMaxVisibleItems(25)
        self.testgrid.setColumnStretch(1, 1)
        self.testgrid.setColumnStretch(2, 1)
        offset = 10
        self.s_r_number_of_books = min(10, len(self.ids))
        for i in range(1,self.s_r_number_of_books+1):
            w = QLabel(self.tabWidgetPage3)
            w.setText(_('Book %d:')%i)
            self.testgrid.addWidget(w, i+offset, 0, 1, 1)
            w = QLineEdit(self.tabWidgetPage3)
            w.setReadOnly(True)
            name = 'book_%d_text'%i
            setattr(self, name, w)
            self.book_1_text.setObjectName(name)
            self.testgrid.addWidget(w, i+offset, 1, 1, 1)
            w = QLineEdit(self.tabWidgetPage3)
            w.setReadOnly(True)
            name = 'book_%d_result'%i
            setattr(self, name, w)
            self.book_1_text.setObjectName(name)
            self.testgrid.addWidget(w, i+offset, 2, 1, 1)

        ident_types = sorted(self.db.get_all_identifier_types(), key=sort_key)
        self.s_r_dst_ident.setCompleter(QCompleter(ident_types))
        try:
            self.s_r_dst_ident.setPlaceholderText(_('Enter an identifier type'))
        except:
            pass
        self.s_r_src_ident.addItems(ident_types)

        self.main_heading = _(
                 '<b>You can destroy your library using this feature.</b> '
                 'Changes are permanent. There is no undo function. '
                 'You are strongly encouraged to back up your library '
                 'before proceeding.<p>'
                 'Search and replace in text fields using character matching '
                 'or regular expressions. ')

        self.character_heading = _(
                 'In character mode, the field is searched for the entered '
                 'search text. The text is replaced by the specified replacement '
                 'text everywhere it is found in the specified field. After '
                 'replacement is finished, the text can be changed to '
                 'upper-case, lower-case, or title-case. If the case-sensitive '
                 'check box is checked, the search text must match exactly. If '
                 'it is unchecked, the search text will match both upper- and '
                 'lower-case letters'
                 )

        self.regexp_heading = _(
                 'In regular expression mode, the search text is an '
                 'arbitrary python-compatible regular expression. The '
                 'replacement text can contain backreferences to parenthesized '
                 'expressions in the pattern. The search is not anchored, '
                 'and can match and replace multiple times on the same string. '
                 'The modification functions (lower-case etc) are applied to the '
                 'matched text, not to the field as a whole. '
                 'The destination box specifies the field where the result after '
                 'matching and replacement is to be assigned. You can replace '
                 'the text in the field, or prepend or append the matched text. '
                 'See <a href="http://docs.python.org/library/re.html"> '
                 'this reference</a> for more information on python\'s regular '
                 'expressions, and in particular the \'sub\' function.'
                 )

        self.search_mode.addItems(self.s_r_match_modes)
        self.search_mode.setCurrentIndex(dynamic.get('s_r_search_mode', 0))
        self.replace_mode.addItems(self.s_r_replace_modes)
        self.replace_mode.setCurrentIndex(0)

        self.s_r_search_mode = 0
        self.s_r_error = None
        self.s_r_obj = None

        self.replace_func.addItems(sorted(self.s_r_functions.keys()))
        self.search_mode.currentIndexChanged[int].connect(self.s_r_search_mode_changed)
        self.search_field.currentIndexChanged[int].connect(self.s_r_search_field_changed)
        self.destination_field.currentIndexChanged[int].connect(self.s_r_destination_field_changed)

        self.replace_mode.currentIndexChanged[int].connect(self.s_r_paint_results)
        self.replace_func.currentIndexChanged[str].connect(self.s_r_paint_results)
        self.search_for.editTextChanged[str].connect(self.s_r_paint_results)
        self.replace_with.editTextChanged[str].connect(self.s_r_paint_results)
        self.test_text.editTextChanged[str].connect(self.s_r_paint_results)
        self.comma_separated.stateChanged.connect(self.s_r_paint_results)
        self.case_sensitive.stateChanged.connect(self.s_r_paint_results)
        self.s_r_src_ident.currentIndexChanged[int].connect(self.s_r_identifier_type_changed)
        self.s_r_dst_ident.textChanged.connect(self.s_r_paint_results)
        self.s_r_template.lost_focus.connect(self.s_r_template_changed)
        self.central_widget.setCurrentIndex(0)

        self.search_for.completer().setCaseSensitivity(Qt.CaseSensitive)
        self.replace_with.completer().setCaseSensitivity(Qt.CaseSensitive)
        self.s_r_template.completer().setCaseSensitivity(Qt.CaseSensitive)

        self.s_r_search_mode_changed(self.search_mode.currentIndex())
        self.multiple_separator.setFixedWidth(30)
        self.multiple_separator.setText(' ::: ')
        self.multiple_separator.textChanged.connect(self.s_r_separator_changed)
        self.results_count.valueChanged[int].connect(self.s_r_display_bounds_changed)
        self.starting_from.valueChanged[int].connect(self.s_r_display_bounds_changed)

        self.save_button.clicked.connect(self.s_r_save_query)
        self.remove_button.clicked.connect(self.s_r_remove_query)

        self.queries = JSONConfig("search_replace_queries")
        self.saved_search_name = ''
        self.query_field.addItem("")
        self.query_field_values = sorted([q for q in self.queries], key=sort_key)
        self.query_field.addItems(self.query_field_values)
        self.query_field.currentIndexChanged[str].connect(self.s_r_query_change)
        self.query_field.setCurrentIndex(0)
        self.search_field.setCurrentIndex(0)
        self.s_r_search_field_changed(0)

    def s_r_sf_itemdata(self, idx):
        if idx is None:
            idx = self.search_field.currentIndex()
        return unicode(self.search_field.itemData(idx).toString())

    def s_r_df_itemdata(self, idx):
        if idx is None:
            idx = self.destination_field.currentIndex()
        return unicode(self.destination_field.itemData(idx).toString())

    def s_r_get_field(self, mi, field):
        if field:
            if field == '{template}':
                v = SafeFormat().safe_format(
                    unicode(self.s_r_template.text()), mi, _('S/R TEMPLATE ERROR'), mi)
                return [v]
            fm = self.db.metadata_for_field(field)
            if field == 'sort':
                val = mi.get('title_sort', None)
            elif fm['datatype'] == 'datetime':
                val = mi.format_field(field)[1]
            else:
                val = mi.get(field, None)
            if isinstance(val, (int, float, bool)):
                val = str(val)
            elif fm['is_csp']:
                # convert the csp dict into a list
                id_type = unicode(self.s_r_src_ident.currentText())
                if id_type:
                    val = [val.get(id_type, '')]
                else:
                    val = [u'%s:%s'%(t[0], t[1]) for t in val.iteritems()]
            if val is None:
                val = [] if fm['is_multiple'] else ['']
            elif not fm['is_multiple']:
                val = [val]
            elif fm['datatype'] == 'composite':
                val = [v2.strip() for v2 in val.split(fm['is_multiple']['ui_to_list'])]
            elif field == 'authors':
                val = [v2.replace('|', ',') for v2 in val]
        else:
            val = []
        if not val:
            val = ['']
        return val

    def s_r_display_bounds_changed(self, i):
        self.s_r_search_field_changed(self.search_field.currentIndex())

    def s_r_template_changed(self):
        self.s_r_search_field_changed(self.search_field.currentIndex())

    def s_r_identifier_type_changed(self, idx):
        self.s_r_search_field_changed(self.search_field.currentIndex())
        self.s_r_paint_results(idx)

    def s_r_search_field_changed(self, idx):
        self.s_r_template.setVisible(False)
        self.template_label.setVisible(False)
        self.s_r_src_ident_label.setVisible(False)
        self.s_r_src_ident.setVisible(False)
        if idx == 1:  # Template
            self.s_r_template.setVisible(True)
            self.template_label.setVisible(True)
        elif self.s_r_sf_itemdata(idx) == 'identifiers':
            self.s_r_src_ident_label.setVisible(True)
            self.s_r_src_ident.setVisible(True)

        for i in range(0, self.s_r_number_of_books):
            w = getattr(self, 'book_%d_text'%(i+1))
            mi = self.db.get_metadata(self.ids[i], index_is_id=True)
            src = self.s_r_sf_itemdata(idx)
            t = self.s_r_get_field(mi, src)
            if len(t) > 1:
                t = t[self.starting_from.value()-1:
                      self.starting_from.value()-1 + self.results_count.value()]
            w.setText(unicode(self.multiple_separator.text()).join(t))

        if self.search_mode.currentIndex() == 0:
            self.destination_field.setCurrentIndex(idx)
        else:
            self.s_r_destination_field_changed(self.destination_field.currentIndex())
            self.s_r_paint_results(None)

    def s_r_destination_field_changed(self, idx):
        self.s_r_dst_ident_label.setVisible(False)
        self.s_r_dst_ident.setVisible(False)
        txt = self.s_r_df_itemdata(idx)
        if not txt:
            txt = self.s_r_sf_itemdata(None)
        if txt and txt in self.writable_fields:
            if txt == 'identifiers':
                self.s_r_dst_ident_label.setVisible(True)
                self.s_r_dst_ident.setVisible(True)
            self.destination_field_fm = self.db.metadata_for_field(txt)
        self.s_r_paint_results(None)

    def s_r_search_mode_changed(self, val):
        self.search_field.clear()
        self.destination_field.clear()
        if val == 0:
            for f in self.writable_fields:
                self.search_field.addItem(f if f != 'sort' else 'title_sort', f)
                self.destination_field.addItem(f if f != 'sort' else 'title_sort', f)
            self.destination_field.setCurrentIndex(0)
            self.destination_field.setVisible(False)
            self.destination_field_label.setVisible(False)
            self.replace_mode.setCurrentIndex(0)
            self.replace_mode.setVisible(False)
            self.replace_mode_label.setVisible(False)
            self.comma_separated.setVisible(False)
            self.s_r_heading.setText('<p>'+self.main_heading + self.character_heading)
        else:
            self.search_field.blockSignals(True)
            self.destination_field.blockSignals(True)
            for f in self.all_fields:
                self.search_field.addItem(f if f != 'sort' else 'title_sort', f)
            for f in self.writable_fields:
                self.destination_field.addItem(f if f != 'sort' else 'title_sort', f)
            self.search_field.blockSignals(False)
            self.destination_field.blockSignals(False)
            self.destination_field.setVisible(True)
            self.destination_field_label.setVisible(True)
            self.replace_mode.setVisible(True)
            self.replace_mode_label.setVisible(True)
            self.comma_separated.setVisible(True)
            self.s_r_heading.setText('<p>'+self.main_heading + self.regexp_heading)
        self.s_r_paint_results(None)

    def s_r_separator_changed(self, txt):
        self.s_r_search_field_changed(self.search_field.currentIndex())

    def s_r_set_colors(self):
        if self.s_r_error is not None:
            col = 'rgb(255, 0, 0, 20%)'
            self.test_result.setText(self.s_r_error.message)
        else:
            col = 'rgb(0, 255, 0, 20%)'
        self.test_result.setStyleSheet('QLineEdit { color: black; '
                                       'background-color: %s; }'%col)
        for i in range(0,self.s_r_number_of_books):
            getattr(self, 'book_%d_result'%(i+1)).setText('')

    def s_r_func(self, match):
        rfunc = self.s_r_functions[unicode(self.replace_func.currentText())]
        rtext = unicode(self.replace_with.text())
        rtext = match.expand(rtext)
        return rfunc(rtext)

    def s_r_do_regexp(self, mi):
        src_field = self.s_r_sf_itemdata(None)
        src = self.s_r_get_field(mi, src_field)
        result = []
        rfunc = self.s_r_functions[unicode(self.replace_func.currentText())]
        for s in src:
            t = self.s_r_obj.sub(self.s_r_func, s)
            if self.search_mode.currentIndex() == 0:
                t = rfunc(t)
            result.append(t)
        return result

    def s_r_do_destination(self, mi, val):
        src = self.s_r_sf_itemdata(None)
        if src == '':
            return ''
        dest = self.s_r_df_itemdata(None)
        if dest == '':
            if (src == '{template}' or
                        self.db.metadata_for_field(src)['datatype'] == 'composite'):
                raise Exception(_('You must specify a destination when source is '
                                  'a composite field or a template'))
            dest = src
        dest_mode = self.replace_mode.currentIndex()

        if self.destination_field_fm['is_csp']:
            dest_ident = unicode(self.s_r_dst_ident.text())
            if not dest_ident or (src == 'identifiers' and dest_ident == '*'):
                raise Exception(_('You must specify a destination identifier type'))

        if self.destination_field_fm['is_multiple']:
            if self.comma_separated.isChecked():
                splitter = self.destination_field_fm['is_multiple']['ui_to_list']
                res = []
                for v in val:
                    res.extend([x.strip() for x in v.split(splitter) if x.strip()])
                val = res
            else:
                val = [v.replace(',', '') for v in val]

        if dest_mode != 0:
            dest_val = mi.get(dest, '')
            if self.db.metadata_for_field(dest)['is_csp']:
                dst_id_type = unicode(self.s_r_dst_ident.text())
                if dst_id_type:
                    dest_val = [dest_val.get(dst_id_type, '')]
                else:
                    # convert the csp dict into a list
                    dest_val = [u'%s:%s'%(t[0], t[1]) for t in dest_val.iteritems()]
            if dest_val is None:
                dest_val = []
            elif not isinstance(dest_val, list):
                dest_val = [dest_val]
        else:
            dest_val = []

        if dest_mode == 1:
            val.extend(dest_val)
        elif dest_mode == 2:
            val[0:0] = dest_val
        return val

    def s_r_replace_mode_separator(self):
        if self.comma_separated.isChecked():
            return ','
        return ''

    def s_r_paint_results(self, txt):
        self.s_r_error = None
        self.s_r_set_colors()

        if self.case_sensitive.isChecked():
            flags = 0
        else:
            flags = re.I

        flags |= re.UNICODE

        try:
            stext = unicode(self.search_for.text())
            if not stext:
                raise Exception(_('You must specify a search expression in the "Search for" field'))
            if self.search_mode.currentIndex() == 0:
                self.s_r_obj = re.compile(re.escape(stext), flags)
            else:
                self.s_r_obj = re.compile(stext, flags)
        except Exception as e:
            self.s_r_obj = None
            self.s_r_error = e
            self.s_r_set_colors()
            return

        try:
            self.test_result.setText(self.s_r_obj.sub(self.s_r_func,
                                     unicode(self.test_text.text())))
        except Exception as e:
            self.s_r_error = e
            self.s_r_set_colors()
            return

        for i in range(0,self.s_r_number_of_books):
            mi = self.db.get_metadata(self.ids[i], index_is_id=True)
            wr = getattr(self, 'book_%d_result'%(i+1))
            try:
                result = self.s_r_do_regexp(mi)
                t = self.s_r_do_destination(mi, result)
                if len(t) > 1 and self.destination_field_fm['is_multiple']:
                    t = t[self.starting_from.value()-1:
                          self.starting_from.value()-1 + self.results_count.value()]
                    t = unicode(self.multiple_separator.text()).join(t)
                else:
                    t = self.s_r_replace_mode_separator().join(t)
                wr.setText(t)
            except Exception as e:
                self.s_r_error = e
                self.s_r_set_colors()
                break

    def do_search_replace(self, book_id):
        source = self.s_r_sf_itemdata(None)
        if not source or not self.s_r_obj:
            return
        dest = self.s_r_df_itemdata(None)
        if not dest:
            dest = source
        dfm = self.db.field_metadata[dest]
        mi = self.db.new_api.get_proxy_metadata(book_id)
        val = self.s_r_do_regexp(mi)
        val = self.s_r_do_destination(mi, val)
        if dfm['is_multiple']:
            if dfm['is_csp']:
                # convert the colon-separated pair strings back into a dict,
                # which is what set_identifiers wants
                dst_id_type = unicode(self.s_r_dst_ident.text())
                if dst_id_type and dst_id_type != '*':
                    v = ''.join(val)
                    ids = mi.get(dest)
                    ids[dst_id_type] = v
                    val = ids
                else:
                    try:
                        val = dict([(t.split(':')) for t in val])
                    except:
                        raise Exception(_('Invalid identifier string. It must be a '
                                          'comma-separated list of pairs of '
                                          'strings separated by a colon'))
        else:
            val = self.s_r_replace_mode_separator().join(val)
            if dest == 'title' and len(val) == 0:
                val = _('Unknown')

        self.set_field_calls[dest][book_id] = val
    # }}}

    def create_custom_column_editors(self):
        w = self.central_widget.widget(1)
        layout = QGridLayout()
        self.custom_column_widgets, self.__cc_spacers = \
            populate_metadata_page(layout, self.db, self.ids, parent=w,
                                   two_column=False, bulk=True)
        w.setLayout(layout)
        self.__custom_col_layouts = [layout]
        ans = self.custom_column_widgets
        for i in range(len(ans)-1):
            w.setTabOrder(ans[i].widgets[-1], ans[i+1].widgets[1])
            for c in range(2, len(ans[i].widgets), 2):
                w.setTabOrder(ans[i].widgets[c-1], ans[i].widgets[c+1])

    def initialize_combos(self):
        self.initalize_authors()
        self.initialize_series()
        self.initialize_publisher()
        for x in ('authors', 'publisher', 'series'):
            x = getattr(self, x)
            x.setSizeAdjustPolicy(x.AdjustToMinimumContentsLengthWithIcon)
            x.setMinimumContentsLength(25)

    def initalize_authors(self):
        all_authors = self.db.all_authors()
        all_authors.sort(key=lambda x : sort_key(x[1]))

        self.authors.set_separator('&')
        self.authors.set_space_before_sep(True)
        self.authors.set_add_separator(tweaks['authors_completer_append_separator'])
        self.authors.update_items_cache(self.db.all_author_names())
        self.authors.show_initial_value('')

    def initialize_series(self):
        all_series = self.db.all_series()
        all_series.sort(key=lambda x : sort_key(x[1]))
        self.series.set_separator(None)
        self.series.update_items_cache([x[1] for x in all_series])
        self.series.show_initial_value('')

    def initialize_publisher(self):
        all_publishers = self.db.all_publishers()
        all_publishers.sort(key=lambda x : sort_key(x[1]))
        self.publisher.set_separator(None)
        self.publisher.update_items_cache([x[1] for x in all_publishers])
        self.publisher.show_initial_value('')

    def tag_editor(self, *args):
        d = TagEditor(self, self.db, None)
        d.exec_()
        if d.result() == QDialog.Accepted:
            tag_string = ', '.join(d.tags)
            self.tags.setText(tag_string)
            self.tags.update_items_cache(self.db.all_tags())
            self.remove_tags.update_items_cache(self.db.all_tags())

    def auto_number_changed(self, state):
        if state:
            self.series_numbering_restarts.setEnabled(True)
            self.series_start_number.setEnabled(True)
        else:
            self.series_numbering_restarts.setEnabled(False)
            self.series_numbering_restarts.setChecked(False)
            self.series_start_number.setEnabled(False)
            self.series_start_number.setValue(1)

    def reject(self):
        self.save_state()
        ResizableDialog.reject(self)

    def accept(self):
        self.save_state()
        if len(self.ids) < 1:
            return QDialog.accept(self)
        try:
            source = self.s_r_sf_itemdata(None)
        except:
            source = ''
        do_sr = source and self.s_r_obj

        if self.s_r_error is not None and do_sr:
            error_dialog(self, _('Search/replace invalid'),
                    _('Search pattern is invalid: %s')%self.s_r_error.message,
                    show=True)
            return False
        self.changed = bool(self.ids)
        # Cache values from GUI so that Qt widgets are not used in
        # non GUI thread
        for w in getattr(self, 'custom_column_widgets', []):
            w.gui_val

        remove_all = self.remove_all_tags.isChecked()
        remove = []
        if not remove_all:
            remove = unicode(self.remove_tags.text()).strip().split(',')
        add = unicode(self.tags.text()).strip().split(',')
        au = unicode(self.authors.text())
        aus = unicode(self.author_sort.text())
        do_aus = self.author_sort.isEnabled()
        rating = self.rating.value()
        pub = unicode(self.publisher.text())
        do_series = self.write_series
        clear_series = self.clear_series.isChecked()
        clear_pub = self.clear_pub.isChecked()
        series = unicode(self.series.currentText()).strip()
        do_autonumber = self.autonumber_series.isChecked()
        do_series_restart = self.series_numbering_restarts.isChecked()
        series_start_value = self.series_start_number.value()
        do_remove_format = self.remove_format.currentIndex() > -1
        remove_format = unicode(self.remove_format.currentText())
        do_swap_ta = self.swap_title_and_author.isChecked()
        do_remove_conv = self.remove_conversion_settings.isChecked()
        do_auto_author = self.auto_author_sort.isChecked()
        do_title_case = self.change_title_to_title_case.isChecked()
        do_title_sort = self.update_title_sort.isChecked()
        clear_languages = self.clear_languages.isChecked()
        restore_original = self.restore_original.isChecked()
        languages = self.languages.lang_codes
        pubdate = adddate = None
        if self.apply_pubdate.isChecked():
            pubdate = qt_to_dt(self.pubdate.dateTime())
        if self.apply_adddate.isChecked():
            adddate = qt_to_dt(self.adddate.dateTime())

        cover_action = None
        if self.cover_remove.isChecked():
            cover_action = 'remove'
        elif self.cover_generate.isChecked():
            cover_action = 'generate'
        elif self.cover_from_fmt.isChecked():
            cover_action = 'fromfmt'
        elif self.cover_trim.isChecked():
            cover_action = 'trim'
        elif self.cover_clone.isChecked():
            cover_action = 'clone'

        args = Settings(remove_all, remove, add, au, aus, do_aus, rating, pub, do_series,
                do_autonumber, do_remove_format, remove_format, do_swap_ta,
                do_remove_conv, do_auto_author, series, do_series_restart,
                series_start_value, do_title_case, cover_action, clear_series, clear_pub,
                pubdate, adddate, do_title_sort, languages, clear_languages,
                restore_original, self.comments)

        self.set_field_calls = defaultdict(dict)
        bb = MyBlockingBusy(args, self.ids, self.db, self.refresh_books,
            getattr(self, 'custom_column_widgets', []),
            self.do_search_replace, do_sr, self.set_field_calls, parent=self)

        # The metadata backup thread causes database commits
        # which can slow down bulk editing of large numbers of books
        self.model.stop_metadata_backup()
        try:
            bb.exec_()
        finally:
            self.model.start_metadata_backup()

        bb.thread = bb.db = bb.cc_widgets = None

        if bb.error is not None:
            return error_dialog(self, _('Failed'),
                    bb.error[0], det_msg=bb.error[1],
                    show=True)

        dynamic['s_r_search_mode'] = self.search_mode.currentIndex()
        self.db.clean()
        return QDialog.accept(self)

    def series_changed(self, *args):
        self.write_series = bool(unicode(self.series.currentText()).strip())
        self.autonumber_series.setEnabled(True)

    def s_r_remove_query(self, *args):
        if self.query_field.currentIndex() == 0:
            return

        if not question_dialog(self, _("Delete saved search/replace"),
                _("The selected saved search/replace will be deleted. "
                    "Are you sure?")):
            return

        item_id = self.query_field.currentIndex()
        item_name = unicode(self.query_field.currentText())

        self.query_field.blockSignals(True)
        self.query_field.removeItem(item_id)
        self.query_field.blockSignals(False)
        self.query_field.setCurrentIndex(0)

        if item_name in self.queries.keys():
            del(self.queries[item_name])
            self.queries.commit()

    def s_r_save_query(self, *args):
        names = ['']
        names.extend(self.query_field_values)
        try:
            dex = names.index(self.saved_search_name)
        except:
            dex = 0
        name = ''
        while not name:
            name, ok =  QInputDialog.getItem(self, _('Save search/replace'),
                    _('Search/replace name:'), names, dex, True)
            if not ok:
                return
            if not name:
                error_dialog(self, _("Save search/replace"),
                        _("You must provide a name."), show=True)
        new = True
        name = unicode(name)
        if name in self.queries.keys():
            if not question_dialog(self, _("Save search/replace"),
                    _("That saved search/replace already exists and will be overwritten. "
                        "Are you sure?")):
                return
            new = False

        query = {}
        query['name'] = name
        query['search_field'] = unicode(self.search_field.currentText())
        query['search_mode'] = unicode(self.search_mode.currentText())
        query['s_r_template'] = unicode(self.s_r_template.text())
        query['s_r_src_ident'] = unicode(self.s_r_src_ident.currentText())
        query['search_for'] = unicode(self.search_for.text())
        query['case_sensitive'] = self.case_sensitive.isChecked()
        query['replace_with'] = unicode(self.replace_with.text())
        query['replace_func'] = unicode(self.replace_func.currentText())
        query['destination_field'] = unicode(self.destination_field.currentText())
        query['s_r_dst_ident'] = unicode(self.s_r_dst_ident.text())
        query['replace_mode'] = unicode(self.replace_mode.currentText())
        query['comma_separated'] = self.comma_separated.isChecked()
        query['results_count'] = self.results_count.value()
        query['starting_from'] = self.starting_from.value()
        query['multiple_separator'] = unicode(self.multiple_separator.text())

        self.queries[name] = query
        self.queries.commit()

        if new:
            self.query_field.blockSignals(True)
            self.query_field.clear()
            self.query_field.addItem('')
            self.query_field_values = sorted([q for q in self.queries], key=sort_key)
            self.query_field.addItems(self.query_field_values)
            self.query_field.blockSignals(False)
        self.query_field.setCurrentIndex(self.query_field.findText(name))

    def s_r_query_change(self, item_name):
        if not item_name:
            self.s_r_reset_query_fields()
            self.saved_search_name = ''
            return
        item = self.queries.get(unicode(item_name), None)
        if item is None:
            self.s_r_reset_query_fields()
            return
        self.saved_search_name = item_name

        def set_text(attr, key):
            try:
                attr.setText(item[key])
            except:
                pass

        def set_checked(attr, key):
            try:
                attr.setChecked(item[key])
            except:
                attr.setChecked(False)

        def set_value(attr, key):
            try:
                attr.setValue(int(item[key]))
            except:
                attr.setValue(0)

        def set_index(attr, key):
            try:
                attr.setCurrentIndex(attr.findText(item[key]))
            except:
                attr.setCurrentIndex(0)

        set_index(self.search_mode, 'search_mode')
        set_index(self.search_field, 'search_field')
        set_text(self.s_r_template, 's_r_template')

        self.s_r_template_changed()  # simulate gain/loss of focus

        set_index(self.s_r_src_ident, 's_r_src_ident')
        set_text(self.s_r_dst_ident, 's_r_dst_ident')
        set_text(self.search_for, 'search_for')
        set_checked(self.case_sensitive, 'case_sensitive')
        set_text(self.replace_with, 'replace_with')
        set_index(self.replace_func, 'replace_func')
        set_index(self.destination_field, 'destination_field')
        set_index(self.replace_mode, 'replace_mode')
        set_checked(self.comma_separated, 'comma_separated')
        set_value(self.results_count, 'results_count')
        set_value(self.starting_from, 'starting_from')
        set_text(self.multiple_separator, 'multiple_separator')

    def s_r_reset_query_fields(self):
        # Don't reset the search mode. The user will probably want to use it
        # as it was
        self.search_field.setCurrentIndex(0)
        self.s_r_src_ident.setCurrentIndex(0)
        self.s_r_template.setText("")
        self.search_for.setText("")
        self.case_sensitive.setChecked(False)
        self.replace_with.setText("")
        self.replace_func.setCurrentIndex(0)
        self.destination_field.setCurrentIndex(0)
        self.s_r_dst_ident.setText('')
        self.replace_mode.setCurrentIndex(0)
        self.comma_separated.setChecked(True)
        self.results_count.setValue(999)
        self.starting_from.setValue(1)
        self.multiple_separator.setText(" ::: ")
class BookSettings(object):
    '''Holds book specific settings'''

    def __init__(self, database, book_id, connections):
        self._connections = connections

        book_path = database.field_for('path', book_id).replace('/', os.sep)

        self._prefs = JSONConfig(os.path.join(book_path, 'book_settings'), base_path=LIBRARY)
        self._prefs.setdefault('asin', '')
        self._prefs.setdefault('goodreads_url', '')
        self._prefs.setdefault('aliases', {})
        self._prefs.setdefault('sample_xray', '')
        self._prefs.commit()

        self._title = database.field_for('title', book_id)
        self._author = ' & '.join(database.field_for('authors', book_id))

        self._asin = self._prefs['asin'] if self._prefs['asin'] != '' else None
        self._goodreads_url = self._prefs['goodreads_url']
        self._sample_xray = self._prefs['sample_xray']

        if not self._asin:
            identifiers = database.field_for('identifiers', book_id)
            if 'mobi-asin' in identifiers.keys():
                self._asin = database.field_for('identifiers', book_id)['mobi-asin'].decode('ascii')
                self._prefs['asin'] = self._asin
            else:
                self._asin = self.search_for_asin_on_amazon(self.title_and_author)
                if self._asin:
                    metadata = database.get_metadata(book_id)
                    identifiers = metadata.get_identifiers()
                    identifiers['mobi-asin'] = self._asin
                    metadata.set_identifiers(identifiers)
                    database.set_metadata(book_id, metadata)
                    self._prefs['asin'] = self._asin

        if self._goodreads_url == '':
            url = None
            if self._asin:
                url = self.search_for_goodreads_url(self._asin)
            if not url and self._title != 'Unknown' and self._author != 'Unknown':
                url = self.search_for_goodreads_url(self.title_and_author)

            if url:
                self._goodreads_url = url
                self._prefs['goodreads_url'] = self._goodreads_url
                if not self._asin:
                    self._asin = self.search_for_asin_on_goodreads(self._goodreads_url)
                    if self._asin:
                        metadata = database.get_metadata(book_id)
                        identifiers = metadata.get_identifiers()
                        identifiers['mobi-asin'] = self._asin
                        metadata.set_identifiers(identifiers)
                        database.set_metadata(book_id, metadata)
                        self._prefs['asin'] = self._asin

        self._aliases = self._prefs['aliases']

        self.save()

    @property
    def prefs(self):
        return self._prefs

    @property
    def asin(self):
        return self._asin

    @asin.setter
    def asin(self, val):
        self._asin = val

    @property
    def sample_xray(self):
        return self._sample_xray

    @sample_xray.setter
    def sample_xray(self, value):
        self._sample_xray = value

    @property
    def title(self):
        return self._title

    @property
    def author(self):
        return self._author

    @property
    def title_and_author(self):
        return '{0} - {1}'.format(self._title, self._author)

    @property
    def goodreads_url(self):
        return self._goodreads_url

    @goodreads_url.setter
    def goodreads_url(self, val):
        self._goodreads_url = val

    @property
    def aliases(self):
        return self._aliases

    def set_aliases(self, label, aliases):
        '''Sets label's aliases to aliases'''

        # 'aliases' is a string containing a comma separated list of aliases.

        # Split it, remove whitespace from each element, drop empty strings (strangely,
        # split only does this if you don't specify a separator)

        # so "" -> []  "foo,bar" and " foo   , bar " -> ["foo", "bar"]
        aliases = [x.strip() for x in aliases.split(",") if x.strip()]
        self._aliases[label] = aliases

    def save(self):
        '''Saves current settings in book's settings file'''
        self._prefs['asin'] = self._asin
        self._prefs['goodreads_url'] = self._goodreads_url
        self._prefs['aliases'] = self._aliases
        self._prefs['sample_xray'] = self._sample_xray

    def search_for_asin_on_amazon(self, query):
        '''Search for book's asin on amazon using given query'''
        query = urlencode({'keywords': query})
        url = '/s/ref=sr_qz_back?sf=qz&rh=i%3Adigital-text%2Cn%3A154606011%2Ck%3A' + query[9:] + '&' + query
        try:
            response = open_url(self._connections['amazon'], url)
        except PageDoesNotExist:
            return None

        # check to make sure there are results
        if ('did not match any products' in response and 'Did you mean:' not in response and
                'so we searched in All Departments' not in response):
            return None

        soup = BeautifulSoup(response)
        results = soup.findAll('div', {'id': 'resultsCol'})

        if not results:
            return None

        for result in results:
            if 'Buy now with 1-Click' in str(result):
                asin_search = AMAZON_ASIN_PAT.search(str(result))
                if asin_search:
                    return asin_search.group(1)

        return None

    def search_for_goodreads_url(self, keywords):
        '''Searches for book's goodreads url using given keywords'''
        query = urlencode({'q': keywords})
        try:
            response = open_url(self._connections['goodreads'], '/search?' + query)
        except PageDoesNotExist:
            return None

        # check to make sure there are results
        if 'No results' in response:
            return None

        urlsearch = GOODREADS_URL_PAT.search(response)
        if not urlsearch:
            return None

        # return the full URL with the query parameters removed
        url = 'https://www.goodreads.com' + urlsearch.group(1)
        return urlparse.urlparse(url)._replace(query=None).geturl()

    def search_for_asin_on_goodreads(self, url):
        '''Searches for ASIN of book at given url'''
        book_id_search = BOOK_ID_PAT.search(url)
        if not book_id_search:
            return None

        book_id = book_id_search.group(1)

        try:
            response = open_url(self._connections['goodreads'], '/buttons/glide/' + book_id)
        except PageDoesNotExist:
            return None

        book_asin_search = GOODREADS_ASIN_PAT.search(response)
        if not book_asin_search:
            return None

        return book_asin_search.group(1)

    def update_aliases(self, source, source_type='url'):
        if source_type.lower() == 'url':
            self.update_aliases_from_url(source)
            return
        if source_type.lower() == 'asc':
            self.update_aliases_from_asc(source)
            return
        if source_type.lower() == 'json':
            self.update_aliases_from_json(source)

    def update_aliases_from_asc(self, filename):
        '''Gets aliases from sample x-ray file and expands them if users settings say to do so'''
        cursor = connect(filename).cursor()
        characters = {x[1]: [x[1]] for x in cursor.execute('SELECT * FROM entity').fetchall() if x[3] == 1}

        self._aliases = {}
        for alias, fullname in auto_expand_aliases(characters).items():
            if fullname not in self._aliases.keys():
                self._aliases[fullname] = [alias]
                continue
            self._aliases[fullname].append(alias)

    def update_aliases_from_json(self, filename):
        '''Gets aliases from json file'''
        data = json.load(open(filename))
        self._aliases = {name: char['aliases'] for name, char in data['characters'].items()} if 'characters' in data else {}
        if 'settings' in data:
            self._aliases.update({name: setting['aliases'] for name, setting in data['settings'].items()})

    def update_aliases_from_url(self, url):
        '''Gets aliases from Goodreads and expands them if users settings say to do so'''
        try:
            goodreads_parser = GoodreadsParser(url, self._connections['goodreads'], self._asin)
            goodreads_chars = goodreads_parser.get_characters(1)
            goodreads_settings = goodreads_parser.get_settings(len(goodreads_chars))
        except PageDoesNotExist:
            goodreads_chars = {}
            goodreads_settings = {}

        self._aliases = {}
        for char_data in goodreads_chars.values() + goodreads_settings.values():
            if char_data['label'] not in self._aliases.keys():
                self._aliases[char_data['label']] = char_data['aliases']
class DJVUmaker(FileTypePlugin, InterfaceActionBase): # multiple inheritance for gui hooks!
    #NODOC
    name                = PLUGINNAME # Name of the plugin
    description         = ('Convert raster-based document files (Postscript, PDF) to DJVU with GUI'
                          ' button and on-import')
    supported_platforms = ['linux', 'osx', 'windows'] # Platforms this plugin will run on
    author              = 'Joey Korkames' # The author of this plugin
    version             = PLUGINVER   # The version number of this plugin
    # The file types that this plugin will be automatically applied to
    file_types          = set(['pdf','ps', 'eps'])
    on_postimport       = True # Run this plugin after books are addded to the database
    # needs the new db api w/id() bugfix, and podofo.image_count()
    minimum_calibre_version = (2, 22, 0)
    # InterfaceAction plugin location
    actual_plugin = 'calibre_plugins.djvumaker.gui:ConvertToDJVUAction'
    REGISTERED_BACKENDS = collections.OrderedDict()

    @classmethod
    def register_backend(cls, fun):
        """Register backend for future use."""
        cls.REGISTERED_BACKENDS[fun.__name__] = fun
        return fun

    def __init__(self, *args, **kwargs):
        super(DJVUmaker, self).__init__(*args, **kwargs)
        self.prints = prints # Easer access because of Calibre load plugins instead of importing
        # Set default preferences for JSONConfig
        DEFAULT_STORE_VALUES = {}
        DEFAULT_STORE_VALUES['plugin_version'] = PLUGINVER
        DEFAULT_STORE_VALUES['postimport'] = False
        for item in self.REGISTERED_BACKENDS:
            DEFAULT_STORE_VALUES[item] = {
                'flags' : [], 'installed' : False, 'version' : None}
        if 'djvudigital' in self.REGISTERED_BACKENDS:
            DEFAULT_STORE_VALUES['use_backend'] = 'djvudigital'
        else:
            raise Exception('No djvudigital backend.')

        # JSONConfig is a dict-like object,
        # if coresponding .json file has not a specific key, it's got from .defaults
        self.plugin_prefs = JSONConfig(os.path.join('plugins', PLUGINNAME))
        self.plugin_prefs.defaults = DEFAULT_STORE_VALUES

        # make sure to create plugins/djvumaker.json
        # self.plugin_prefs.values() doesn't use self.plugin_prefs.__getitem__()
        # and returns real json, not defaults
        if not self.plugin_prefs.values():
            for key, val in DEFAULT_STORE_VALUES.iteritems():
                self.plugin_prefs[key] = val

    def site_customization_parser(self, use_backend):
        """Parse user input from "Customize plugin" menu. Return backend and cmd flags to use."""
        backend, cmdflags = use_backend, self.plugin_prefs[use_backend]['flags']
        # site_customization is problematic, cannot assume about its content
        try:
            if self.site_customization is not None:
                site_customization = self.site_customization.split()
                if site_customization[0] in self.REGISTERED_BACKENDS:
                    backend = site_customization[0]
                    cmdflags = site_customization[1:]
                elif site_customization[0][0] == '-':
                    backend = use_backend
                    cmdflags = site_customization
                    #`--gsarg=-dFirstPage=1,-dLastPage=1` how to limit page range
                    #more gsargs: https://leanpub.com/pdfkungfoo
                else:
                    # TODO: Custom command implementation
                    #   some template engine with %djvu, %src or sth
                    raise NotImplementedError('Custom commands are not implemented')
        except NotImplementedError:
            raise
        except:
            pass
        return backend, cmdflags

    def run_backend(self, *args, **kwargs):
        """
        Choose proper backend. Check saved settings and overriden from "Customize plugin" menu.

        Possible kwargs:
            cmd_creation_only:bool -- if True, return only command creation function result
        """
        use_backend = self.plugin_prefs['use_backend']
        kwargs['preferences'] = self.plugin_prefs
        try:
            use_backend, kwargs['cmdflags'] = self.site_customization_parser(use_backend)
        except NotImplementedError as err:
            prints('Error: '+ str(err))
            prints('Back to not overriden backend settings...')
            kwargs['cmdflags'] = []

        if 'cmd_creation_only' in kwargs and kwargs['cmd_creation_only']:
            kwargs.pop('cmd_creation_only')
            return self.REGISTERED_BACKENDS[use_backend].__wrapped__(*args, **kwargs)
                #srcdoc, cmdflags, djvu, preferences
        kwargs.pop('cmd_creation_only', None)
        return self.REGISTERED_BACKENDS[use_backend](*args, **kwargs)

    def customization_help(self, gui=True):
        """Method required by calibre. Shows user info in "Customize plugin" menu."""
        # TODO: add info about current JSON settings
        # TODO: proper english
        current_backend = self.plugin_prefs['use_backend']
        flags = ''.join(self.plugin_prefs[current_backend]['flags'])
        command = current_backend + ' ' + flags

        try:
            overr_backend, overr_flags = self.site_customization_parser(current_backend)
        except NotImplementedError as err:
             overriden_info = 'Overriding command is not recognized. {}<br><br>'.format(err.message)
        else:
            overr_flags = ''.join(overr_flags)
            overr_command = overr_backend + ' ' + overr_flags
            if overr_backend != current_backend or overr_flags != flags:
                overriden_info = ('This command is overriden by this plugin customization command:'
                                ' <b>{}</b><br><br>').format(overr_command)
            else:
                overriden_info = '<br><br>'

        help_command = 'calibre-debug -r djvumaker -- --help'
        info = ('<p>You can enter overwritting command and flags to create djvu files.'
                'eg: `pdf2djvu -v`. You have to restart calibre before changes can take effect.<br>'
                'Currently set command is: <b>{}</b><br>'
                '{}'
                'You can read more about plugin customization running "{}" from command line.</p>').format(command, overriden_info, help_command)
        return info

        # return 'Enter additional `djvudigital --help` command-flags here:'

        # os.system('MANPAGER=cat djvudigital --help')
        # TODO: make custom config widget so we can have attrs for each of the wrappers:
        #       djvudigital minidjvu, c44, etc.
        # TODO: `man2html djvumaker` and gui=True for comprehensive help?

    def cli_main(self, args):
        """Handles plugin CLI interface"""
        args = args[1:] # args[0] = PLUGINNAME
        printsd('cli_main enter: args: ', args) # DEBUG
        parser = create_cli_parser(self, PLUGINNAME, PLUGINVER_DOT,
            self.REGISTERED_BACKENDS.keys())
        if len(args) == 0:
            parser.print_help()
            return sys.exit()
        options = parser.parse_args(args)
        options.func(options)

    def cli_test(self, args):
        """Debug method."""
        from calibre.utils.config import config_dir
        prints(config_dir)
        prints(os.path.join(config_dir, 'plugins', 'djvumaker'))
        prints(plugin_dir(PLUGINNAME))
        # prints(subprocess.check_output(['pwd']))

    def cli_backend(self, args):
        #NODOC
        printsd('cli_backend enter: plugin_prefs:', self.plugin_prefs)
        if args.command == 'install':
            self.cli_install_backend(args)
        elif args.command == 'set':
            self.cli_set_backend(args)
        else:
            raise Exception('Command not recognized.')

    def cli_install_backend(self, args):
        #NODOC
        # def brew_install(args, name):
        #     #NODOC
        #     joined = ' '.join(args)
        #     if os.system("which brew >/dev/null") == 0:
        #             if ask_yesno_input("Install {} from brew with args: '{}'?".format(name, joined)):
        #             os.system("brew {}".format(joined))
        #         else:
        #             raise Exception("Homebrew required."
        #                             "Please visit http://github.com/Homebrew/homebrew")

        printsd('cli_install_backend enter: args.backend:', args.backend)
        if not args.backend: # Report currently installed backends if without args
            installed_backend = [k for k, v in {
                    item : self.plugin_prefs[item]['installed'] for item in self.REGISTERED_BACKENDS
                    }.iteritems() if v]
            prints('Currently installed backends: {}'.format(
                ', '.join(installed_backend) if installed_backend else 'None'))
            sys.exit()

        if args.backend == 'djvudigital':
            if isosx:
                # brew_install(["install", "--with-djvu", "ghostscript"], "ghostscript")
                # brew_install(["install", "caskroom/cask/brew-cask"], "brew-cask")
                # brew_install(["cask", "install", "djview"], "DjView.app")

                if os.system("which brew >/dev/null") == 0:
                    os.system("brew install --with-djvu ghostscript")
                else:
                    raise Exception("Homebrew required."
                                    "Please visit http://github.com/Homebrew/homebrew")
                if raw_input("Install DjView.app? (y/n): ").lower() == 'y':
                    os.system("brew install caskroom/cask/brew-cask;"
                              " brew cask install djview")
                else:
                    sys.exit()
            # need a cask for the caminova finder/safari plugin too
            # TODO: make more install scripts
            #       for linux it should be relatively easy
            #       for plain windows probably impossible, only through cygwin
            elif islinux: raise Exception('Only macOS supported')
            elif iswindows: raise Exception('Only macOS supported. Check pdf2djvu backend for solution.')
            elif isbsd: raise Exception('Only macOS supported')
            else: raise Exception('Only macOS supported')
            self.plugin_prefs['djvudigital']['installed'] = True
            self.plugin_prefs.commit() # always use commit if uses nested dict
            # TODO: inherit from JSONConfig and make better implementation for defaults
        elif args.backend == 'pdf2djvu':
            # TODO: neat "Not supported" messages for every backend from function
            err_info = 'Only Windows supported. Try manual installation and add pdf2djvu to PATH env'
            if iswindows:
                success, version = install_pdf2djvu(PLUGINNAME, self.plugin_prefs, log=prints)
            elif isosx: raise Exception(err_info + ' Check djvudigital backend for solution.')
            elif islinux: raise Exception(err_info + ' Can work: `sudo apt-get install pdf2djvu` or your distro equivalent.')
            elif isbsd: raise Exception(err_info)
            else: raise Exception(err_info)
            # TODO: very easy: add support for macOS and linux, just add `make` after download source

            # path?
            # TODO: give flag where to installed_backend
            # TODO: ask if add to path?
            # TODO: should use github api v3
            #       https://developer.github.com/v3/repos/releases/
            #       https://developer.github.com/libraries/
            if success:
                self.plugin_prefs['pdf2djvu']['installed'] = True
                self.plugin_prefs['pdf2djvu']['version'] = version
                self.plugin_prefs.commit() # always use commit if uses nested dict
                prints('Installation of pdf2djvu was succesfull or unrequired.')
            else:
                prints('Installation of pdf2djvu was not succesfull.')
        else:
            raise Exception('Backend not recognized.')

    def cli_set_backend(self, args):
        #NODOC
        if not args.backend:
            prints('Currently set backend: {}'.format(self.plugin_prefs['use_backend']))
            return None
            # sys.exit()

        if args.backend in self.REGISTERED_BACKENDS:
            self.plugin_prefs['use_backend'] = args.backend
            prints('{} successfully set as current backend.'.format(args.backend))
        else:
            raise Exception('Backend not recognized.')
        return None

    def cli_set_postimport(self, args):
        #NODOC
        if args.yes:
            prints('Will try to convert files after import')
            self.plugin_prefs['postimport'] = True
        elif args.no:
            prints('Will not try to convert files after import')
            self.plugin_prefs['postimport'] = False
        else:
            if self.plugin_prefs['postimport']:
                prints('Currently {} tries to convert PDF files after import'.format(PLUGINNAME))
            else:
                prints("Currently {} doesn't do convertion of PDF's after import".format(PLUGINNAME))

    def cli_convert(self, args):
        #NODOC
        printsd(args)
        if args.all:
            # `calibre-debug -r djvumaker -- convert --all`
            printsd('in cli convert_all')
            # TODO: make work `djvumaker -- convert --all`
            # raise NotImplementedError('Convert all is not implemented.')

            user_input = ask_yesno_input('Do you wany to copy-convert all PDFs to DJVU?')
            if not user_input:
                return None

            from calibre.library import db
            from calibre.customize.ui import run_plugins_on_postimport
            db = db() # initialize calibre library database
            for book_id in list(db.all_ids()):
                if db.has_format(book_id, 'DJVU', index_is_id=True):
                    continue
                # TODO: shouldn't work with this code, db has not atributte run_plugins_on_postimport
                #       https://github.com/kovidgoyal/calibre/blob/master/src/calibre/customize/ui.py
                if db.has_format(book_id, 'PDF', index_is_id=True):
                    run_plugins_on_postimport(db, book_id, 'pdf')
                    continue
        elif args.path is not None:
            # `calibre-debug -r djvumaker -- convert -p test.pdf` -> tempfile(test.djvu)
            printsd('in path')
            if is_rasterbook(args.path):
                djvu = self.run_backend(args.path, log=self.prints.func)
                if djvu:
                    input_filename, _ = os.path.splitext(args.path)
                    shutil.copy2(djvu, input_filename + '.djvu')
                    prints("Finished DJVU outputed to: {}.".format(input_filename + '.djvu'))

                    user_input = ask_yesno_input('Do you want to open djvused in subshell?'
                                                 ' (may not work on not macOS)')
                    if not user_input:
                        return None
                    # de-munge the tty
                    sys.stdin = sys.__stdin__
                    sys.stdout = sys.__stdout__
                    sys.stderr = sys.__stderr__
                    os.system("stat '%s'" % djvu)
                    # TODO: doesn't work on Windows, why is it here?
                    os.system("djvused -e dump '%s'" % djvu)
                    os.system("djvused -v '%s'" % djvu)

        elif args.id is not None:
            # `calibre-debug -r djvumaker -- convert -i 123 #id(123).pdf` -> tempfile(id(123).djvu)
            printsd('in convert by id')
            self._postimport(args.id, fork_job=False)

    # -- calibre filetype plugin mandatory methods --
    def run(self, path_to_ebook):
        #NODOC
        return path_to_ebook # noop

    def postimport(self, book_id, book_format, db):
        """Run postimport conversion if it's turned on"""
        if self.plugin_prefs['postimport']:
            return self._postimport(book_id, book_format, db)
        else:
            return None

    def _postimport(self, book_id, book_format=None, db=None, log=None, fork_job=True, abort=None,
                   notifications=None):
        #NODOC IMPORTANT
        # TODO: make general overhaul of starting conversion logic
        if log: # divert our printing to the caller's logger
            prints = log # Log object has __call__ dunder method with INFO level
            prints = partial(prints, '{}:'.format(PLUGINNAME))
        else:
            log = self.prints.func
        try:
            prints
        except NameError:
            prints = self.prints

        if sys.__stdin__.isatty():
            # if run by cli, i.e.:
            #    calibredb add
            #    calibredebug -r djvumaker -- convert -i #id
            # runs also for GUI if run trough `calibredebug -g`
            fork_job = False # DEBUG UNCOMMENT
            rpc_refresh = True # use the calibre RPC to signal a GUI refresh

        if db is None:
            from calibre.library import db # TODO: probably legacy db import, change for new_api
            db = db() # initialize calibre library database

        if book_format == None:
            if not db.has_format(book_id, 'PDF', index_is_id=True):
                raise Exception('Book with id #{} has not a PDF format.'.format(book_id))
            else:
                book_format='pdf'

        if db.has_format(book_id, 'DJVU', index_is_id=True):
            prints("already have 'DJVU' format document for book ID #{}".format(book_id))
            return None # don't auto convert, we already have a DJVU for this document

        path_to_ebook = db.format_abspath(book_id, book_format, index_is_id=True)
        if book_format == 'pdf':
            is_rasterbook_val, pages, images = is_rasterbook(path_to_ebook, basic_return=False)
            if is_rasterbook_val:
                pass # TODO: should add a 'scanned' or 'djvumaker' tag
            else:
            # this is a marked-up/vector-based pdf,
            # no advantages to having another copy in DJVU format
                prints(("{} document from book ID #{} determined to be a markup-based ebook,"
                        " not converting to DJVU").format(book_format, book_id))
                return None #no-error in job panel
            # TODO: test the DPI to determine if a document is from a broad-sheeted book.
            #       if so, queue up k2pdfopt to try and chunk the content appropriately to letter size

            prints(("scheduling new {} document from book ID #{} for post-import DJVU"
                    " conversion: {}").format(book_format, book_id, path_to_ebook))

        if fork_job:
            #useful for not blocking calibre GUI when large PDFs
            # are dropped into the automatic-import-folder
            try:
            # https://github.com/kovidgoyal/calibre/blob/master/src/calibre/utils/ipc/simple_worker.py
            # dispatch API for Worker()
            # src/calibre/utils/ipc/launch.py
            # Worker() uses sbp.Popen to
            # run a second Python to a logfile
            # note that Calibre bungs the python loader to check the plugin directory when
            # modules with calibre_plugin. prefixed are passed
            # https://github.com/kovidgoyal/calibre/blob/master/src/calibre/customize/zipplugin.py#L192
                func_name = self.plugin_prefs['use_backend']
                args = [path_to_ebook, log, abort, notifications, pages, images]
                jobret = worker_fork_job('calibre_plugins.{}'.format(PLUGINNAME), func_name,
                            args= args,
                            kwargs={'preferences' : self.plugin_prefs},
                            env={'PATH': os.environ['PATH'] + ':/usr/local/bin'},
                            # djvu and poppler-utils on osx
                            timeout=600)
                            # TODO: determine a resonable timeout= based on filesize or
                            # make a heartbeat= check
                            # TODO: doesn't work for pdf2djvu, why?

            except WorkerError as e:
                prints('djvudigital background conversion failed: \n{}'.format(force_unicode(e.orig_tb)))
                raise # ConversionError
            except:
                prints(traceback.format_exc())
                raise

        # dump djvudigital output logged in file by the Worker to
        # calibre proc's (gui or console) log/stdout
            with open(jobret['stdout_stderr'], 'rb') as f:
                raw = f.read().strip()
                prints(raw)

            if jobret['result']:
                djvu = jobret['result']
            else:
                WorkerError("djvu conversion error: %s" % jobret['result'])
        # elif hasattr(self, gui): #if we have the calibre gui running,
        # we can give it a threadedjob and not use fork_job
        else: #!fork_job & !gui
            prints("Starts backend")
            djvu = self.run_backend(path_to_ebook, log, abort, notifications, pages,
                                    images)

        if djvu:
            db.new_api.add_format(book_id, 'DJVU', djvu, run_hooks=True)
            prints("added new 'DJVU' document to book ID #{}".format(book_id))
            if sys.__stdin__.isatty():
            # update calibre gui Out-Of-Band. Like if we were run as a command-line scripted import
            # this resets current gui views/selections, no cleaner way to do it :-(
                from calibre.utils.ipc import RC
                t = RC(print_error=False)
                t.start()
                t.join(3)
                if t.done: # GUI is running
                    t.conn.send('refreshdb:')
                    t.conn.close()
                    prints("signalled Calibre GUI refresh")
        else:
            # TODO: normal Exception propagation instead of passing errors as return values
            raise Exception(('ConversionError, djvu: {}. Did you install any backend according to the'
                             ' documentation?').format(djvu))
class BookSettings(object):
    AMAZON_ASIN_PAT = re.compile(r'data\-asin=\"([a-zA-z0-9]+)\"')
    SHELFARI_URL_PAT = re.compile(r'href="(.+/books/.+?)"')
    HEADERS = {"Content-type": "application/x-www-form-urlencoded", "Accept": "text/html", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:46.0) Gecko/20100101 Firefox/46.0"}
    LIBRARY = current_library_path()
    HONORIFICS = 'mr mrs ms esq prof dr fr rev pr atty adv hon pres gov sen ofc pvt cpl sgt maj capt cmdr lt col gen'
    HONORIFICS = HONORIFICS.split()
    HONORIFICS.extend([x + '.' for x in HONORIFICS])
    HONORIFICS += 'miss master sir madam lord dame lady esquire professor doctor father mother brother sister reverend pastor elder rabbi sheikh'.split()
    HONORIFICS += 'attorney advocate honorable president governor senator officer private corporal sargent major captain commander lieutenant colonel general'.split()
    RELIGIOUS_HONORIFICS = 'fr br sr rev pr'
    RELIGIOUS_HONORIFICS = RELIGIOUS_HONORIFICS.split()
    RELIGIOUS_HONORIFICS.extend([x + '.' for x in RELIGIOUS_HONORIFICS])
    RELIGIOUS_HONORIFICS += 'father mother brother sister reverend pastor elder rabbi sheikh'.split()

    def __init__(self, db, book_id, aConnection, sConnection):
        self._db = db
        self._book_id = book_id
        self._aConnection = aConnection
        self._sConnection = sConnection

        book_path = self._db.field_for('path', book_id).replace('/', os.sep)

        self._prefs = JSONConfig(os.path.join(book_path, 'book_settings'), base_path=self.LIBRARY)
        self._prefs.setdefault('asin', '')
        self._prefs.setdefault('shelfari_url', '')
        self._prefs.setdefault('aliases', {})
        self._prefs.commit()

        self._title = self._db.field_for('title', book_id)
        self._author = ' & '.join(self._db.field_for('authors', self._book_id))

        self.asin = self._prefs['asin']
        if self.asin == '':
            identifiers = self._db.field_for('identifiers', self._book_id)
            self.asin = self._db.field_for('identifiers', self._book_id)['mobi-asin'].decode('ascii') if 'mobi-asin' in identifiers.keys() else None
            if not self.asin:
                self.asin = self.get_asin()
            if self.asin:
                self._prefs['asin'] = self.asin

        self.shelfari_url = self._prefs['shelfari_url']
        if self.shelfari_url == '':
            url = ''
            if self._prefs['asin'] != '':
                url = self.search_shelfari(self._prefs['asin'])
            if url != '' and self.title != 'Unknown' and self.author != 'Unknown':
                url = self.search_shelfari(self.title_and_author)

            if url != '':
                self.shelfari_url = url
                self._prefs['shelfari_url'] = self.shelfari_url

        self._aliases = self._prefs['aliases']
        if len(self._aliases.keys()) == 0 and self.shelfari_url != '':
            self.update_aliases()
        self.save()

    @property
    def prefs(self):
        return self._prefs

    @property
    def title(self):
        return self._title
    
    @property
    def author(self):
        return self._author

    @property
    def title_and_author(self):
        return '%s - %s' % (self.title, self.author)

    @property
    def asin(self):
        return self._asin
    
    @asin.setter
    def asin(self, val):
        self._asin = val

    @property
    def shelfari_url(self):
        return self._shelfari_url
    
    @shelfari_url.setter
    def shelfari_url(self, val):
        self._shelfari_url = val

    @property
    def aliases(self):
        return self._aliases

    @aliases.setter
    def aliases(self, val):
        # 'aliases' is a string containing a comma separated list of aliases.  
        #
        # Split it, remove whitespace from each element, drop empty strings (strangely, split only does this if you don't specify a separator)
        #
        # so "" -> []  "foo,bar" and " foo   , bar " -> ["foo", "bar"]
        label, aliases = val
        aliases = [x.strip() for x in aliases.split(",") if x.strip()]
        self._aliases[label] =  aliases

    def save(self):
        self._prefs['asin'] = self.asin
        self._prefs['shelfari_url'] = self.shelfari_url
        self._prefs['aliases'] = self.aliases

    def get_asin(self):
        query = urlencode({'keywords': '%s' % self.title_and_author})
        try:
            self._aConnection.request('GET', '/s/ref=sr_qz_back?sf=qz&rh=i%3Adigital-text%2Cn%3A154606011%2Ck%3A' + query[9:] + '&' + query, headers=self.HEADERS)
            response = self._aConnection.getresponse().read()
        except:
            try:
                self._aConnection.close()
                if self._proxy:
                    self._aConnection = HTTPConnection(self._http_address, self._http_port)
                    self._aConnection.set_tunnel('www.amazon.com', 80)
                else:
                    self._aConnection = HTTPConnection('www.amazon.com')

                self._aConnection.request('GET', '/s/ref=sr_qz_back?sf=qz&rh=i%3Adigital-text%2Cn%3A154606011%2Ck%3A' + query[9:] + '&' + query, headers=self.HEADERS)
                response = self._aConnection.getresponse().read()
            except:
                return None

        # check to make sure there are results
        if 'did not match any products' in response and not 'Did you mean:' in response and not 'so we searched in All Departments' in response:
            return None

        soup = BeautifulSoup(response)
        results = soup.findAll('div', {'id': 'resultsCol'})
       
        if not results or len(results) == 0:
            return None

        for r in results:
            if 'Buy now with 1-Click' in str(r):
                asinSearch = self.AMAZON_ASIN_PAT.search(str(r))
                if asinSearch:
                    asin = asinSearch.group(1)
                    mi = self._db.get_metadata(self._book_id)
                    identifiers = mi.get_identifiers()
                    identifiers['mobi-asin'] = asin
                    mi.set_identifiers(identifiers)
                    self._db.set_metadata(self._book_id, mi)
                    return asin

    def search_shelfari(self, keywords):
        query = urlencode ({'Keywords': keywords})
        try:
            self._sConnection.request('GET', '/search/books?' + query)
            response = self._sConnection.getresponse().read()
        except:
            try:
                self._sConnection.close()
                if self._proxy:
                    self._sConnection = HTTPConnection(self._http_address, self._http_port)
                    self._sConnection.set_tunnel('www.shelfari.com', 80)
                else:
                    self._sConnection = HTTPConnection('www.shelfari.com')

                self._sConnection.request('GET', '/search/books?' + query)
                response = self._sConnection.getresponse().read()
            except:
                return None
        
        # check to make sure there are results
        if 'did not return any results' in response:
            return None

        urlsearch = self.SHELFARI_URL_PAT.search(response)
        if not urlsearch:
            return None

        return urlsearch.group(1)

    def update_aliases(self, overwrite=False):
        shelfari_parser = ShelfariParser(self.shelfari_url)
        shelfari_parser.get_characters()
        shelfari_parser.get_terms()

        if overwrite:
            self._prefs['aliases'] = {}
            self._aliases = {}
        
        characters = [char[1]['label'] for char in shelfari_parser.characters.items()]
        for char in characters:
            if char not in self.aliases.keys():
                self.aliases = (char, '')
        
        terms = [term[1]['label'] for term in shelfari_parser.terms.items()]
        for term in terms:
            if term not in self.aliases.keys():
                self.aliases = (term, '')

        aliases = self.auto_expand_aliases(characters)
        for alias, fullname in aliases.items():
            self.aliases = (fullname, alias + ',' + ','.join(self.aliases[fullname]))

    def auto_expand_aliases(self, characters):
        actual_aliases = {}
        duplicates = [x.lower() for x in characters]
        for fullname in characters:
            aliases = self.fullname_to_possible_aliases(fullname.lower())
            for alias in aliases:
                # if this alias has already been flagged as a duplicate, skip it
                if alias in duplicates:
                    continue
                # check if this alias is a duplicate but isn't in the duplicates list
                if actual_aliases.has_key(alias):
                    duplicates.append(alias)
                    actual_aliases.pop(alias)
                    continue

                # at this point, the alias is new -- add it to the dict with the alias as the key and fullname as the value
                actual_aliases[alias] = fullname

        return actual_aliases

    def fullname_to_possible_aliases(self, fullname):
        """
        Given a full name ("{Title} ChristianName {Middle Names} {Surname}"), return a list of possible aliases
        
        ie. Title Surname, ChristianName Surname, Title ChristianName, {the full name}
        
        The returned aliases are in the order they should match
        """
        aliases = []        
        parts = fullname.split()

        if parts[0].lower() in self.HONORIFICS:
            title = []
            while len(parts) > 0 and parts[0].lower() in self.HONORIFICS:
                title.append(parts.pop(0))
            title = ' '.join(title)
        else:
            title = None
            
        if len(parts) >= 2:
            # Assume: {Title} Firstname {Middlenames} Lastname
            # Already added the full form, also add Title Lastname, and for some Title Firstname
            surname = parts.pop() # This will cover double barrel surnames, we split on whitespace only
            christian_name = parts.pop(0)
            middlenames = parts
            if title:
                if title in self.RELIGIOUS_HONORIFICS:
                    aliases.append("%s %s" % (title, christian_name))
                else:
                    aliases.append("%s %s" % (title, surname))
            aliases.append(christian_name)
            aliases.append(surname)
            aliases.append("%s %s" % (christian_name, surname))

        elif title:
            # Odd, but got Title Name (eg. Lord Buttsworth), so see if we can alias
            if len(parts) > 0:
                aliases.append(parts[0])
        else:
            # We've got no title, so just a single word name.  No alias needed
            pass
        return aliases
Beispiel #6
0
class DJVUmaker(FileTypePlugin,
                InterfaceActionBase):  # multiple inheritance for gui hooks!
    #NODOC
    name = PLUGINNAME  # Name of the plugin
    description = (
        'Convert raster-based document files (Postscript, PDF) to DJVU with GUI'
        ' button and on-import')
    supported_platforms = ['linux', 'osx',
                           'windows']  # Platforms this plugin will run on
    author = 'Joey Korkames'  # The author of this plugin
    version = PLUGINVER  # The version number of this plugin
    # The file types that this plugin will be automatically applied to
    file_types = set(['pdf', 'ps', 'eps'])
    on_postimport = True  # Run this plugin after books are addded to the database
    # needs the new db api w/id() bugfix, and podofo.image_count()
    minimum_calibre_version = (2, 22, 0)
    # InterfaceAction plugin location
    actual_plugin = 'calibre_plugins.djvumaker.gui:ConvertToDJVUAction'
    REGISTERED_BACKENDS = collections.OrderedDict()

    @classmethod
    def register_backend(cls, fun):
        """Register backend for future use."""
        cls.REGISTERED_BACKENDS[fun.__name__] = fun
        return fun

    def __init__(self, *args, **kwargs):
        super(DJVUmaker, self).__init__(*args, **kwargs)
        self.prints = prints  # Easer access because of Calibre load plugins instead of importing
        # Set default preferences for JSONConfig
        DEFAULT_STORE_VALUES = {}
        DEFAULT_STORE_VALUES['plugin_version'] = PLUGINVER
        DEFAULT_STORE_VALUES['postimport'] = False
        for item in self.REGISTERED_BACKENDS:
            DEFAULT_STORE_VALUES[item] = {
                'flags': [],
                'installed': False,
                'version': None
            }
        if 'djvudigital' in self.REGISTERED_BACKENDS:
            DEFAULT_STORE_VALUES['use_backend'] = 'djvudigital'
        else:
            raise Exception('No djvudigital backend.')

        # JSONConfig is a dict-like object,
        # if coresponding .json file has not a specific key, it's got from .defaults
        self.plugin_prefs = JSONConfig(os.path.join('plugins', PLUGINNAME))
        self.plugin_prefs.defaults = DEFAULT_STORE_VALUES

        # make sure to create plugins/djvumaker.json
        # self.plugin_prefs.values() doesn't use self.plugin_prefs.__getitem__()
        # and returns real json, not defaults
        if not self.plugin_prefs.values():
            for key, val in DEFAULT_STORE_VALUES.iteritems():
                self.plugin_prefs[key] = val

    def site_customization_parser(self, use_backend):
        """Parse user input from "Customize plugin" menu. Return backend and cmd flags to use."""
        backend, cmdflags = use_backend, self.plugin_prefs[use_backend][
            'flags']
        if self.site_customization != '':
            site_customization = self.site_customization.split()
            if site_customization[0] in self.REGISTERED_BACKENDS:
                backend = site_customization[0]
                cmdflags = site_customization[1:]
            elif site_customization[0][0] == '-':
                backend = use_backend
                cmdflags = site_customization
                #`--gsarg=-dFirstPage=1,-dLastPage=1` how to limit page range
                #more gsargs: https://leanpub.com/pdfkungfoo
            else:
                # TODO: Custom command implementation
                #   some template engine with %djvu, %src or sth
                raise NotImplementedError(
                    'Custom commands are not implemented')
        return backend, cmdflags

    def run_backend(self, *args, **kwargs):
        """
        Choose proper backend. Check saved settings and overriden from "Customize plugin" menu.

        Possible kwargs:
            cmd_creation_only:bool -- if True, return only command creation function result
        """
        use_backend = self.plugin_prefs['use_backend']
        kwargs['preferences'] = self.plugin_prefs
        try:
            use_backend, kwargs['cmdflags'] = self.site_customization_parser(
                use_backend)
        except NotImplementedError as err:
            prints('Error: ' + str(err))
            prints('Back to not overriden backend settings...')
            kwargs['cmdflags'] = []

        if 'cmd_creation_only' in kwargs and kwargs['cmd_creation_only']:
            kwargs.pop('cmd_creation_only')
            return self.REGISTERED_BACKENDS[use_backend].__wrapped__(
                *args, **kwargs)
            #srcdoc, cmdflags, djvu, preferences
        kwargs.pop('cmd_creation_only', None)
        return self.REGISTERED_BACKENDS[use_backend](*args, **kwargs)

    def customization_help(self, gui=True):
        """Method required by calibre. Shows user info in "Customize plugin" menu."""
        # TODO: add info about current JSON settings
        # TODO: proper english
        current_backend = self.plugin_prefs['use_backend']
        flags = ''.join(self.plugin_prefs[current_backend]['flags'])
        command = current_backend + ' ' + flags

        try:
            overr_backend, overr_flags = self.site_customization_parser(
                current_backend)
        except NotImplementedError as err:
            overriden_info = 'Overriding command is not recognized. {}<br><br>'.format(
                err.message)
        else:
            overr_flags = ''.join(overr_flags)
            overr_command = overr_backend + ' ' + overr_flags
            if overr_backend != current_backend or overr_flags != flags:
                overriden_info = (
                    'This command is overriden by this plugin customization command:'
                    ' <b>{}</b><br><br>').format(overr_command)
            else:
                overriden_info = '<br><br>'

        help_command = 'calibre-debug -r djvumaker -- --help'
        info = (
            '<p>You can enter overwritting command and flags to create djvu files.'
            'eg: `pdf2djvu -v`. You have to restart calibre before changes can take effect.<br>'
            'Currently set command is: <b>{}</b><br>'
            '{}'
            'You can read more about plugin customization running "{}" from command line.</p>'
        ).format(command, overriden_info, help_command)
        return info

        # return 'Enter additional `djvudigital --help` command-flags here:'

        # os.system('MANPAGER=cat djvudigital --help')
        # TODO: make custom config widget so we can have attrs for each of the wrappers:
        #       djvudigital minidjvu, c44, etc.
        # TODO: `man2html djvumaker` and gui=True for comprehensive help?

    def cli_main(self, args):
        """Handles plugin CLI interface"""
        args = args[1:]  # args[0] = PLUGINNAME
        printsd('cli_main enter: args: ', args)  # DEBUG
        parser = create_cli_parser(self, PLUGINNAME, PLUGINVER_DOT,
                                   self.REGISTERED_BACKENDS.keys())
        if len(args) == 0:
            parser.print_help()
            return sys.exit()
        options = parser.parse_args(args)
        options.func(options)

    def cli_test(self, args):
        """Debug method."""
        prints(subprocess.check_output(['pwd']))

    def cli_backend(self, args):
        #NODOC
        printsd('cli_backend enter: plugin_prefs:', self.plugin_prefs)
        if args.command == 'install':
            self.cli_install_backend(args)
        elif args.command == 'set':
            self.cli_set_backend(args)
        else:
            raise Exception('Command not recognized.')

    def cli_install_backend(self, args):
        #NODOC
        printsd('cli_install_backend enter: args.backend:', args.backend)
        if not args.backend:  # Report currently installed backends if without args
            installed_backend = [
                k for k, v in {
                    item: self.plugin_prefs[item]['installed']
                    for item in self.REGISTERED_BACKENDS
                }.iteritems() if v
            ]
            prints('Currently installed backends: {}'.format(
                ', '.join(installed_backend) if installed_backend else 'None'))
            sys.exit()

        if args.backend == 'djvudigital':
            if isosx:
                if os.system("which brew >/dev/null") == 0:
                    os.system("brew install --with-djvu ghostscript")
                else:
                    raise Exception(
                        "Homebrew required."
                        "Please visit http://github.com/Homebrew/homebrew")
                if raw_input("Install DjView.app? (y/n): ").lower() == 'y':
                    os.system("brew install caskroom/cask/brew-cask;"
                              " brew cask install djview")
                else:
                    sys.exit()
            # need a cask for the caminova finder/safari plugin too
            # TODO: make more install scripts
            elif islinux:
                raise Exception('Only macOS supported')
            elif iswindows:
                raise Exception('Only macOS supported')
            elif isbsd:
                raise Exception('Only macOS supported')
            else:
                raise Exception('Only macOS supported')
            self.plugin_prefs['djvudigital']['installed'] = True
            self.plugin_prefs.commit()  # always use commit if uses nested dict
            # TODO: inherit from JSONConfig and make better implementation for defaults
        elif args.backend == 'pdf2djvu':
            # TODO: neat "Not supported" messages for every backend from function
            err_info = 'Only Windows supported. Try manual installation and add pdf2djvu to PATH env'
            if iswindows:
                success, version = install_pdf2djvu(PLUGINNAME,
                                                    self.plugin_prefs,
                                                    log=prints)
            elif isosx:
                raise Exception(err_info)
            elif islinux:
                raise Exception(err_info)
            elif isbsd:
                raise Exception(err_info)
            else:
                raise Exception(err_info)
            # TODO: very easy: add support for macOS and linux, just add `make` after download source

            # path?
            # TODO: give flag where to installed_backend
            # TODO: ask if add to path?
            # TODO: should use github api v3
            if success:
                self.plugin_prefs['pdf2djvu']['installed'] = True
                self.plugin_prefs['pdf2djvu']['version'] = version
                self.plugin_prefs.commit(
                )  # always use commit if uses nested dict
                prints(
                    'Installation of pdf2djvu was succesfull or unrequired.')
            else:
                prints('Installation of pdf2djvu was not succesfull.')
        else:
            raise Exception('Backend not recognized.')

    def cli_set_backend(self, args):
        #NODOC
        if not args.backend:
            prints('Currently set backend: {}'.format(
                self.plugin_prefs['use_backend']))
            return None
            # sys.exit()

        if args.backend in self.REGISTERED_BACKENDS:
            self.plugin_prefs['use_backend'] = args.backend
            prints('{} successfully set as current backend.'.format(
                args.backend))
        else:
            raise Exception('Backend not recognized.')
        return None

    def cli_set_postimport(self, args):
        #NODOC
        if args.yes:
            prints('Will try to convert files after import')
            self.plugin_prefs['postimport'] = True
        elif args.no:
            prints('Will not try to convert files after import')
            self.plugin_prefs['postimport'] = False
        else:
            if self.plugin_prefs['postimport']:
                prints('Currently {} tries to convert PDF files after import'.
                       format(PLUGINNAME))
            else:
                prints(
                    "Currently {} doesn't do convertion of PDF's after import".
                    format(PLUGINNAME))

    def cli_convert(self, args):
        #NODOC
        printsd(args)
        if args.all:
            # `calibre-debug -r djvumaker -- convert --all`
            printsd('in cli convert_all')
            # TODO: make work `djvumaker -- convert --all`
            # raise NotImplementedError('Convert all is not implemented.')

            user_input = ask_yesno_input(
                'Do you wany to copy-convert all PDFs to DJVU?')
            if not user_input:
                return None

            from calibre.library import db
            from calibre.customize.ui import run_plugins_on_postimport
            db = db()  # initialize calibre library database
            for book_id in list(db.all_ids()):
                if db.has_format(book_id, 'DJVU', index_is_id=True):
                    continue
                # TODO: shouldn't work with this code, db has not atributte run_plugins_on_postimport
                #       https://github.com/kovidgoyal/calibre/blob/master/src/calibre/customize/ui.py
                if db.has_format(book_id, 'PDF', index_is_id=True):
                    run_plugins_on_postimport(db, book_id, 'pdf')
                    continue
        elif args.path is not None:
            # `calibre-debug -r djvumaker -- convert -p test.pdf` -> tempfile(test.djvu)
            printsd('in path')
            if is_rasterbook(args.path):
                djvu = self.run_backend(args.path, log=self.prints.func)
                if djvu:
                    input_filename, _ = os.path.splitext(args.path)
                    shutil.copy2(djvu, input_filename + '.djvu')
                    prints("Finished DJVU outputed to: {}.".format(
                        input_filename + '.djvu'))

                    user_input = ask_yesno_input(
                        'Do you want to open djvused in subshell?'
                        ' (may not work on not macOS)')
                    if not user_input:
                        return None
                    # de-munge the tty
                    sys.stdin = sys.__stdin__
                    sys.stdout = sys.__stdout__
                    sys.stderr = sys.__stderr__
                    os.system("stat '%s'" % djvu)
                    # TODO: doesn't work on Windows, why is it here?
                    os.system("djvused -e dump '%s'" % djvu)
                    os.system("djvused -v '%s'" % djvu)

        elif args.id is not None:
            # `calibre-debug -r djvumaker -- convert -i 123 #id(123).pdf` -> tempfile(id(123).djvu)
            printsd('in convert by id')
            self._postimport(args.id)

    # -- calibre filetype plugin mandatory methods --
    def run(self, path_to_ebook):
        #NODOC
        return path_to_ebook  # noop

    def postimport(self, book_id, book_format, db):
        """Run postimport conversion if it's turned on"""
        if self.plugin_prefs['postimport']:
            return self._postimport(book_id, book_format, db)
        else:
            return None

    def _postimport(self,
                    book_id,
                    book_format=None,
                    db=None,
                    log=None,
                    fork_job=True,
                    abort=None,
                    notifications=None):
        #NODOC IMPORTANT
        # TODO: make general overhaul of starting conversion logic
        if log:  # divert our printing to the caller's logger
            prints = log  # Log object has __call__ dunder method with INFO level
            prints = partial(prints, '{}:'.format(PLUGINNAME))
        else:
            log = self.prints.func
        try:
            prints
        except NameError:
            prints = self.prints

        if sys.__stdin__.isatty():
            # if run by cli, i.e.:
            #    calibredb add
            #    calibredebug -r djvumaker -- convert -i #id
            # runs also for GUI if run trough `calibredebug -g`
            fork_job = False  # DEBUG UNCOMMENT
            rpc_refresh = True  # use the calibre RPC to signal a GUI refresh

        if db is None:
            from calibre.library import db  # TODO: probably legacy db import, change for new_api
            db = db()  # initialize calibre library database

        if book_format == None:
            if not db.has_format(book_id, 'PDF', index_is_id=True):
                raise Exception(
                    'Book with id #{} has not a PDF format.'.format(book_id))
            else:
                book_format = 'pdf'

        if db.has_format(book_id, 'DJVU', index_is_id=True):
            prints(
                "already have 'DJVU' format document for book ID #{}".format(
                    book_id))
            return None  # don't auto convert, we already have a DJVU for this document

        path_to_ebook = db.format_abspath(book_id,
                                          book_format,
                                          index_is_id=True)
        if book_format == 'pdf':
            is_rasterbook_val, pages, images = is_rasterbook(
                path_to_ebook, basic_return=False)
            if is_rasterbook_val:
                pass  # TODO: should add a 'scanned' or 'djvumaker' tag
            else:
                # this is a marked-up/vector-based pdf,
                # no advantages to having another copy in DJVU format
                prints((
                    "{} document from book ID #{} determined to be a markup-based ebook,"
                    " not converting to DJVU").format(book_format, book_id))
                return None  #no-error in job panel
            # TODO: test the DPI to determine if a document is from a broad-sheeted book.
            #       if so, queue up k2pdfopt to try and chunk the content appropriately to letter size

            prints((
                "scheduling new {} document from book ID #{} for post-import DJVU"
                " conversion: {}").format(book_format, book_id, path_to_ebook))

        if fork_job:
            #useful for not blocking calibre GUI when large PDFs
            # are dropped into the automatic-import-folder
            try:
                # https://github.com/kovidgoyal/calibre/blob/master/src/calibre/utils/ipc/simple_worker.py
                # dispatch API for Worker()
                # src/calibre/utils/ipc/launch.py
                # Worker() uses sbp.Popen to
                # run a second Python to a logfile
                # note that Calibre bungs the python loader to check the plugin directory when
                # modules with calibre_plugin. prefixed are passed
                # https://github.com/kovidgoyal/calibre/blob/master/src/calibre/customize/zipplugin.py#L192
                func_name = self.plugin_prefs['use_backend']
                args = [
                    path_to_ebook, log, abort, notifications, pages, images
                ]
                jobret = worker_fork_job(
                    'calibre_plugins.{}'.format(PLUGINNAME),
                    func_name,
                    args=args,
                    kwargs={'preferences': self.plugin_prefs},
                    env={'PATH': os.environ['PATH'] + ':/usr/local/bin'},
                    # djvu and poppler-utils on osx
                    timeout=600)
                # TODO: determine a resonable timeout= based on filesize or
                # make a heartbeat= check
                # TODO: doesn't work for pdf2djvu, why?

            except WorkerError as e:
                prints('djvudigital background conversion failed: \n{}'.format(
                    force_unicode(e.orig_tb)))
                raise  # ConversionError
            except:
                prints(traceback.format_exc())
                raise

        # dump djvudigital output logged in file by the Worker to
        # calibre proc's (gui or console) log/stdout
            with open(jobret['stdout_stderr'], 'rb') as f:
                raw = f.read().strip()
                prints(raw)

            if jobret['result']:
                djvu = jobret['result']
            else:
                WorkerError("djvu conversion error: %s" % jobret['result'])
        # elif hasattr(self, gui): #if we have the calibre gui running,
        # we can give it a threadedjob and not use fork_job
        else:  #!fork_job & !gui
            prints("Starts backend")
            djvu = self.run_backend(path_to_ebook, log, abort, notifications,
                                    pages, images)

        if djvu:
            db.new_api.add_format(book_id, 'DJVU', djvu, run_hooks=True)
            prints("added new 'DJVU' document to book ID #{}".format(book_id))
            if sys.__stdin__.isatty():
                # update calibre gui Out-Of-Band. Like if we were run as a command-line scripted import
                # this resets current gui views/selections, no cleaner way to do it :-(
                from calibre.utils.ipc import RC
                t = RC(print_error=False)
                t.start()
                t.join(3)
                if t.done:  # GUI is running
                    t.conn.send('refreshdb:')
                    t.conn.close()
                    prints("signalled Calibre GUI refresh")
        else:
            # TODO: normal Exception propagation instead of passing errors as return values
            raise Exception('ConversionError, djvu: {}'.format(djvu))