diff --git a/manual/images/custom_news.png b/manual/images/custom_news.png index 2f2c6e47be..2ad8adc032 100644 Binary files a/manual/images/custom_news.png and b/manual/images/custom_news.png differ diff --git a/manual/news.rst b/manual/news.rst index 4767f89aed..f5d7e64e32 100644 --- a/manual/news.rst +++ b/manual/news.rst @@ -32,10 +32,10 @@ blog into an ebook, we rely on the :term:`RSS` feed of the blog:: http://blog.calibre-ebook.com/feeds/posts/default I got the RSS URL by looking under "Subscribe to" at the bottom of the blog -page and choosing Posts->Atom. To make calibre download the feeds and convert +page and choosing :guilabel:`Posts->Atom`. To make calibre download the feeds and convert them into an ebook, you should right click the :guilabel:`Fetch news` button -and then the :guilabel:`Add a custom news source` menu item. A dialog similar -to that shown below should open up. +and then the :guilabel:`Add a custom news source` menu item and then the +:guilabel:`New Recipe` button. A dialog similar to that shown below should open up. .. image:: images/custom_news.png :align: center @@ -44,7 +44,9 @@ First enter ``calibre Blog`` into the :guilabel:`Recipe title` field. This will The next two fields (:guilabel:`Oldest article` and :guilabel:`Max. number of articles`) allow you some control over how many articles should be downloaded from each feed, and they are pretty self explanatory. -To add the feeds to the recipe, enter the feed title and the feed URL and click the :guilabel:`Add feed` button. Once you have added the feed, simply click the :guilabel:`Add/update recipe` button and you're done! Close the dialog. +To add the feeds to the recipe, enter the feed title and the feed URL and click +the :guilabel:`Add feed` button. Once you have added the feed, simply click the +:guilabel:`Save` button and you're done! Close the dialog. To test your new :term:`recipe`, click the :guilabel:`Fetch news` button and in the :guilabel:`Custom news sources` sub-menu click :guilabel:`calibre Blog`. After a couple of minutes, the newly downloaded ebook of blog posts will appear in the main library view (if you have your reader connected, it will be put onto the reader instead of into the library). Select it and hit the :guilabel:`View` button to read! diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py index d56c612ff7..65f17fc6f5 100644 --- a/src/calibre/gui2/dialogs/scheduler.py +++ b/src/calibre/gui2/dialogs/scheduler.py @@ -489,11 +489,10 @@ class Scheduler(QObject): self.lock.unlock() def customize_feeds(self, *args): - from calibre.gui2.dialogs.user_profiles import UserProfiles - d = UserProfiles(self._parent, self.recipe_model) + from calibre.gui2.dialogs.custom_recipes import CustomRecipes + d = CustomRecipes(self.recipe_model, self._parent) try: d.exec_() - d.break_cycles() finally: d.deleteLater() diff --git a/src/calibre/gui2/dialogs/user_profiles.py b/src/calibre/gui2/dialogs/user_profiles.py deleted file mode 100644 index 7d0404fc8f..0000000000 --- a/src/calibre/gui2/dialogs/user_profiles.py +++ /dev/null @@ -1,449 +0,0 @@ -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal ' - -import time, os - -from PyQt5.Qt import (QUrl, QAbstractListModel, Qt, QFont) - -from calibre.web.feeds.recipes import compile_recipe, custom_recipes -from calibre.web.feeds.news import AutomaticNewsRecipe -from calibre.gui2.dialogs.user_profiles_ui import Ui_Dialog -from calibre.gui2 import ( - error_dialog, question_dialog, open_url, choose_files, ResizableDialog, - open_local_file) -from calibre.gui2.widgets import PythonHighlighter -from calibre.ptempfile import PersistentTemporaryFile -from calibre.utils.icu import sort_key - -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 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 has_title(self, title): - for x in self.recipe_model.custom_recipe_collection: - if x.get('title', False) == title: - return True - return False - - def rowCount(self, *args): - try: - return len(self.recipe_model.custom_recipe_collection) - except: - return 0 - - def data(self, index, role): - if role == Qt.DisplayRole: - ans = self.title(index) - if ans is not None: - return (ans) - return None - - def replace_by_title(self, title, script): - 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: - 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 scriptmap.iteritems(): - 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): - self.beginResetModel() - self.recipe_model.add_custom_recipe(title, script) - self.endResetModel() - - 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() - -class UserProfiles(ResizableDialog, Ui_Dialog): - - def __init__(self, parent, recipe_model): - ResizableDialog.__init__(self, parent) - - self._model = self.model = CustomRecipeModel(recipe_model) - self.available_profiles.setModel(self._model) - self.available_profiles.currentChanged = self.current_changed - f = QFont() - f.setStyleHint(f.Monospace) - self.source_code.setFont(f) - - self.remove_feed_button.clicked[(bool)].connect(self.added_feeds.remove_selected_items) - self.remove_profile_button.clicked[(bool)].connect(self.remove_selected_items) - self.add_feed_button.clicked[(bool)].connect(self.add_feed) - self.load_button.clicked.connect(self.load) - self.opml_button.clicked.connect(self.opml_import) - self.builtin_recipe_button.clicked.connect(self.add_builtin_recipe) - self.share_button.clicked.connect(self.share) - self.show_recipe_files_button.clicked.connect(self.show_recipe_files) - self.down_button.clicked.connect(self.down) - self.up_button.clicked.connect(self.up) - self.add_profile_button.clicked[(bool)].connect(self.add_profile) - self.feed_url.returnPressed[()].connect(self.add_feed) - self.feed_title.returnPressed[()].connect(self.add_feed) - self.toggle_mode_button.clicked[(bool)].connect(self.toggle_mode) - self.clear() - - def show_recipe_files(self, *args): - 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 break_cycles(self): - self.recipe_model = self._model.recipe_model = None - self.available_profiles = None - self.model = self._model = None - - def remove_selected_items(self): - indices = self.available_profiles.selectionModel().selectedRows() - self._model.remove([i.row() for i in indices]) - self.clear() - - def up(self): - row = self.added_feeds.currentRow() - item = self.added_feeds.takeItem(row) - if item is not None: - self.added_feeds.insertItem(max(row-1, 0), item) - self.added_feeds.setCurrentItem(item) - - def down(self): - row = self.added_feeds.currentRow() - item = self.added_feeds.takeItem(row) - if item is not None: - self.added_feeds.insertItem(row+1, item) - self.added_feeds.setCurrentItem(item) - - def share(self): - index = self.available_profiles.currentIndex() - title, src = self._model.title(index), self._model.script(index) - if not title or not src: - error_dialog(self, _('No recipe selected'), _('No recipe selected')).exec_() - return - pt = PersistentTemporaryFile(suffix='.recipe') - pt.write(src.encode('utf-8')) - pt.close() - body = _('The attached file: %(fname)s is a ' - 'recipe to download %(title)s.')%dict( - fname=os.path.basename(pt.name), title=title) - subject = _('Recipe for ')+title - url = QUrl('mailto:') - url.addQueryItem('subject', subject) - url.addQueryItem('body', body) - url.addQueryItem('attachment', pt.name) - open_url(url) - - def current_changed(self, current, previous): - if not current.isValid(): - return - src = self._model.script(current) - if src is None: - return - if 'class BasicUserRecipe' in src: - recipe = compile_recipe(src) - self.populate_options(recipe) - self.stacks.setCurrentIndex(0) - self.toggle_mode_button.setText(_('Switch to Advanced mode')) - self.source_code.setPlainText('') - else: - self.source_code.setPlainText(src) - self.highlighter = PythonHighlighter(self.source_code.document()) - self.stacks.setCurrentIndex(1) - self.toggle_mode_button.setText(_('Switch to Basic mode')) - - def toggle_mode(self, *args): - if self.stacks.currentIndex() == 1: - self.stacks.setCurrentIndex(0) - self.toggle_mode_button.setText(_('Switch to Advanced mode')) - else: - self.stacks.setCurrentIndex(1) - self.toggle_mode_button.setText(_('Switch to Basic mode')) - if not unicode(self.source_code.toPlainText()).strip(): - src = self.options_to_profile()[0].replace('AutomaticNewsRecipe', 'BasicNewsRecipe') - self.source_code.setPlainText(src.replace('BasicUserRecipe', 'AdvancedUserRecipe')) - self.highlighter = PythonHighlighter(self.source_code.document()) - - def add_feed(self, *args): - title = unicode(self.feed_title.text()).strip() - if not title: - error_dialog(self, _('Feed must have a title'), - _('The feed must have a title')).exec_() - return - url = unicode(self.feed_url.text()).strip() - if not url: - error_dialog(self, _('Feed must have a URL'), - _('The feed %s must have a URL')%title).exec_() - return - try: - self.added_feeds.add_item(title+' - '+url, (title, url)) - except ValueError: - error_dialog(self, _('Already exists'), - _('This feed has already been added to the recipe')).exec_() - return - self.feed_title.setText('') - self.feed_url.setText('') - - def options_to_profile(self, **kw): - classname = 'BasicUserRecipe'+str(int(time.time())) - title = kw.get('title', self.profile_title.text()) - title = unicode(title).strip() - if not title: - title = classname - self.profile_title.setText(title) - oldest_article = kw.get('oldest_article', self.oldest_article.value()) - max_articles = kw.get('max_articles', self.max_articles.value()) - feeds = kw.get('feeds', - [i.user_data for i in self.added_feeds.items()]) - - src = '''\ -class %(classname)s(%(base_class)s): - title = %(title)s - oldest_article = %(oldest_article)d - max_articles_per_feed = %(max_articles)d - auto_cleanup = True - - feeds = %(feeds)s -'''%dict(classname=classname, title=repr(title), - feeds=repr(feeds), oldest_article=oldest_article, - max_articles=max_articles, - base_class='AutomaticNewsRecipe') - return src, title - - def populate_source_code(self): - src = self.options_to_profile().replace('BasicUserRecipe', 'AdvancedUserRecipe') - self.source_code.setPlainText(src) - self.highlighter = PythonHighlighter(self.source_code.document()) - - def add_profile(self, clicked): - if self.stacks.currentIndex() == 0: - src, title = self.options_to_profile() - - try: - compile_recipe(src) - except Exception as err: - error_dialog(self, _('Invalid input'), - _('

