diff --git a/Changelog.yaml b/Changelog.yaml index b8b8f2b480..b297823841 100644 --- a/Changelog.yaml +++ b/Changelog.yaml @@ -92,8 +92,8 @@ - title: "Various Romanian news sources" author: Silviu Coatara - - title: "Osnews.pl and SwiatKindle" - author: Mori + - title: "Osnews.pl and SwiatCzytnikow" + author: Tomasz Dlugosz - title: "Roger Ebert Journal" author: Shane Erstad diff --git a/resources/images/minusminus.png b/resources/images/minusminus.png new file mode 100644 index 0000000000..71225be8d7 Binary files /dev/null and b/resources/images/minusminus.png differ diff --git a/resources/images/news/bucataras.png b/resources/images/news/bucataras.png new file mode 100644 index 0000000000..fae90e17c4 Binary files /dev/null and b/resources/images/news/bucataras.png differ diff --git a/resources/images/news/historiaro.png b/resources/images/news/historiaro.png new file mode 100644 index 0000000000..c9e616c876 Binary files /dev/null and b/resources/images/news/historiaro.png differ diff --git a/resources/images/plusplus.png b/resources/images/plusplus.png new file mode 100644 index 0000000000..db918365d0 Binary files /dev/null and b/resources/images/plusplus.png differ diff --git a/resources/recipes/bucataras.recipe b/resources/recipes/bucataras.recipe new file mode 100644 index 0000000000..b069ecc5b0 --- /dev/null +++ b/resources/recipes/bucataras.recipe @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python + +__license__ = 'GPL v3' +__copyright__ = u'2011, Silviu Cotoar\u0103' +''' +bucataras.ro +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class Bucataras(BasicNewsRecipe): + title = u'Bucataras' + __author__ = u'Silviu Cotoar\u0103' + description = '' + publisher = 'Bucataras' + oldest_article = 5 + language = 'ro' + max_articles_per_feed = 100 + no_stylesheets = True + use_embedded_content = False + category = 'Ziare,Bucatarie,Retete' + encoding = 'utf-8' + cover_url = 'http://www.bucataras.ro/templates/default/images/pink/logo.jpg' + + conversion_options = { + 'comments' : description + ,'tags' : category + ,'language' : language + ,'publisher' : publisher + } + + keep_only_tags = [ + dict(name='h1', attrs={'class':'titlu'}) + , dict(name='div', attrs={'class':'contentL'}) + , dict(name='div', attrs={'class':'contentBottom'}) + + ] + + remove_tags = [ + dict(name='div', attrs={'class':['sociale']}) + , dict(name='div', attrs={'class':['contentR']}) + , dict(name='a', attrs={'target':['_self']}) + , dict(name='div', attrs={'class':['comentarii']}) + ] + + remove_tags_after = [ + dict(name='div', attrs={'class':['comentarii']}) + ] + + feeds = [ + (u'Feeds', u'http://www.bucataras.ro/rss/retete/') + ] + + def preprocess_html(self, soup): + return self.adeify_images(soup) diff --git a/resources/recipes/buffalo_news.recipe b/resources/recipes/buffalo_news.recipe new file mode 100644 index 0000000000..92c96757ae --- /dev/null +++ b/resources/recipes/buffalo_news.recipe @@ -0,0 +1,56 @@ +__license__ = 'GPL v3' +__author__ = 'Todd Chapman' +__copyright__ = 'Todd Chapman' +__version__ = 'v0.1' +__date__ = '26 February 2011' + +''' +http://www.buffalonews.com/RSS/ +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class AdvancedUserRecipe1298680852(BasicNewsRecipe): + title = u'Buffalo News' + __author__ = 'ChappyOnIce' + language = 'en' + oldest_article = 2 + max_articles_per_feed = 20 + encoding = 'utf-8' + remove_javascript = True + keep_only_tags = [ + dict(name='div', attrs={'class':['main-content-left']}) + ] + + remove_tags = [ + dict(name='div', attrs={'id':['commentCount']}), + dict(name='div', attrs={'class':['story-list-links']}) + ] + + remove_tags_after = dict(name='div', attrs={'class':['body storyContent']}) + conversion_options = { + 'base_font_size' : 14, + } + feeds = [(u'City of Buffalo', u'http://www.buffalonews.com/city/communities/buffalo/?widget=rssfeed&view=feed&contentId=77944'), + (u'Southern Erie County', u'http://www.buffalonews.com/city/communities/southern-erie/?widget=rssfeed&view=feed&contentId=77944'), + (u'Eastern Erie County', u'http://www.buffalonews.com/city/communities/eastern-erie/?widget=rssfeed&view=feed&contentId=77944'), + (u'Southern Tier', u'http://www.buffalonews.com/city/communities/southern-tier/?widget=rssfeed&view=feed&contentId=77944'), + (u'Niagara County', u'http://www.buffalonews.com/city/communities/niagara-county/?widget=rssfeed&view=feed&contentId=77944'), + (u'Business', u'http://www.buffalonews.com/business/?widget=rssfeed&view=feed&contentId=77944'), + (u'MoneySmart', u'http://www.buffalonews.com/business/moneysmart/?widget=rssfeed&view=feed&contentId=77944'), + (u'Bills & NFL', u'http://www.buffalonews.com/sports/bills-nfl/?widget=rssfeed&view=feed&contentId=77944'), + (u'Sabres & NHL', u'http://www.buffalonews.com/sports/sabres-nhl/?widget=rssfeed&view=feed&contentId=77944'), + (u'Bob DiCesare', u'http://www.buffalonews.com/sports/columns/bob-dicesare/?widget=rssfeed&view=feed&contentId=77944'), + (u'Bucky Gleason', u'http://www.buffalonews.com/sports/columns/bucky-gleason/?widget=rssfeed&view=feed&contentId=77944'), + (u'Mark Gaughan', u'http://www.buffalonews.com/sports/bills-nfl/inside-the-nfl/?widget=rssfeed&view=feed&contentId=77944'), + (u'Mike Harrington', u'http://www.buffalonews.com/sports/columns/mike-harrington/?widget=rssfeed&view=feed&contentId=77944'), + (u'Jerry Sullivan', u'http://www.buffalonews.com/sports/columns/jerry-sullivan/?widget=rssfeed&view=feed&contentId=77944'), + (u'Other Sports Columns', u'http://www.buffalonews.com/sports/columns/other-sports-columns/?widget=rssfeed&view=feed&contentId=77944'), + (u'Life', u'http://www.buffalonews.com/life/?widget=rssfeed&view=feed&contentId=77944'), + (u'Bruce Andriatch', u'http://www.buffalonews.com/city/columns/bruce-andriatch/?widget=rssfeed&view=feed&contentId=77944'), + (u'Donn Esmonde', u'http://www.buffalonews.com/city/columns/donn-esmonde/?widget=rssfeed&view=feed&contentId=77944'), + (u'Rod Watson', u'http://www.buffalonews.com/city/columns/rod-watson/?widget=rssfeed&view=feed&contentId=77944'), + (u'Entertainment', u'http://www.buffalonews.com/entertainment/?widget=rssfeed&view=feed&contentId=77944'), + (u'Off Main Street', u'http://www.buffalonews.com/city/columns/off-main-street/?widget=rssfeed&view=feed&contentId=77944'), + (u'Editorials', u'http://www.buffalonews.com/editorial-page/buffalo-news-editorials/?widget=rssfeed&view=feed&contentId=77944') + ] diff --git a/resources/recipes/dotpod.recipe b/resources/recipes/dotpod.recipe new file mode 100644 index 0000000000..b04945e6d4 --- /dev/null +++ b/resources/recipes/dotpod.recipe @@ -0,0 +1,27 @@ +__license__ = 'GPL v3' +__copyright__ = '2011-2011, Federico Escalada ' + +from calibre.web.feeds.news import BasicNewsRecipe + +class Dotpod(BasicNewsRecipe): + __author__ = 'Federico Escalada' + description = 'Tecnologia y Comunicacion Audiovisual' + encoding = 'utf-8' + language = 'es' + max_articles_per_feed = 100 + no_stylesheets = True + oldest_article = 7 + publication_type = 'blog' + title = 'Dotpod' + authors = 'Federico Picone' + + conversion_options = { + 'authors' : authors + ,'comments' : description + ,'language' : language + } + + feeds = [('Dotpod', 'http://www.dotpod.com.ar/feed/')] + + remove_tags = [dict(name='div', attrs={'class':'feedflare'})] + diff --git a/resources/recipes/historiaro.recipe b/resources/recipes/historiaro.recipe new file mode 100644 index 0000000000..98eb5b6dfe --- /dev/null +++ b/resources/recipes/historiaro.recipe @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python + +__license__ = 'GPL v3' +__copyright__ = u'2011, Silviu Cotoar\u0103' +''' +historia.ro +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class HistoriaRo(BasicNewsRecipe): + title = u'Historia' + __author__ = u'Silviu Cotoar\u0103' + description = '' + publisher = 'Historia' + oldest_article = 5 + language = 'ro' + max_articles_per_feed = 100 + no_stylesheets = True + use_embedded_content = False + category = 'Ziare,Reviste,Istorie' + encoding = 'utf-8' + cover_url = 'http://www.historia.ro/sites/all/themes/historia/images/historia.png' + + conversion_options = { + 'comments' : description + ,'tags' : category + ,'language' : language + ,'publisher' : publisher + } + + keep_only_tags = [ + dict(name='div', attrs={'class':'c_antet_title'}) + , dict(name='a', attrs={'class':'overlaybox'}) + , dict(name='div', attrs={'class':'art_content'}) + ] + + remove_tags = [ + dict(name='div', attrs={'class':['fl_left']}) + , dict(name='div', attrs={'id':['article_toolbar']}) + , dict(name='div', attrs={'class':['zoom_cont']}) + ] + + + feeds = [ + (u'Feeds', u'http://www.historia.ro/rss.xml') + ] + + def preprocess_html(self, soup): + return self.adeify_images(soup) diff --git a/src/calibre/gui2/actions/fetch_news.py b/src/calibre/gui2/actions/fetch_news.py index 5c2a5e9663..f7756efbab 100644 --- a/src/calibre/gui2/actions/fetch_news.py +++ b/src/calibre/gui2/actions/fetch_news.py @@ -58,6 +58,21 @@ class FetchNewsAction(InterfaceAction): self.scheduler.recipe_download_failed(arg) return self.gui.job_exception(job) id = self.gui.library_view.model().add_news(pt.name, arg) + + # Arg may contain a "keep_issues" variable. If it is non-zero, + # delete all but newest x issues. + try: + keep_issues = int(arg['keep_issues']) + except: + keep_issues = 0 + if keep_issues > 0: + ids_with_tag = list(sorted(self.gui.library_view.model(). + db.tags_older_than(arg['title'], + None, must_have_tag=_('News')), reverse=True)) + ids_to_delete = ids_with_tag[keep_issues:] + if ids_to_delete: + self.gui.library_view.model().delete_books_by_id(ids_to_delete) + self.gui.library_view.model().reset() sync = self.gui.news_to_be_synced sync.add(id) diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py index b6a3bed3eb..48d0d67255 100644 --- a/src/calibre/gui2/dialogs/scheduler.py +++ b/src/calibre/gui2/dialogs/scheduler.py @@ -10,11 +10,9 @@ Scheduler for automated recipe downloads from datetime import timedelta from PyQt4.Qt import QDialog, SIGNAL, Qt, QTime, QObject, QMenu, \ - QAction, QIcon, QMutex, QTimer, pyqtSignal, QWidget, QHBoxLayout, \ - QLabel + QAction, QIcon, QMutex, QTimer, pyqtSignal from calibre.gui2.dialogs.scheduler_ui import Ui_Dialog -from calibre.gui2.search_box import SearchBox2 from calibre.gui2 import config as gconf, error_dialog from calibre.web.feeds.recipes.model import RecipeModel from calibre.ptempfile import PersistentTemporaryFile @@ -28,18 +26,12 @@ class SchedulerDialog(QDialog, Ui_Dialog): self.setupUi(self) self.recipe_model = recipe_model self.recipe_model.do_refresh() + self.count_label.setText( + _('%s news sources') % + self.recipe_model.showing_count) - self._cont = QWidget(self) - self._cont.l = QHBoxLayout() - self._cont.setLayout(self._cont.l) - self._cont.la = QLabel(_('&Search:')) - self._cont.l.addWidget(self._cont.la, 1) - self.search = SearchBox2(self) - self._cont.l.addWidget(self.search, 100) - self._cont.la.setBuddy(self.search) - self.search.setMinimumContentsLength(25) self.search.initialize('scheduler_search_history') - self.recipe_box.layout().insertWidget(0, self._cont) + self.search.setMinimumContentsLength(15) self.search.search.connect(self.recipe_model.search) self.recipe_model.searched.connect(self.search.search_done, type=Qt.QueuedConnection) @@ -153,9 +145,12 @@ class SchedulerDialog(QDialog, Ui_Dialog): self.recipe_model.un_schedule_recipe(urn) add_title_tag = self.add_title_tag.isChecked() + keep_issues = u'0' + if self.keep_issues.isEnabled(): + keep_issues = unicode(self.keep_issues.value()) custom_tags = unicode(self.custom_tags.text()).strip() custom_tags = [x.strip() for x in custom_tags.split(',')] - self.recipe_model.customize_recipe(urn, add_title_tag, custom_tags) + self.recipe_model.customize_recipe(urn, add_title_tag, custom_tags, keep_issues) return True def initialize_detail_box(self, urn): @@ -215,9 +210,16 @@ class SchedulerDialog(QDialog, Ui_Dialog): if d < timedelta(days=366): self.last_downloaded.setText(_('Last downloaded')+': '+tm) - add_title_tag, custom_tags = customize_info + add_title_tag, custom_tags, keep_issues = customize_info self.add_title_tag.setChecked(add_title_tag) self.custom_tags.setText(u', '.join(custom_tags)) + try: + keep_issues = int(keep_issues) + except: + keep_issues = 0 + self.keep_issues.setValue(keep_issues) + self.keep_issues.setEnabled(self.add_title_tag.isChecked()) + class Scheduler(QObject): @@ -299,7 +301,7 @@ class Scheduler(QObject): un = pw = None if account_info is not None: un, pw = account_info - add_title_tag, custom_tags = customize_info + add_title_tag, custom_tags, keep_issues = customize_info script = self.recipe_model.get_recipe(urn) pt = PersistentTemporaryFile('_builtin.recipe') pt.write(script) @@ -312,6 +314,7 @@ class Scheduler(QObject): 'recipe':pt.name, 'title':recipe.get('title',''), 'urn':urn, + 'keep_issues':keep_issues } self.download_queue.add(urn) self.start_recipe_fetch.emit(arg) diff --git a/src/calibre/gui2/dialogs/scheduler.ui b/src/calibre/gui2/dialogs/scheduler.ui index 8e6ab37162..26953bbe16 100644 --- a/src/calibre/gui2/dialogs/scheduler.ui +++ b/src/calibre/gui2/dialogs/scheduler.ui @@ -14,358 +14,403 @@ Schedule news download - + :/images/scheduler.png:/images/scheduler.png - - - - - Recipes + + + + + &Search: + + + search - - - - - false - - - - 16 - 16 - - - - true - - - true - - - - - - - Download all scheduled recipes at once - - - Download &all scheduled - - - - - - - - - - - - - - - - QFrame::NoFrame + + + + + + QFrame::NoFrame + + + true + + + + + 0 + 0 + 469 + 504 + + + + + 0 - - true - - - - - 0 - 0 - 375 - 502 - - - - + + + 0 - - - - - 0 - 100 - - - - 0 - - - - &Schedule - - + + + &Schedule + + + + + + blurb + + + true + + + true + + + + + + + &Schedule for download: + + + + + - + - blurb - - - Qt::RichText - - - true - - - true + Every - + + + + day + + + + + Monday + + + + + Tuesday + + + + + Wednesday + + + + + Thursday + + + + + Friday + + + + + Saturday + + + + + Sunday + + + + + + - &Schedule for download: + at - - - - - Every - - - - - - - - day - - - - - Monday - - - - - Tuesday - - - - - Wednesday - - - - - Thursday - - - - - Friday - - - - - Saturday - - - - - Sunday - - - - - - - - at - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + - - - - - Every - - - - - - - - 0 - 0 - - - - Interval at which to download this recipe. A value of zero means that the recipe will be downloaded every hour. - - - days - - - 1 - - - 0.000000000000000 - - - 365.100000000000023 - - - 1.000000000000000 - - - 1.000000000000000 - - - - - - - - - - - - - - - - &Account - - - - - - - - - &Username: - - - username - - - - - - - &Password: - - - password - - - - - - - QLineEdit::Password - - - - - - - &Show password - - - - - - - - - - For the scheduling to work, you must leave calibre running. - - - true - - - - - - - - &Advanced - - - - - - Add &title as tag - - - - - - - &Extra tags: - - - custom_tags - - - - - - - - + - Qt::Vertical + Qt::Horizontal - 20 - 40 + 40 + 20 - - - - - - - &Download now - - - - - + + + + + + + Every + + + + + + + + 0 + 0 + + + + Interval at which to download this recipe. A value of zero means that the recipe will be downloaded every hour. + + + days + + + 1 + + + 0.000000000000000 + + + 365.100000000000023 + + + 1.000000000000000 + + + 1.000000000000000 + + + + + + + + + + + + true + + + + + + + &Account + + + + + + + + + &Username: + + + username + + + + + + + &Password: + + + password + + + + + + + QLineEdit::Password + + + + + + + &Show password + + + + + + + + + + For the scheduling to work, you must leave calibre running. + + + true + + + + + + + + &Advanced + + + + + + Add &title as tag + + + + + + + &Extra tags: + + + custom_tags + + + + + + + Maximum number of copies (issues) of this recipe to keep. Set to 0 to keep all (disable). + + + &Keep at most: + + + keep_issues + + + + + + + <p>When set, this option will cause calibre to keep, at most, the specified number of issues of this periodical. Every time a new issue is downloaded, the oldest one is deleted, if the total is larger than this number. +<p>Note that this feature only works if you have the option to add the title as tag checked, above. +<p>Also, the setting for deleting periodicals older than a number of days, below, takes priority over this setting. + + + all issues + + + issues + + + 100000 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + &Download now + + + + + + + + + + + + 0 + 0 + + + + false + + + + 16 + 16 + + + + true + + + true + + + + + + + + + &Delete downloaded news older than: + + + old_news + + + + + + + <p>Delete downloaded news older than the specified number of days. Set to zero to disable. +<p>You can also control the maximum number of issues of a specific periodical that are kept by clicking the Advanced tab for that periodical above. + + + never delete + + + days + + + 1000 + - + Qt::Horizontal @@ -375,24 +420,35 @@ - - + + - Delete downloaded news older than the specified number of days. Set to zero to disable. + Download all scheduled news sources at once - - days + + Download &all scheduled - - Delete downloaded news older than + + + + + + - - 1000 + + Qt::AlignCenter + + + SearchBox2 + QComboBox +
calibre/gui2/search_box.h
+
+
@@ -436,12 +492,12 @@ setEnabled(bool) - 456 - 173 + 458 + 155 - 537 - 176 + 573 + 158 @@ -452,12 +508,12 @@ setEnabled(bool) - 456 - 173 + 458 + 155 - 647 - 176 + 684 + 157 @@ -468,12 +524,28 @@ setEnabled(bool) - 456 - 239 + 458 + 212 - 495 - 218 + 752 + 215 + + + + + add_title_tag + toggled(bool) + keep_issues + setEnabled(bool) + + + 508 + 42 + + + 577 + 108 diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index 9bddb817cf..899d3d1920 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -178,8 +178,10 @@ class TagCategories(QDialog, Ui_TagCategories): 'multiple periods in a row or spaces before ' 'or after periods.')).exec_() return False - for c in self.categories: - if strcmp(c, cat_name) == 0: + for c in sorted(self.categories.keys(), key=sort_key): + if strcmp(c, cat_name) == 0 or \ + (icu_lower(cat_name).startswith(icu_lower(c) + '.') and\ + not cat_name.startswith(c + '.')): error_dialog(self, _('Name already used'), _('That name is already used, perhaps with different case.')).exec_() return False diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index 34be6cd276..5a4c34a5cd 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -217,11 +217,15 @@ class SearchBox2(QComboBox): # {{{ self.clear() else: self.normalize_state() + self.lineEdit().setCompleter(None) self.setEditText(txt) self.line_edit.end(False) if emit_changed: self.changed.emit() self._do_search(store_in_history=store_in_history) + c = QCompleter() + self.lineEdit().setCompleter(c) + c.setCompletionMode(c.PopupCompletion) self.focus_to_library.emit() finally: if not store_in_history: diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 2693ba8ed6..8abe2d433d 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -21,6 +21,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, \ from calibre.ebooks.metadata import title_sort from calibre.gui2 import config, NONE, gprefs from calibre.library.field_metadata import TagsIcons, category_icon_map +from calibre.library.database2 import Tag from calibre.utils.config import tweaks from calibre.utils.icu import sort_key, lower, strcmp from calibre.utils.search_query_parser import saved_searches @@ -69,7 +70,8 @@ class TagDelegate(QItemDelegate): # {{{ # }}} -TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_minus': 2} +TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2, + 'mark_minus': 3, 'mark_minusminus': 4} class TagsView(QTreeView): # {{{ @@ -127,13 +129,17 @@ class TagsView(QTreeView): # {{{ self.set_new_model(self._model.get_filter_categories_by()) def set_database(self, db, tag_match, sort_by): - self.hidden_categories = db.prefs.get('tag_browser_hidden_categories', None) + hidden_cats = db.prefs.get('tag_browser_hidden_categories', None) + self.hidden_categories = [] # migrate from config to db prefs - if self.hidden_categories is None: - self.hidden_categories = config['tag_browser_hidden_categories'] - db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) - else: - self.hidden_categories = set(self.hidden_categories) + if hidden_cats is None: + hidden_cats = config['tag_browser_hidden_categories'] + # strip out any non-existence field keys + for cat in hidden_cats: + if cat in db.field_metadata: + self.hidden_categories.append(cat) + db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) + self.hidden_categories = set(self.hidden_categories) old = getattr(self, '_model', None) if old is not None: @@ -370,14 +376,15 @@ class TagsView(QTreeView): # {{{ action='delete_user_category', key=key)) self.context_menu.addSeparator() # Hide/Show/Restore categories - if not key.startswith('@') or key.find('.') < 0: - self.context_menu.addAction(_('Hide category %s') % category, - partial(self.context_menu_handler, action='hide', - category=category)) + #if not key.startswith('@') or key.find('.') < 0: + self.context_menu.addAction(_('Hide category %s') % category, + partial(self.context_menu_handler, action='hide', + category=key)) if self.hidden_categories: m = self.context_menu.addMenu(_('Show category')) - for col in sorted(self.hidden_categories, key=sort_key): - m.addAction(col, + for col in sorted(self.hidden_categories, + key=lambda x: sort_key(self.db.field_metadata[x]['name'])): + m.addAction(self.db.field_metadata[col]['name'], partial(self.context_menu_handler, action='show', category=col)) # search by category @@ -540,6 +547,7 @@ class TagTreeItem(object): # {{{ self.id_set = set() self.is_gst = False self.boxed = False + self.icon_state_map = list(map(QVariant, icon_map)) if self.parent is not None: self.parent.append(self) if data is None: @@ -554,9 +562,11 @@ class TagTreeItem(object): # {{{ self.bold_font = QVariant(self.bold_font) self.category_key = category_key self.temporary = temporary + self.tag = Tag(data) + self.tag.is_hierarchical = category_key.startswith('@') elif self.type == self.TAG: icon_map[0] = data.icon - self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) + self.tag = data if tooltip: self.tooltip = tooltip + ' ' else: @@ -593,6 +603,8 @@ class TagTreeItem(object): # {{{ if role == Qt.EditRole: return QVariant(self.py_name) if role == Qt.DecorationRole: + if self.tag.state: + return self.icon_state_map[self.tag.state] return self.icon if role == Qt.FontRole: return self.bold_font @@ -642,11 +654,21 @@ class TagTreeItem(object): # {{{ ''' set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES ''' - if self.type == self.TAG: - if set_to is None: - self.tag.state = (self.tag.state + 1)%3 - else: - self.tag.state = set_to + if set_to is None: + while True: + self.tag.state = (self.tag.state + 1)%5 + if self.tag.state == TAG_SEARCH_STATES['mark_plus'] or \ + self.tag.state == TAG_SEARCH_STATES['mark_minus']: + if self.tag.is_editable: + break + elif self.tag.state == TAG_SEARCH_STATES['mark_plusplus'] or\ + self.tag.state == TAG_SEARCH_STATES['mark_minusminus']: + if self.tag.is_hierarchical and len(self.children): + break + else: + break + else: + self.tag.state = set_to def child_tags(self): res = [] @@ -677,7 +699,8 @@ class TagsModel(QAbstractItemModel): # {{{ self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags'] self.drag_drop_finished = drag_drop_finished - self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('minus.png'))] + self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('plusplus.png')), + QIcon(I('minus.png')), QIcon(I('minusminus.png'))] self.db = db self.tags_view = parent self.hidden_categories = hidden_categories @@ -691,26 +714,33 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_tags_by']) gst = db.prefs.get('grouped_search_terms', {}) - self.root_item = TagTreeItem() + self.root_item = TagTreeItem(icon_map=self.icon_state_map) self.category_nodes = [] last_category_node = None category_node_map = {} self.category_node_tree = {} - for i, r in enumerate(self.row_map): - if self.hidden_categories and self.categories[i] in self.hidden_categories: - continue + for i, key in enumerate(self.row_map): + if self.hidden_categories: + if key in self.hidden_categories: + continue + found = False + for cat in self.hidden_categories: + if cat.startswith('@') and key.startswith(cat + '.'): + found = True + if found: + continue is_gst = False - if r.startswith('@') and r[1:] in gst: - tt = _(u'The grouped search term name is "{0}"').format(r[1:]) + if key.startswith('@') and key[1:] in gst: + tt = _(u'The grouped search term name is "{0}"').format(key[1:]) is_gst = True - elif r == 'news': + elif key == 'news': tt = '' else: - tt = _(u'The lookup/search name is "{0}"').format(r) + tt = _(u'The lookup/search name is "{0}"').format(key) - if r.startswith('@'): - path_parts = [p for p in r.split('.')] + if key.startswith('@'): + path_parts = [p for p in key.split('.')] path = '' last_category_node = self.root_item tree_root = self.category_node_tree @@ -719,9 +749,10 @@ class TagsModel(QAbstractItemModel): # {{{ if path not in category_node_map: node = TagTreeItem(parent=last_category_node, data=p[1:] if i == 0 else p, - category_icon=self.category_icon_map[r], - tooltip=tt if path == r else path, - category_key=path) + category_icon=self.category_icon_map[key], + tooltip=tt if path == key else path, + category_key=path, + icon_map=self.icon_state_map) last_category_node = node category_node_map[path] = node self.category_nodes.append(node) @@ -736,11 +767,12 @@ class TagsModel(QAbstractItemModel): # {{{ path += '.' else: node = TagTreeItem(parent=self.root_item, - data=self.categories[i], - category_icon=self.category_icon_map[r], - tooltip=tt, category_key=r) + data=self.categories[key], + category_icon=self.category_icon_map[key], + tooltip=tt, category_key=key, + icon_map=self.icon_state_map) node.is_gst = False - category_node_map[r] = node + category_node_map[key] = node last_category_node = node self.category_nodes.append(node) self.refresh(data=data) @@ -1015,7 +1047,7 @@ class TagsModel(QAbstractItemModel): # {{{ def get_node_tree(self, sort): old_row_map = self.row_map[:] self.row_map = [] - self.categories = [] + self.categories = {} # Get the categories if self.search_restriction: @@ -1062,7 +1094,7 @@ class TagsModel(QAbstractItemModel): # {{{ for category in tb_categories: if category in data: # The search category can come and go self.row_map.append(category) - self.categories.append(tb_categories[category]['name']) + self.categories[category] = tb_categories[category]['name'] if len(old_row_map) != 0 and len(old_row_map) != len(self.row_map): # A category has been added or removed. We must force a rebuild of @@ -1163,7 +1195,8 @@ class TagsModel(QAbstractItemModel): # {{{ sub_cat = TagTreeItem(parent=category, data = name, tooltip = None, temporary=True, category_icon = category_node.icon, - category_key=category_node.category_key) + category_key=category_node.category_key, + icon_map=self.icon_state_map) self.endInsertRows() else: # by 'first letter' cl = cl_list[idx] @@ -1173,7 +1206,8 @@ class TagsModel(QAbstractItemModel): # {{{ data = collapse_letter, category_icon = category_node.icon, tooltip = None, temporary=True, - category_key=category_node.category_key) + category_key=category_node.category_key, + icon_map=self.icon_state_map) node_parent = sub_cat else: node_parent = category @@ -1284,16 +1318,19 @@ class TagsModel(QAbstractItemModel): # {{{ return False user_cats = self.db.prefs.get('user_categories', {}) + user_cat_keys_lower = [icu_lower(k) for k in user_cats] ckey = item.category_key[1:] + ckey_lower = icu_lower(ckey) dotpos = ckey.rfind('.') if dotpos < 0: nkey = val else: nkey = ckey[:dotpos+1] + val - for c in user_cats: - if c.startswith(ckey): + nkey_lower = icu_lower(nkey) + for c in sorted(user_cats.keys(), key=sort_key): + if icu_lower(c).startswith(ckey_lower): if len(c) == len(ckey): - if nkey in user_cats: + if nkey_lower in user_cat_keys_lower: error_dialog(self.tags_view, _('Rename user category'), _('The name %s is already used')%nkey, show=True) return False @@ -1301,7 +1338,7 @@ class TagsModel(QAbstractItemModel): # {{{ del user_cats[ckey] elif c[len(ckey)] == '.': rest = c[len(ckey):] - if (nkey + rest) in user_cats: + if icu_lower(nkey + rest) in user_cat_keys_lower: error_dialog(self.tags_view, _('Rename user category'), _('The name %s is already used')%(nkey+rest), show=True) return False @@ -1477,16 +1514,15 @@ class TagsModel(QAbstractItemModel): # {{{ def reset_all_states(self, except_=None): update_list = [] def process_tag(tag_item): - if tag_item.type != TagTreeItem.CATEGORY: - tag = tag_item.tag - if tag is except_: - tag_index = self.createIndex(tag_item.row(), 0, tag_item) - self.dataChanged.emit(tag_index, tag_index) - elif tag.state != 0 or tag in update_list: - tag_index = self.createIndex(tag_item.row(), 0, tag_item) - tag.state = 0 - update_list.append(tag) - self.dataChanged.emit(tag_index, tag_index) + tag = tag_item.tag + if tag is except_: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + self.dataChanged.emit(tag_index, tag_index) + elif tag.state != 0 or tag in update_list: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + tag.state = 0 + update_list.append(tag) + self.dataChanged.emit(tag_index, tag_index) for t in tag_item.children: process_tag(t) @@ -1503,13 +1539,11 @@ class TagsModel(QAbstractItemModel): # {{{ ''' if not index.isValid(): return False item = index.internalPointer() - if item.type == TagTreeItem.TAG: - item.toggle(set_to=set_to) - if exclusive: - self.reset_all_states(except_=item.tag) - self.dataChanged.emit(index, index) - return True - return False + item.toggle(set_to=set_to) + if exclusive: + self.reset_all_states(except_=item.tag) + self.dataChanged.emit(index, index) + return True def tokens(self): ans = [] @@ -1523,19 +1557,31 @@ class TagsModel(QAbstractItemModel): # {{{ # into the search string only once. The nodes_seen set helps us do that nodes_seen = set() + node_searches = {TAG_SEARCH_STATES['mark_plus'] : 'true', + TAG_SEARCH_STATES['mark_plusplus'] : '.true', + TAG_SEARCH_STATES['mark_minus'] : 'false', + TAG_SEARCH_STATES['mark_minusminus'] : '.false'} + for node in self.category_nodes: + if node.tag.state: + ans.append('%s:%s'%(node.category_key, node_searches[node.tag.state])) + key = node.category_key for tag_item in node.child_tags(): tag = tag_item.tag if tag.state != TAG_SEARCH_STATES['clear']: - prefix = ' not ' if tag.state == TAG_SEARCH_STATES['mark_minus'] \ - else '' + if tag.state == TAG_SEARCH_STATES['mark_minus'] or \ + tag.state == TAG_SEARCH_STATES['mark_minusminus']: + prefix = ' not ' + else: + prefix = '' category = tag.category if key != 'news' else 'tag' if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating ans.append('%s%s:%s'%(prefix, category, len(tag.name))) else: name = original_name(tag) - use_prefix = tag.is_hierarchical + use_prefix = tag.state in [TAG_SEARCH_STATES['mark_plusplus'], + TAG_SEARCH_STATES['mark_minusminus']] if category == 'tags': if name in tags_seen: continue diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index e626d446d2..0335c1d280 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -419,28 +419,23 @@ class ResultCache(SearchQueryParser): # {{{ def get_user_category_matches(self, location, query, candidates): res = set([]) - if self.db_prefs is None: + if self.db_prefs is None or len(query) < 2: return res user_cats = self.db_prefs.get('user_categories', []) c = set(candidates) - l = location.rfind('.') - if l > 0: - alt_loc = location[0:l] - alt_item = location[l+1:] + + if query.startswith('.'): + check_subcats = True + query = query[1:] else: - alt_loc = None + check_subcats = False + for key in user_cats: - if key == location or key.startswith(location + '.'): + if key == location or (check_subcats and key.startswith(location + '.')): for (item, category, ign) in user_cats[key]: s = self.get_matches(category, '=' + item, candidates=c) c -= s res |= s - elif key == alt_loc: - for (item, category, ign) in user_cats[key]: - if item == alt_item: - s = self.get_matches(category, '=' + item, candidates=c) - c -= s - res |= s if query == 'false': return candidates - res return res diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 4be2ba4340..8e90fe77bd 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1195,7 +1195,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): i += 1 else: new_cats['.'.join(comps)] = user_cats[k] - self.prefs.set('user_categories', new_cats) + try: + self.prefs.set('user_categories', new_cats) + except: + pass return new_cats def get_categories(self, sort='name', ids=None, icon_map=None): @@ -1500,18 +1503,30 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ############# End get_categories - def tags_older_than(self, tag, delta): + def tags_older_than(self, tag, delta, must_have_tag=None): + ''' + Return the ids of all books having the tag ``tag`` that are older than + than the specified time. tag comparison is case insensitive. + + :param delta: A timedelta object or None. If None, then all ids with + the tag are returned. + :param must_have_tag: If not None the list of matches will be + restricted to books that have this tag + ''' tag = tag.lower().strip() + mht = must_have_tag.lower().strip() if must_have_tag else None now = nowf() tindex = self.FIELD_MAP['timestamp'] gindex = self.FIELD_MAP['tags'] + iindex = self.FIELD_MAP['id'] for r in self.data._data: if r is not None: - if (now - r[tindex]) > delta: + if delta is None or (now - r[tindex]) > delta: tags = r[gindex] - if tags and tag in [x.strip() for x in - tags.lower().split(',')]: - yield r[self.FIELD_MAP['id']] + if tags: + tags = [x.strip() for x in tags.lower().split(',')] + if tag in tags and (mht is None or mht in tags): + yield r[iindex] def get_next_series_num_for(self, series): series_id = self.conn.get('SELECT id from series WHERE name=?', diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst index 882e3d5921..210bd0569e 100644 --- a/src/calibre/manual/gui.rst +++ b/src/calibre/manual/gui.rst @@ -436,25 +436,26 @@ Tag Browser .. image:: images/tag_browser.png :class: float-left-img -The Tag Browser allows you to easily browse your collection by Author/Tags/Series/etc. If you click on any Item in the Tag Browser, for example, the Author name, Isaac Asimov, then the list of books to the right is restricted to books by that author. Clicking once again on Isaac Asimov will restrict the list of books to books not by Isaac Asimov. A third click will remove the restriction. If you hold down the Ctrl or Shift keys and click on multiple items, then restrictions based on multiple items are created. For example you could Hold Ctrl and click on the tags History and Europe for find books on European history. The Tag Browser works by constructing search expressions that are automatically entered into the Search bar. It is a good way to learn how to construct basic search expressions. +The Tag Browser allows you to easily browse your collection by Author/Tags/Series/etc. If you click on any item in the Tag Browser, for example the author name Isaac Asimov, then the list of books to the right is restricted to showing books by that author. You can click on category names as well. For example, clicking on "Series" will show you all books in any series. -There is a search bar at the top of the Tag Browser that allows you to easily find any item in the Tag Browser. In addition, you can right click on any item and choose to hide it or rename it or open a "Manage x" dialog that allows you to manage items of that kind. For example the "Manage Authors" dialog allows you to rename authors and control how their names are sorted. +The first click on an item will restrict the list of books to those that contain/match the item. Continuing the above example, clicking on Isaac Asimov will show books by that author. Clicking again on the item will change what is shown, depending on whether the item has children (see sub-categories and hierarchical items below). Continuing the Isaac Asimov example, clicking again on Isaac Asimov will restrict the list of books to those not by Isaac Asimov. A third click will remove the restriction, showing all books. If you hold down the Ctrl or Shift keys and click on multiple items, then restrictions based on multiple items are created. For example you could hold Ctrl and click on the tags History and Europe for find books on European history. The Tag Browser works by constructing search expressions that are automatically entered into the Search bar. Looking at what the Tag Browser generates is a good way to learn how to construct basic search expressions. Items in the Tag browser have their icons partially colored. The amount of color depends on the average rating of the books in that category. So for example if the books by Isaac Asimov have an average of four stars, the icon for Isaac Asimov in the Tag Browser will be 4/5th colored. You can hover your mouse over the icon to see the average rating. -For convenience, you can drag and drop books from the book list to items in the Tag Browser and that item will be automatically applied to the dropped books. For example, dragging a book to Isaac Asimov will set the author of that book to Isaac Asimov or dragging it to the tag History will add the tag History to its tags. +The outer-level items in the tag browser such as Authors and Series are called categories. You can create your own categories, called User Categories, which are useful for organizing items. For example, you can use the User Categories Editor (push the Manage User Categories button) to create a user category called Favorite Authors, then put the items for your favorites into the category. User categories can have sub-categories. For example, the user category Favorites.Authors is a sub-category of Favorites. You might also have Favorites.Series, in which case there will be two sub-categories under Favorites. Sub-categories can be created by right-clicking on a user category, choosing "Add sub-category to ...", and entering the sub-category name; or by using the User Categories Editor by entering names like the Favorites example above. -The outer-level items in the tag browser such as Authors and Series are called categories. You can create your own categories, called User Categories, which are useful for organizing items. For example, you can use the user categories editor (push the Manage User Categories button) to create a user category called Favorite Authors, then put the items for your favorites into the category. User categories act like built-in categories; you can click on items to search for them. You can search for all items in a category by right-clicking on the category name and choosing "Search for books in ...". +You can search user categories in the same way as built-in categories, by clicking on them. There are four different searches cycled through by clicking: "everything matching an item in the category" indicated by a single green plus sign, "everything matching an item in the category or its sub-categories" indicated by two green plus signs, "everything not matching an item in the category" shown by a single red minus sign, and "everything not matching an item in the category or its sub-categories" shown by two red minus signs. -User categories can have sub-categories. For example, the user category Favorites.Authors is a sub-category of Favorites. You might also have Favorites.Series, in which case there will be two sub-categories under Favorites. Sub-categories can be created using Manage User Categories by entering names like the Favorites example. They can also be created by right-clicking on a user category, choosing "Add sub-category to ...", and entering the category name. +It is also possible to create hierarchies inside some of the text categories such as tags, series, and custom columns. These hierarchies show with the small triangle, permitting the sub-items to be hidden. To use hierarchies of items in a category, you must first go to Preferences / Look & Feel and enter the category name(s) into the "Categories with hierarchical items" box. Once this is done, items in that category that contain periods will be shown using the small triangle. For example, assume you create a custom column called "Genre" and indicate that it contains hierarchical items. Once done, items such as Mystery.Thriller and Mystery.English will display as Mystery with the small triangle next to it. Clicking on the triangle will show Thriller and English as sub-items. -It is also possible to create hierarchies inside some of the built-in categories (the text categories). These hierarchies show with the small triangle permitting the sub-items to be hidden. To use hierarchies in a category, you must first go to Preferences / Look & Feel and enter the category name(s) into the "Categories with hierarchical items" box. Once this is done, items in that category that contain periods will be shown using the small triangle. For example, assume you create a custom column called "Genre" and indicate that it contains hierarchical items. Once done, items such as Mystery.Thriller and Mystery.English will display as Mystery with the small triangle next to it. Clicking on the triangle will show Thriller and English as sub-items. +Hierarchical items (items with children) use the same four 'click-on' searches as user categories. Items that do not have children use two of the searches: "everything matching" and "everything not matching". -You can drag and drop items in the Tag browser onto user categories to add them to that category. +You can drag and drop items in the Tag browser onto user categories to add them to that category. If the source is a user category, holding the shift key while dragging will move the item to the new category. You can also drag and drop books from the book list onto items in the Tag Browser; dropping a book on an item causes that item to be automatically applied to the dropped books. For example, dragging a book onto Isaac Asimov will set the author of that book to Isaac Asimov. Dropping it onto the tag History will add the tag History to the book's tags. + +There is a search bar at the top of the Tag Browser that allows you to easily find any item in the Tag Browser. In addition, you can right click on any item and choose one of several operations. Some examples are to hide the it, rename it, or open a "Manage x" dialog that allows you to manage items of that kind. For example, the "Manage Authors" dialog allows you to rename authors and control how their names are sorted. You can control how items are sorted in the Tag browser via the box at the bottom of the Tag Browser. You can choose to sort by name, average rating or popularity (popularity is the number of books with an item in your library; for example; the popularity of Isaac Asimov is the number of book sin your library by Isaac Asimov). - Jobs ----- .. image:: images/jobs.png diff --git a/src/calibre/web/feeds/recipes/collection.py b/src/calibre/web/feeds/recipes/collection.py index 5dd360213b..cd5a220dc3 100644 --- a/src/calibre/web/feeds/recipes/collection.py +++ b/src/calibre/web/feeds/recipes/collection.py @@ -201,12 +201,14 @@ class SchedulerConfig(object): self.root.append(sr) self.write_scheduler_file() - def customize_recipe(self, urn, add_title_tag, custom_tags): + # 'keep_issues' argument for recipe-specific number of copies to keep + def customize_recipe(self, urn, add_title_tag, custom_tags, keep_issues): with self.lock: for x in list(self.iter_customization()): if x.get('id') == urn: self.root.remove(x) cs = E.recipe_customization({ + 'keep_issues' : keep_issues, 'id' : urn, 'add_title_tag' : 'yes' if add_title_tag else 'no', 'custom_tags' : ','.join(custom_tags), @@ -317,16 +319,18 @@ class SchedulerConfig(object): return x.get('username', ''), x.get('password', '') def get_customize_info(self, urn): + keep_issues = 0 add_title_tag = True custom_tags = [] with self.lock: for x in self.iter_customization(): if x.get('id', False) == urn: + keep_issues = x.get('keep_issues', '0') add_title_tag = x.get('add_title_tag', 'yes') == 'yes' custom_tags = [i.strip() for i in x.get('custom_tags', '').split(',')] break - return add_title_tag, custom_tags + return add_title_tag, custom_tags, keep_issues def get_schedule_info(self, urn): with self.lock: diff --git a/src/calibre/web/feeds/recipes/model.py b/src/calibre/web/feeds/recipes/model.py index 559a5c08dd..553fdcc3c3 100644 --- a/src/calibre/web/feeds/recipes/model.py +++ b/src/calibre/web/feeds/recipes/model.py @@ -196,6 +196,7 @@ class RecipeModel(QAbstractItemModel, SearchQueryParser): lang_map = {} self.all_urns = set([]) self.showing_count = 0 + self.builtin_count = 0 for x in self.custom_recipe_collection: urn = x.get('id') self.all_urns.add(urn) @@ -211,6 +212,7 @@ class RecipeModel(QAbstractItemModel, SearchQueryParser): lang_map[lang] = factory(NewsCategory, new_root, lang) factory(NewsItem, lang_map[lang], urn, x.get('title')) self.showing_count += 1 + self.builtin_count += 1 for x in self.scheduler_config.iter_recipes(): urn = x.get('id') if urn not in self.all_urns: @@ -354,9 +356,9 @@ class RecipeModel(QAbstractItemModel, SearchQueryParser): self.scheduler_config.schedule_recipe(self.recipe_from_urn(urn), sched_type, schedule) - def customize_recipe(self, urn, add_title_tag, custom_tags): + def customize_recipe(self, urn, add_title_tag, custom_tags, keep_issues): self.scheduler_config.customize_recipe(urn, add_title_tag, - custom_tags) + custom_tags, keep_issues) def get_to_be_downloaded_recipes(self): ans = self.scheduler_config.get_to_be_downloaded_recipes()