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']
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
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))