%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/dialogs/ |
Current File : //lib/calibre/calibre/gui2/dialogs/custom_recipes.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' import os, re, textwrap, time from qt.core import ( QVBoxLayout, QStackedWidget, QSize, QPushButton, QIcon, QWidget, QListView, QItemSelectionModel, QHBoxLayout, QAbstractListModel, Qt, QLabel, QSizePolicy, pyqtSignal, QSortFilterProxyModel, QFormLayout, QSpinBox, QLineEdit, QGroupBox, QListWidget, QListWidgetItem, QToolButton, QTreeView, QDialog, QDialogButtonBox) from calibre.gui2 import error_dialog, open_local_file, choose_files, choose_save_file from calibre.gui2.dialogs.confirm_delete import confirm as confirm_delete from calibre.gui2.widgets2 import Dialog from calibre.web.feeds.recipes import custom_recipes, compile_recipe from calibre.gui2.tweak_book.editor.text import TextEdit from calibre.web.feeds.recipes.collection import get_builtin_recipe_by_id from calibre.utils.localization import localize_user_manual_link from polyglot.builtins import iteritems, as_unicode from calibre.gui2.search_box import SearchBox2 from polyglot.builtins import as_bytes def is_basic_recipe(src): return re.search(r'^class BasicUserRecipe', src, flags=re.MULTILINE) is not None class CustomRecipeModel(QAbstractListModel): # {{{ def __init__(self, recipe_model): QAbstractListModel.__init__(self) self.recipe_model = recipe_model def title(self, index): row = index.row() if row > -1 and row < self.rowCount(): return self.recipe_model.custom_recipe_collection[row].get('title', '') def urn(self, index): row = index.row() if row > -1 and row < self.rowCount(): return self.recipe_model.custom_recipe_collection[row].get('id') def has_title(self, title): for x in self.recipe_model.custom_recipe_collection: if x.get('title', False) == title: return True return False def script(self, index): row = index.row() if row > -1 and row < self.rowCount(): urn = self.recipe_model.custom_recipe_collection[row].get('id') return self.recipe_model.get_recipe(urn) def rowCount(self, *args): try: return len(self.recipe_model.custom_recipe_collection) except Exception: return 0 def data(self, index, role): if role == Qt.ItemDataRole.DisplayRole: return self.title(index) def update(self, row, title, script): if row > -1 and row < self.rowCount(): urn = self.recipe_model.custom_recipe_collection[row].get('id') self.beginResetModel() self.recipe_model.update_custom_recipe(urn, title, script) self.endResetModel() def replace_many_by_title(self, scriptmap): script_urn_map = {} for title, script in iteritems(scriptmap): urn = None for x in self.recipe_model.custom_recipe_collection: if x.get('title', False) == title: urn = x.get('id') if urn is not None: script_urn_map.update({urn: (title, script)}) if script_urn_map: self.beginResetModel() self.recipe_model.update_custom_recipes(script_urn_map) self.endResetModel() def add(self, title, script): all_urns = {x.get('id') for x in self.recipe_model.custom_recipe_collection} self.beginResetModel() self.recipe_model.add_custom_recipe(title, script) self.endResetModel() new_urns = {x.get('id') for x in self.recipe_model.custom_recipe_collection} - all_urns if new_urns: urn = tuple(new_urns)[0] for row, item in enumerate(self.recipe_model.custom_recipe_collection): if item.get('id') == urn: return row return 0 def add_many(self, scriptmap): self.beginResetModel() self.recipe_model.add_custom_recipes(scriptmap) self.endResetModel() def remove(self, rows): urns = [] for r in rows: try: urn = self.recipe_model.custom_recipe_collection[r].get('id') urns.append(urn) except: pass self.beginResetModel() self.recipe_model.remove_custom_recipes(urns) self.endResetModel() # }}} def py3_repr(x): ans = repr(x) if isinstance(x, bytes) and not ans.startswith('b'): ans = 'b' + ans if isinstance(x, str) and ans.startswith('u'): ans = ans[1:] return ans def options_to_recipe_source(title, oldest_article, max_articles_per_feed, feeds): classname = 'BasicUserRecipe%d' % int(time.time()) title = str(title).strip() or classname indent = ' ' * 8 if feeds: if len(feeds[0]) == 1: feeds = '\n'.join(f'{indent}{py3_repr(url)},' for url in feeds) else: feeds = '\n'.join(f'{indent}({py3_repr(title)}, {py3_repr(url)}),' for title, url in feeds) else: feeds = '' if feeds: feeds = 'feeds = [\n%s\n ]' % feeds src = textwrap.dedent('''\ #!/usr/bin/env python # vim:fileencoding=utf-8 from calibre.web.feeds.news import {base} class {classname}({base}): title = {title} oldest_article = {oldest_article} max_articles_per_feed = {max_articles_per_feed} auto_cleanup = True {feeds}''').format( classname=classname, title=py3_repr(title), oldest_article=oldest_article, feeds=feeds, max_articles_per_feed=max_articles_per_feed, base='AutomaticNewsRecipe') return src class RecipeList(QWidget): # {{{ edit_recipe = pyqtSignal(object, object) def __init__(self, parent, model): QWidget.__init__(self, parent) self.l = l = QHBoxLayout(self) self.view = v = QListView(self) v.doubleClicked.connect(self.item_activated) v.setModel(CustomRecipeModel(model)) l.addWidget(v) self.stacks = s = QStackedWidget(self) l.addWidget(s, stretch=10, alignment=Qt.AlignmentFlag.AlignTop) self.first_msg = la = QLabel(_( 'Create a new news source by clicking one of the buttons below')) la.setWordWrap(True) s.addWidget(la) self.w = w = QWidget(self) w.l = l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) s.addWidget(w) self.title = la = QLabel(w) la.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) l.addWidget(la) l.setSpacing(20) self.edit_button = b = QPushButton(QIcon(I('modified.png')), _('&Edit this recipe'), w) b.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) b.clicked.connect(self.edit_requested) l.addWidget(b) self.remove_button = b = QPushButton(QIcon(I('list_remove.png')), _('&Remove this recipe'), w) b.clicked.connect(self.remove) b.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) l.addWidget(b) self.export_button = b = QPushButton(QIcon(I('save.png')), _('S&ave recipe as file'), w) b.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) b.clicked.connect(self.save_recipe) l.addWidget(b) self.download_button = b = QPushButton(QIcon(I('download-metadata.png')), _('&Download this recipe'), w) b.clicked.connect(self.download) b.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) l.addWidget(b) self.select_row() v.selectionModel().currentRowChanged.connect(self.recipe_selected) def select_row(self, row=0): v = self.view if v.model().rowCount() > 0: idx = v.model().index(row) if idx.isValid(): v.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect) v.setCurrentIndex(idx) self.recipe_selected(idx) def add(self, title, src): row = self.model.add(title, src) self.select_row(row) def update(self, row, title, src): self.model.update(row, title, src) self.select_row(row) @property def model(self): return self.view.model() def recipe_selected(self, cur, prev=None): if cur.isValid(): self.stacks.setCurrentIndex(1) self.title.setText('<h2 style="text-align:center">%s</h2>' % self.model.title(cur)) else: self.stacks.setCurrentIndex(0) def edit_requested(self): idx = self.view.currentIndex() if idx.isValid(): src = self.model.script(idx) if src is not None: self.edit_recipe.emit(idx.row(), src) def save_recipe(self): idx = self.view.currentIndex() if idx.isValid(): src = self.model.script(idx) if src is not None: path = choose_save_file( self, 'save-custom-recipe', _('Save recipe'), filters=[(_('Recipes'), ['recipe'])], all_files=False, initial_filename=f'{self.model.title(idx)}.recipe' ) if path: with open(path, 'wb') as f: f.write(as_bytes(src)) def item_activated(self, idx): if idx.isValid(): src = self.model.script(idx) if src is not None: self.edit_recipe.emit(idx.row(), src) def remove(self): idx = self.view.currentIndex() if idx.isValid(): if confirm_delete(_('Are you sure you want to permanently remove this recipe?'), 'remove-custom-recipe', parent=self): self.model.remove((idx.row(),)) self.select_row() if self.model.rowCount() == 0: self.stacks.setCurrentIndex(0) def download(self): idx = self.view.currentIndex() if idx.isValid(): urn = self.model.urn(idx) title = self.model.title(idx) from calibre.gui2.ui import get_gui gui = get_gui() gui.iactions['Fetch News'].download_custom_recipe(title, urn) def has_title(self, title): return self.model.has_title(title) def add_many(self, script_map): self.model.add_many(script_map) self.select_row() def replace_many_by_title(self, script_map): self.model.replace_many_by_title(script_map) self.select_row() # }}} class BasicRecipe(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self.hm = hm = QLabel(_( 'Create a basic news recipe, by adding RSS feeds to it.\n' 'For some news sources, you will have to use the "Switch to advanced mode" ' 'button below to further customize the fetch process.')) hm.setWordWrap(True) l.addRow(hm) self.title = t = QLineEdit(self) l.addRow(_('Recipe &title:'), t) t.setStyleSheet('QLineEdit { font-weight: bold }') self.oldest_article = o = QSpinBox(self) o.setSuffix(' ' + _('day(s)')) o.setToolTip(_("The oldest article to download")) o.setMinimum(1), o.setMaximum(36500) l.addRow(_('&Oldest article:'), o) self.max_articles = m = QSpinBox(self) m.setMinimum(5), m.setMaximum(100) m.setToolTip(_("Maximum number of articles to download per feed.")) l.addRow(_("&Max. number of articles per feed:"), m) self.fg = fg = QGroupBox(self) fg.setTitle(_("Feeds in recipe")) self.feeds = f = QListWidget(self) fg.h = QHBoxLayout(fg) fg.h.addWidget(f) fg.l = QVBoxLayout() self.up_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))) b.setToolTip(_('Move selected feed up')) fg.l.addWidget(b) b.clicked.connect(self.move_up) self.remove_button = b = QToolButton(self) b.setIcon(QIcon(I('list_remove.png'))) b.setToolTip(_('Remove selected feed')) fg.l.addWidget(b) b.clicked.connect(self.remove_feed) self.down_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))) b.setToolTip(_('Move selected feed down')) fg.l.addWidget(b) b.clicked.connect(self.move_down) fg.h.addLayout(fg.l) l.addRow(fg) self.afg = afg = QGroupBox(self) afg.setTitle(_('Add feed to recipe')) afg.l = QFormLayout(afg) afg.l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self.feed_title = ft = QLineEdit(self) afg.l.addRow(_('&Feed title:'), ft) self.feed_url = fu = QLineEdit(self) afg.l.addRow(_('Feed &URL:'), fu) self.afb = b = QPushButton(QIcon(I('plus.png')), _('&Add feed'), self) b.setToolTip(_('Add this feed to the recipe')) b.clicked.connect(self.add_feed) afg.l.addRow(b) l.addRow(afg) def move_up(self): items = self.feeds.selectedItems() if items: row = self.feeds.row(items[0]) if row > 0: self.feeds.insertItem(row - 1, self.feeds.takeItem(row)) self.feeds.setCurrentItem(items[0]) def move_down(self): items = self.feeds.selectedItems() if items: row = self.feeds.row(items[0]) if row < self.feeds.count() - 1: self.feeds.insertItem(row + 1, self.feeds.takeItem(row)) self.feeds.setCurrentItem(items[0]) def remove_feed(self): for item in self.feeds.selectedItems(): self.feeds.takeItem(self.feeds.row(item)) def add_feed(self): title = self.feed_title.text().strip() if not title: return error_dialog(self, _('No feed title'), _( 'You must specify a title for the feed'), show=True) url = self.feed_url.text().strip() if not title: return error_dialog(self, _('No feed URL'), _( 'You must specify a URL for the feed'), show=True) QListWidgetItem(f'{title} - {url}', self.feeds).setData(Qt.ItemDataRole.UserRole, (title, url)) self.feed_title.clear(), self.feed_url.clear() def validate(self): title = self.title.text().strip() if not title: error_dialog(self, _('Title required'), _( 'You must give your news source a title'), show=True) return False if self.feeds.count() < 1: error_dialog(self, _('Feed required'), _( 'You must add at least one feed to your news source'), show=True) return False try: compile_recipe(self.recipe_source) except Exception as err: error_dialog(self, _('Invalid recipe'), _( 'Failed to compile the recipe, with syntax error: %s' % err), show=True) return False return True @property def recipe_source(self): title = self.title.text().strip() feeds = [self.feeds.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.feeds.count())] return options_to_recipe_source(title, self.oldest_article.value(), self.max_articles.value(), feeds) @recipe_source.setter def recipe_source(self, src): self.feeds.clear() self.feed_title.clear() self.feed_url.clear() if src is None: self.title.setText(_('My news source')) self.oldest_article.setValue(7) self.max_articles.setValue(100) else: recipe = compile_recipe(src) self.title.setText(recipe.title) self.oldest_article.setValue(recipe.oldest_article) self.max_articles.setValue(recipe.max_articles_per_feed) for x in (recipe.feeds or ()): title, url = ('', x) if len(x) == 1 else x QListWidgetItem(f'{title} - {url}', self.feeds).setData(Qt.ItemDataRole.UserRole, (title, url)) # }}} class AdvancedRecipe(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) self.l = l = QVBoxLayout(self) self.la = la = QLabel(_( 'For help with writing advanced news recipes, see the <a href="%s">User Manual</a>' ) % localize_user_manual_link('https://manual.calibre-ebook.com/news.html')) la.setOpenExternalLinks(True) l.addWidget(la) self.editor = TextEdit(self) l.addWidget(self.editor) def validate(self): src = self.recipe_source try: compile_recipe(src) except Exception as err: error_dialog(self, _('Invalid recipe'), _( 'Failed to compile the recipe, with syntax error: %s' % err), show=True) return False return True @property def recipe_source(self): return self.editor.toPlainText() @recipe_source.setter def recipe_source(self, src): self.editor.load_text(src, syntax='python', doc_name='<recipe>') def sizeHint(self): return QSize(800, 500) # }}} class ChooseBuiltinRecipeModel(QSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): idx = self.sourceModel().index(source_row, 0, source_parent) urn = idx.data(Qt.ItemDataRole.UserRole) if not urn or urn in ('::category::0', '::category::1'): return False return True class ChooseBuiltinRecipe(Dialog): # {{{ def __init__(self, recipe_model, parent=None): self.recipe_model = recipe_model Dialog.__init__(self, _("Choose builtin recipe"), 'choose-builtin-recipe', parent=parent) def setup_ui(self): self.l = l = QVBoxLayout(self) self.recipes = r = QTreeView(self) r.setAnimated(True) r.setHeaderHidden(True) self.model = ChooseBuiltinRecipeModel(self) self.model.setSourceModel(self.recipe_model) r.setModel(self.model) r.doubleClicked.connect(self.accept) self.search = s = SearchBox2(self) self.search.initialize('scheduler_search_history') self.search.setMinimumContentsLength(15) self.search.search.connect(self.recipe_model.search) self.recipe_model.searched.connect(self.search.search_done, type=Qt.ConnectionType.QueuedConnection) self.recipe_model.searched.connect(self.search_done) self.go_button = b = QToolButton(self) b.setText(_("Go")) b.clicked.connect(self.search.do_search) h = QHBoxLayout() h.addWidget(s), h.addWidget(b) l.addLayout(h) l.addWidget(self.recipes) l.addWidget(self.bb) self.search.setFocus(Qt.FocusReason.OtherFocusReason) def search_done(self, *args): if self.recipe_model.showing_count < 10: self.recipes.expandAll() def sizeHint(self): return QSize(600, 450) @property def selected_recipe(self): for idx in self.recipes.selectedIndexes(): urn = idx.data(Qt.ItemDataRole.UserRole) if urn and not urn.startswith('::category::'): return urn def accept(self): if not self.selected_recipe: return error_dialog(self, _('Choose recipe'), _( 'You must choose a recipe to customize first'), show=True) return Dialog.accept(self) # }}} class CustomRecipes(Dialog): def __init__(self, recipe_model, parent=None): self.recipe_model = recipe_model Dialog.__init__(self, _("Add custom news source"), 'add-custom-news-source', parent=parent) def setup_ui(self): self.l = l = QVBoxLayout(self) self.stack = s = QStackedWidget(self) l.addWidget(s) self.recipe_list = rl = RecipeList(self, self.recipe_model) rl.edit_recipe.connect(self.edit_recipe) s.addWidget(rl) self.basic_recipe = br = BasicRecipe(self) s.addWidget(br) self.advanced_recipe = ar = AdvancedRecipe(self) s.addWidget(ar) l.addWidget(self.bb) self.list_actions = [] la = lambda *args:self.list_actions.append(args) la('plus.png', _('&New recipe'), _('Create a new recipe from scratch'), self.add_recipe) la('news.png', _('Customize &builtin recipe'), _('Customize a builtin news download source'), self.customize_recipe) la('document_open.png', _('Load recipe from &file'), _('Load a recipe from a file'), self.load_recipe) la('mimetypes/dir.png', _('&Show recipe files'), _('Show the folder containing all recipe files'), self.show_recipe_files) la('mimetypes/opml.png', _('Import &OPML'), _( "Import a collection of RSS feeds in OPML format\n" "Many RSS readers can export their subscribed RSS feeds\n" "in OPML format"), self.import_opml) s.currentChanged.connect(self.update_button_box) self.update_button_box() def update_button_box(self, index=0): bb = self.bb bb.clear() if index == 0: bb.setStandardButtons(QDialogButtonBox.StandardButton.Close) for icon, text, tooltip, receiver in self.list_actions: b = bb.addButton(text, QDialogButtonBox.ButtonRole.ActionRole) b.setIcon(QIcon(I(icon))), b.setToolTip(tooltip) b.clicked.connect(receiver) else: bb.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Save) if self.stack.currentIndex() == 1: text = _('S&witch to advanced mode') tooltip = _('Edit this recipe in advanced mode') receiver = self.switch_to_advanced b = bb.addButton(text, QDialogButtonBox.ButtonRole.ActionRole) b.setToolTip(tooltip) b.clicked.connect(receiver) def accept(self): idx = self.stack.currentIndex() if idx > 0: self.editing_finished() return Dialog.accept(self) def reject(self): idx = self.stack.currentIndex() if idx > 0: if confirm_delete(_('Are you sure? Any unsaved changes will be lost.'), 'confirm-cancel-edit-custom-recipe'): self.stack.setCurrentIndex(0) return Dialog.reject(self) def sizeHint(self): sh = Dialog.sizeHint(self) return QSize(max(sh.width(), 900), 600) def show_recipe_files(self): bdir = os.path.dirname(custom_recipes.file_path) if not os.path.exists(bdir): return error_dialog(self, _('No recipes'), _('No custom recipes created.'), show=True) open_local_file(bdir) def add_recipe(self): self.editing_row = None self.basic_recipe.recipe_source = None self.stack.setCurrentIndex(1) def edit_recipe(self, row, src): self.editing_row = row if is_basic_recipe(src): self.basic_recipe.recipe_source = src self.stack.setCurrentIndex(1) else: self.advanced_recipe.recipe_source = src self.stack.setCurrentIndex(2) def editing_finished(self): w = self.stack.currentWidget() if not w.validate(): return src = w.recipe_source if not isinstance(src, bytes): src = src.encode('utf-8') recipe = compile_recipe(src) row = self.editing_row if row is None: # Adding a new recipe self.recipe_list.add(recipe.title, src) else: self.recipe_list.update(row, recipe.title, src) self.stack.setCurrentIndex(0) def customize_recipe(self): d = ChooseBuiltinRecipe(self.recipe_model, self) if d.exec() != QDialog.DialogCode.Accepted: return id_ = d.selected_recipe if not id_: return src = get_builtin_recipe_by_id(id_, download_recipe=True) if src is None: raise Exception('Something weird happened') src = as_unicode(src) self.edit_recipe(None, src) def load_recipe(self): files = choose_files(self, 'recipe loader dialog', _('Choose a recipe file'), filters=[(_('Recipes'), ['py', 'recipe'])], all_files=False, select_only_single_file=True) if files: path = files[0] try: with open(path, 'rb') as f: src = f.read().decode('utf-8') except Exception as err: error_dialog(self, _('Invalid input'), _('<p>Could not create recipe. Error:<br>%s')%err, show=True) return self.edit_recipe(None, src) def import_opml(self): from calibre.gui2.dialogs.opml import ImportOPML d = ImportOPML(parent=self) if d.exec() != QDialog.DialogCode.Accepted: return oldest_article, max_articles_per_feed, replace_existing = d.oldest_article, d.articles_per_feed, d.replace_existing failed_recipes, replace_recipes, add_recipes = {}, {}, {} for group in d.recipes: title = base_title = group.title or _('Unknown') if not replace_existing: c = 0 while self.recipe_list.has_title(title): c += 1 title = '%s %d' % (base_title, c) try: src = options_to_recipe_source(title, oldest_article, max_articles_per_feed, group.feeds) compile_recipe(src) except Exception: import traceback failed_recipes[title] = traceback.format_exc() continue if replace_existing and self.recipe_list.has_title(title): replace_recipes[title] = src else: add_recipes[title] = src if add_recipes: self.recipe_list.add_many(add_recipes) if replace_recipes: self.recipe_list.replace_many_by_title(replace_recipes) if failed_recipes: det_msg = '\n'.join(f'{title}\n{tb}\n' for title, tb in iteritems(failed_recipes)) error_dialog(self, _('Failed to create recipes'), _( 'Failed to create some recipes, click "Show details" for details'), show=True, det_msg=det_msg) def switch_to_advanced(self): src = self.basic_recipe.recipe_source src = src.replace('AutomaticNewsRecipe', 'BasicNewsRecipe') src = src.replace('BasicUserRecipe', 'AdvancedUserRecipe') self.advanced_recipe.recipe_source = src self.stack.setCurrentIndex(2) if __name__ == '__main__': from calibre.gui2 import Application from calibre.web.feeds.recipes.model import RecipeModel app = Application([]) CustomRecipes(RecipeModel()).exec() del app