Could not create recipe. Error:
%s')%str(err)).exec_() - return - profile = src - else: - src = unicode(self.source_code.toPlainText()) - try: - title = compile_recipe(src).title - except Exception as err: - error_dialog(self, _('Invalid input'), - _('

Could not create recipe. Error:
%s')%str(err)).exec_() - return - profile = src.replace('BasicUserRecipe', 'AdvancedUserRecipe') - if self._model.has_title(title): - if question_dialog(self, _('Replace recipe?'), - _('A custom recipe named %s already exists. Do you want to ' - 'replace it?')%title): - self._model.replace_by_title(title, profile) - else: - return - else: - self.model.add(title, profile) - self.clear() - - def add_builtin_recipe(self): - from calibre.web.feeds.recipes.collection import \ - get_builtin_recipe_collection, get_builtin_recipe_by_id - from PyQt5.Qt import QDialog, QVBoxLayout, QListWidgetItem, \ - QListWidget, QDialogButtonBox, QSize - - d = QDialog(self) - d.l = QVBoxLayout() - d.setLayout(d.l) - d.list = QListWidget(d) - d.list.doubleClicked.connect(lambda x: d.accept()) - d.l.addWidget(d.list) - d.bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel, - Qt.Horizontal, d) - d.bb.accepted.connect(d.accept) - d.bb.rejected.connect(d.reject) - d.l.addWidget(d.bb) - d.setWindowTitle(_('Choose builtin recipe')) - items = [] - for r in get_builtin_recipe_collection(): - id_ = r.get('id', '') - title = r.get('title', '') - lang = r.get('language', '') - if id_ and title: - items.append((title + ' [%s]'%lang, id_)) - - items.sort(key=lambda x:sort_key(x[0])) - for title, id_ in items: - item = QListWidgetItem(title) - item.setData(Qt.UserRole, id_) - d.list.addItem(item) - - d.resize(QSize(450, 400)) - ret = d.exec_() - d.list.doubleClicked.disconnect() - if ret != d.Accepted: - return - - items = list(d.list.selectedItems()) - if not items: - return - item = items[-1] - id_ = unicode(item.data(Qt.UserRole) or '') - title = unicode(item.data(Qt.DisplayRole) or '').rpartition(' [')[0] - profile = get_builtin_recipe_by_id(id_, download_recipe=True) - if profile is None: - raise Exception('Something weird happened') - - if self._model.has_title(title): - if question_dialog(self, _('Replace recipe?'), - _('A custom recipe named %s already exists. Do you want to ' - 'replace it?')%title): - self._model.replace_by_title(title, profile) - else: - return - else: - self.model.add(title, profile) - - self.clear() - - def load(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: - file = files[0] - try: - profile = open(file, 'rb').read().decode('utf-8') - title = compile_recipe(profile).title - except Exception as err: - error_dialog(self, _('Invalid input'), - _('

Could not create recipe. Error:
%s')%str(err)).exec_() - return - if self._model.has_title(title): - if question_dialog(self, _('Replace recipe?'), - _('A custom recipe named %s already exists. Do you want to ' - 'replace it?')%title): - self._model.replace_by_title(title, profile) - else: - return - else: - self.model.add(title, profile) - self.clear() - - def opml_import(self): - from calibre.gui2.dialogs.opml import ImportOPML - d = ImportOPML(parent=self) - if d.exec_() != d.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._model.has_title(title): - c += 1 - title = u'%s %d' % (base_title, c) - src, title = self.options_to_profile(**{ - 'title':title, - 'feeds':group.feeds, - 'oldest_article':oldest_article, - 'max_articles':max_articles_per_feed, - }) - try: - compile_recipe(src) - except Exception: - import traceback - failed_recipes[title] = traceback.format_exc() - continue - - if replace_existing and self._model.has_title(title): - replace_recipes[title] = src - else: - add_recipes[title] = src - - if add_recipes: - self.model.add_many(add_recipes) - if replace_recipes: - self.model.replace_many_by_title(replace_recipes) - if failed_recipes: - det_msg = '\n'.join('%s\n%s\n' % (title, tb) for title, tb in failed_recipes.iteritems()) - error_dialog(self, _('Failed to create recipes'), _( - 'Failed to create some recipes, click "Show details" for details'), show=True, - det_msg=det_msg) - self.clear() - - def populate_options(self, profile): - self.oldest_article.setValue(profile.oldest_article) - self.max_articles.setValue(profile.max_articles_per_feed) - self.profile_title.setText(profile.title) - self.added_feeds.clear() - feeds = [] if profile.feeds is None else profile.feeds - for title, url in feeds: - self.added_feeds.add_item(title+' - '+url, (title, url)) - self.feed_title.setText('') - self.feed_url.setText('') - - def clear(self): - self.populate_options(AutomaticNewsRecipe) - self.source_code.setText('') - - def reject(self): - if question_dialog(self, _('Are you sure?'), - _('You will lose any unsaved changes. To save your' - ' changes, click the Add/Update recipe button.' - ' Continue?'), show_copy_button=False): - ResizableDialog.reject(self) - -if __name__ == '__main__': - from PyQt5.Qt import QApplication - app = QApplication([]) - from calibre.web.feeds.recipes.model import RecipeModel - d=UserProfiles(None, RecipeModel()) - d.exec_() - del app - diff --git a/src/calibre/gui2/dialogs/user_profiles.ui b/src/calibre/gui2/dialogs/user_profiles.ui deleted file mode 100644 index a84fb83c75..0000000000 --- a/src/calibre/gui2/dialogs/user_profiles.ui +++ /dev/null @@ -1,515 +0,0 @@ - - - Dialog - - - - 0 - 0 - 738 - 640 - - - - Add custom news source - - - - :/images/user_profile.png:/images/user_profile.png - - - - - - QFrame::NoFrame - - - 0 - - - true - - - - - 0 - 0 - 726 - 595 - - - - - 0 - - - - - - 580 - 550 - - - - - 0 - - - - - - 1 - 0 - - - - Available user recipes - - - - - - - - - Add/Update &recipe - - - - :/images/plus.png:/images/plus.png - - - - - - - &Remove recipe - - - - :/images/list_remove.png:/images/list_remove.png - - - - - - - &Share recipe - - - - :/images/forward.png:/images/forward.png - - - - - - - S&how recipe files - - - - :/images/document_open.png:/images/document_open.png - - - - - - - Customize &builtin recipe - - - - :/images/news.png:/images/news.png - - - - - - - &Load recipe from file - - - - :/images/chapters.png:/images/chapters.png - - - - - - - Import a collection of RSS feeds in OPML format -Many RSS readers can export their subscribed RSS feeds -in OPML format - - - Import &OPML - - - - :/images/mimetypes/opml.png:/images/mimetypes/opml.png - - - - - - - - - - - 10 - 0 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Switch to Advanced mode - - - - - - - 0 - - - - - - - <html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'DejaVu Sans'; font-size:10pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Create a basic news recipe, by adding RSS feeds to it. <br />For most feeds, you will have to use the "Advanced mode" to further customize the fetch process.</p></body></html> - - - Qt::RichText - - - true - - - - - - - - - Recipe &title: - - - profile_title - - - - - - - - 75 - true - - - - - - - - &Oldest article: - - - oldest_article - - - - - - - The oldest article to download - - - days - - - 1 - - - 36500 - - - 7 - - - - - - - &Max. number of articles per feed: - - - max_articles - - - - - - - Maximum number of articles to download per feed. - - - 5 - - - 100 - - - 10 - - - - - - - - - - 100 - 0 - - - - Feeds in recipe - - - - - - - 100 - 0 - - - - QAbstractItemView::MultiSelection - - - - - - - - - ... - - - - :/images/arrow-up.png:/images/arrow-up.png - - - - - - - Remove feed from recipe - - - ... - - - - :/images/list_remove.png:/images/list_remove.png - - - - - - - ... - - - - :/images/arrow-down.png:/images/arrow-down.png - - - - - - - - - - - - Add feed to recipe - - - - - - &Feed title: - - - feed_title - - - - - - - - - - Feed &URL: - - - feed_url - - - - - - - - - - Add feed to recipe - - - &Add feed - - - - :/images/plus.png:/images/plus.png - - - - - - - - - - - - - - For help with writing advanced news recipes, please visit <a href="http://manual.calibre-ebook.com/news.html">User Recipes</a> - - - true - - - true - - - - - - - Recipe source code (python) - - - - - - - 100 - 0 - - - - QTextEdit::NoWrap - - - false - - - - - - - - - - - - - - - - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Close - - - - - - - - BasicList - QListWidget -

calibre/gui2/widgets.h
- - - EnLineEdit - QLineEdit -
calibre/gui2/widgets.h
-
- - - - - - - buttonBox - accepted() - Dialog - accept() - - - 446 - 649 - - - 0 - 632 - - - - - buttonBox - rejected() - Dialog - reject() - - - 175 - 643 - - - 176 - 636 - - - - -