diff --git a/resources/images/news/sarajevo_x.png b/resources/images/news/sarajevo_x.png new file mode 100644 index 0000000000..07806831f7 Binary files /dev/null and b/resources/images/news/sarajevo_x.png differ diff --git a/resources/recipes/newsweek.recipe b/resources/recipes/newsweek.recipe index 7a53c23e45..73837c1872 100644 --- a/resources/recipes/newsweek.recipe +++ b/resources/recipes/newsweek.recipe @@ -1,189 +1,76 @@ -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal ' - -import re -from calibre import strftime -from calibre.ebooks.BeautifulSoup import BeautifulSoup +import string from calibre.web.feeds.news import BasicNewsRecipe class Newsweek(BasicNewsRecipe): - title = 'Newsweek' - __author__ = 'Kovid Goyal and Sujata Raman' + __author__ = 'Kovid Goyal' description = 'Weekly news and current affairs in the US' + language = 'en' + encoding = 'utf-8' no_stylesheets = True - extra_css = ''' - h1{font-family:Arial,Helvetica,sans-serif; font-size:large; color:#383733;} - .deck{font-family:Georgia,sans-serif; color:#383733;} - .bylineDate{font-family:georgia ; color:#58544A; font-size:x-small;} - .authorInfo{font-family:arial,helvetica,sans-serif; color:#0066CC; font-size:x-small;} - .articleUpdated{font-family:arial,helvetica,sans-serif; color:#73726C; font-size:x-small;} - .issueDate{font-family:arial,helvetica,sans-serif; color:#73726C; font-size:x-small; font-style:italic;} - h5{font-family:arial,helvetica,sans-serif; color:#73726C; font-size:x-small;} - h6{font-family:arial,helvetica,sans-serif; color:#73726C; font-size:x-small;} - .story{font-family:georgia,sans-serif ;color:black;} - .photoCredit{color:#999999; font-family:Arial,Helvetica,sans-serif;font-size:x-small;} - .photoCaption{color:#0A0A09;font-family:Arial,Helvetica,sans-serif;font-size:x-small;} - .fwArticle{font-family:Arial,Helvetica,sans-serif;font-size:x-small;font-weight:bold;} - ''' + BASE_URL = 'http://www.newsweek.com' + INDEX = BASE_URL+'/topics.html' - encoding = 'utf-8' - language = 'en' + keep_only_tags = dict(name='article', attrs={'class':'article-text'}) + remove_tags = [dict(attrs={'data-dartad':True})] + remove_attributes = ['property'] - remove_tags = [ - {'class':['fwArticle noHr','fwArticle','hdlBulletItem','head-content','navbar','link', 'ad', 'sponsorLinksArticle', 'mm-content', - 'inline-social-links-wrapper', 'email-article','ToolBox', - 'inline-promo-link', 'sponsorship', - 'inlineComponentRight', - 'comments-and-social-links-wrapper', 'EmailArticleBlock']}, - {'id' : ['footer', 'ticker-data', 'topTenVertical', - 'digg-top-five', 'mesothorax', 'nw-comments', 'my-take-landing', - 'ToolBox', 'EmailMain']}, - {'class': re.compile('related-cloud')}, - dict(name='li', attrs={'id':['slug_bigbox']}) - ] + def postprocess_html(self, soup, first): + for tag in soup.findAll(name=['article', 'header']): + tag.name = 'div' + return soup + + def newsweek_sections(self): + soup = self.index_to_soup(self.INDEX) + for a in soup.findAll('a', title='Primary tag', href=True): + yield (string.capitalize(self.tag_to_string(a)), + self.BASE_URL+a['href']) - keep_only_tags = [{'class':['article HorizontalHeader', - 'articlecontent','photoBox', 'article columnist first']}, ] - recursions = 1 - match_regexps = [r'http://www.newsweek.com/id/\S+/page/\d+'] - preprocess_regexps = [(re.compile(r'', re.DOTALL), lambda m: '')] - - def find_title(self, section): - d = {'scope':'Scope', 'thetake':'The Take', 'features':'Features', - None:'Departments', 'culture':'Culture'} - ans = None - a = section.find('a', attrs={'name':True}) - if a is not None: - ans = a['name'] - return d.get(ans, ans) - - - def find_articles(self, section): - ans = [] - for x in section.findAll('h5'): - title = ' '.join(x.findAll(text=True)).strip() - a = x.find('a') - if not a: continue - href = a['href'] - ans.append({'title':title, 'url':href, 'description':'', 'date': strftime('%a, %d %b')}) - if not ans: - for x in section.findAll('div', attrs={'class':'hdlItem'}): - a = x.find('a', href=True) - if not a : continue - title = ' '.join(a.findAll(text=True)).strip() - href = a['href'] - if 'http://xtra.newsweek.com' in href: continue - ans.append({'title':title, 'url':href, 'description':'', 'date': strftime('%a, %d %b')}) - - #for x in ans: - # x['url'] += '/output/print' - return ans + def newsweek_parse_section_page(self, soup): + for article in soup.findAll('article', about=True, + attrs={'class':'stream-item'}): + title = article.find(attrs={'property': 'dc:title'}) + if title is None: continue + title = self.tag_to_string(title) + url = self.BASE_URL + article['about'] + desc = '' + author = article.find({'property':'dc:creator'}) + if author: + desc = u'by %s. '%self.tag_to_string(author) + p = article.find(attrs={'property':'dc:abstract'}) + if p is not None: + for a in p.find('a'): a.extract() + desc += self.tag_to_string(p) + t = article.find('time', attrs={'property':'dc:created'}) + date = '' + if t is not None: + date = u' [%s]'%self.tag_to_string(t) + self.log('\tFound article:', title, 'at', url) + self.log('\t\t', desc) + yield {'title':title, 'url':url, 'description':desc, 'date':date} def parse_index(self): - soup = self.get_current_issue() - if not soup: - raise RuntimeError('Unable to connect to newsweek.com. Try again later.') - sections = soup.findAll('div', attrs={'class':'featurewell'}) - titles = map(self.find_title, sections) - articles = map(self.find_articles, sections) - ans = list(zip(titles, articles)) - def fcmp(x, y): - tx, ty = x[0], y[0] - if tx == "Features": return cmp(1, 2) - if ty == "Features": return cmp(2, 1) - return cmp(tx, ty) - return sorted(ans, cmp=fcmp) - - def ensure_html(self, soup): - root = soup.find(name=True) - if root.name == 'html': return soup - nsoup = BeautifulSoup('') - nroot = nsoup.find(name='body') - for x in soup.contents: - if getattr(x, 'name', False): - x.extract() - nroot.insert(len(nroot), x) - return nsoup - - def postprocess_html(self, soup, first_fetch): - if not first_fetch: - h1 = soup.find(id='headline') - if h1: - h1.extract() - div = soup.find(attrs={'class':'articleInfo'}) - if div: - div.extract() - divs = list(soup.findAll('div', 'pagination')) - if not divs: - return self.ensure_html(soup) - for div in divs[1:]: div.extract() - all_a = divs[0].findAll('a', href=True) - divs[0]['style']="display:none" - if len(all_a) > 1: - all_a[-1].extract() - test = re.compile(self.match_regexps[0]) - for a in soup.findAll('a', href=test): - if a not in all_a: - del a['href'] - return self.ensure_html(soup) - - def get_current_issue(self): - soup = self.index_to_soup('http://www.newsweek.com') - div = soup.find('div', attrs={'class':re.compile('more-from-mag')}) - if div is None: return None - a = div.find('a') - if a is not None: - href = a['href'].split('#')[0] - return self.index_to_soup(href) - - def get_cover_url(self): - cover_url = None - soup = self.index_to_soup('http://www.newsweek.com') - link_item = soup.find('div',attrs={'class':'cover-image'}) - if link_item and link_item.a and link_item.a.img: - cover_url = link_item.a.img['src'] - return cover_url + sections = [] + for section, shref in self.newsweek_sections(): + self.log('Processing section', section, shref) + articles = [] + soups = [self.index_to_soup(shref)] + na = soups[0].find('a', rel='next') + if na: + soups.append(self.index_to_soup(self.BASE_URL+na['href'])) + for soup in soups: + articles.extend(self.newsweek_parse_section_page(soup)) + if self.test and len(articles) > 1: + break + if articles: + sections.append((section, articles)) + if self.test and len(sections) > 1: + break + return sections - def postprocess_book(self, oeb, opts, log) : - - def extractByline(href) : - soup = BeautifulSoup(str(oeb.manifest.hrefs[href])) - byline = soup.find(True,attrs={'class':'authorInfo'}) - byline = self.tag_to_string(byline) if byline is not None else '' - issueDate = soup.find(True,attrs={'class':'issueDate'}) - issueDate = self.tag_to_string(issueDate) if issueDate is not None else '' - issueDate = re.sub(',','', issueDate) - if byline > '' and issueDate > '' : - return byline + ' | ' + issueDate - else : - return byline + issueDate - - def extractDescription(href) : - soup = BeautifulSoup(str(oeb.manifest.hrefs[href])) - description = soup.find(True,attrs={'name':'description'}) - if description is not None and description.has_key('content'): - description = description['content'] - if description.startswith('Newsweek magazine online plus') : - description = soup.find(True, attrs={'class':'story'}) - firstPara = soup.find('p') - description = self.tag_to_string(firstPara) - else : - description = soup.find(True, attrs={'class':'story'}) - firstPara = soup.find('p') - description = self.tag_to_string(firstPara) - return description - - for section in oeb.toc : - for article in section : - if article.author is None : - article.author = extractByline(article.href) - if article.description is None : - article.description = extractDescription(article.href) - return diff --git a/resources/recipes/nytimes.recipe b/resources/recipes/nytimes.recipe index 33758e8c47..eba717027e 100644 --- a/resources/recipes/nytimes.recipe +++ b/resources/recipes/nytimes.recipe @@ -391,10 +391,14 @@ class NYTimes(BasicNewsRecipe): return ans def preprocess_html(self, soup): - # Skip ad pages before actual article + # Skip ad pages served before actual article skip_tag = soup.find(True, {'name':'skip'}) if skip_tag is not None: - soup = self.index_to_soup(skip_tag.parent['href']) + self.log.error("Found forwarding link: %s" % skip_tag.parent['href']) + url = 'http://www.nytimes.com' + re.sub(r'\?.*', '', skip_tag.parent['href']) + url += '?pagewanted=all' + self.log.error("Skipping ad to article at '%s'" % url) + soup = self.index_to_soup(url) return self.strip_anchors(soup) def postprocess_html(self,soup, True): diff --git a/resources/recipes/nytimes_sub.recipe b/resources/recipes/nytimes_sub.recipe index 79c0d49223..c08b06572d 100644 --- a/resources/recipes/nytimes_sub.recipe +++ b/resources/recipes/nytimes_sub.recipe @@ -280,18 +280,14 @@ class NYTimes(BasicNewsRecipe): return ans def preprocess_html(self, soup): - ''' - refresh = soup.find('meta', {'http-equiv':'refresh'}) - if refresh is None: - return soup - content = refresh.get('content').partition('=')[2] - raw = self.browser.open('http://www.nytimes.com'+content).read() - return BeautifulSoup(raw.decode('cp1252', 'replace')) - ''' - # Skip ad pages before actual article + # Skip ad pages served before actual article skip_tag = soup.find(True, {'name':'skip'}) if skip_tag is not None: - soup = self.index_to_soup(skip_tag.parent['href']) + self.log.error("Found forwarding link: %s" % skip_tag.parent['href']) + url = 'http://www.nytimes.com' + re.sub(r'\?.*', '', skip_tag.parent['href']) + url += '?pagewanted=all' + self.log.error("Skipping ad to article at '%s'" % url) + soup = self.index_to_soup(url) return self.strip_anchors(soup) def postprocess_html(self,soup, True): diff --git a/resources/recipes/sarajevo_x.recipe b/resources/recipes/sarajevo_x.recipe new file mode 100644 index 0000000000..21d764405a --- /dev/null +++ b/resources/recipes/sarajevo_x.recipe @@ -0,0 +1,66 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Darko Miletic ' + +''' +sarajevo-x.com +''' + +import re +from calibre.web.feeds.recipes import BasicNewsRecipe +from calibre.ebooks.BeautifulSoup import Tag, NavigableString + +class SarajevoX(BasicNewsRecipe): + title = 'Sarajevo-x.com' + __author__ = 'Darko Miletic' + description = 'Sarajevo-x.com - najposjeceniji bosanskohercegovacki internet portal' + publisher = 'InterSoft d.o.o.' + category = 'news, politics, Bosnia and Herzegovina,Sarajevo-x.com, internet, portal, vijesti, bosna i hercegovina, sarajevo' + oldest_article = 2 + delay = 1 + max_articles_per_feed = 100 + no_stylesheets = True + encoding = 'cp1250' + use_embedded_content = False + language = 'bs' + extra_css = ' @font-face {font-family: "sans1";src:url(res:///opt/sony/ebook/FONT/tt0003m_.ttf)} body{font-family: Arial,Verdana,Helvetica,sans1,sans-serif} .article_description{font-family: sans1, sans-serif} div#fotka{display: block} img{margin-bottom: 0.5em} ' + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language + } + + preprocess_regexps = [(re.compile(u'\u0110'), lambda match: u'\u00D0')] + + keep_only_tags = [dict(name='div', attrs={'class':'content-bg'})] + remove_tags_after = dict(name='div',attrs={'class':'izvor'}) + remove_tags = [dict(name=['object','link','base','table'])] + remove_attributes = ['height','width','alt','border'] + + feeds = [ + (u'BIH' , u'http://www.sarajevo-x.com/rss/bih' ) + ,(u'Svijet' , u'http://www.sarajevo-x.com/rss/svijet' ) + ,(u'Biznis' , u'http://www.sarajevo-x.com/rss/biznis' ) + ,(u'Sport' , u'http://www.sarajevo-x.com/rss/sport' ) + ,(u'Showtime' , u'http://www.sarajevo-x.com/rss/showtime' ) + ,(u'Scitech' , u'http://www.sarajevo-x.com/rss/scitech' ) + ,(u'Lifestyle' , u'http://www.sarajevo-x.com/rss/lifestyle' ) + ,(u'Kultura' , u'http://www.sarajevo-x.com/rss/kultura' ) + ,(u'Zanimljivosti', u'http://www.sarajevo-x.com/rss/zanimljivosti') + ] + + def preprocess_html(self, soup): + dtag = soup.find('div',attrs={'id':'fotka'}) + if dtag: + sp = soup.find('div',attrs={'id':'opisslike'}) + img = soup.find('img') + if sp: + sp + else: + mtag = Tag(soup,'div',[("id","opisslike"),("class","opscitech")]) + mopis = NavigableString("Opis") + mtag.insert(0,mopis) + img.append(mtag) + return soup + diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index 4eaaf3b90a..9a018231ef 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -29,7 +29,7 @@ class Plugin(object): ''' #: List of platforms this plugin works on - #: For example: ``['windows', 'osx', 'linux'] + #: For example: ``['windows', 'osx', 'linux']`` supported_platforms = [] #: The name of this plugin. You must set it something other @@ -214,10 +214,8 @@ class MetadataReaderPlugin(Plugin): Return metadata for the file represented by stream (a file like object that supports reading). Raise an exception when there is an error with the input data. - :param type: The type of file. Guaranteed to be one of the entries in :attr:`file_types`. - :return: A :class:`calibre.ebooks.metadata.MetaInformation` object ''' return None @@ -245,11 +243,9 @@ class MetadataWriterPlugin(Plugin): Set metadata for the file represented by stream (a file like object that supports reading). Raise an exception when there is an error with the input data. - :param type: The type of file. Guaranteed to be one of the entries in :attr:`file_types`. :param mi: A :class:`calibre.ebooks.metadata.MetaInformation` object - ''' pass diff --git a/src/calibre/customize/profiles.py b/src/calibre/customize/profiles.py index 84dff6052b..222f6128e0 100644 --- a/src/calibre/customize/profiles.py +++ b/src/calibre/customize/profiles.py @@ -240,6 +240,9 @@ class OutputProfile(Plugin): # Device supports displaying a nested TOC supports_nested_toc = True + # If True output should be optimized for a touchscreen interface + touchscreen = False + @classmethod def tags_to_string(cls, tags): return escape(', '.join(tags)) diff --git a/src/calibre/ebooks/conversion/preprocess.py b/src/calibre/ebooks/conversion/preprocess.py index 40c67453b2..7a7f362169 100644 --- a/src/calibre/ebooks/conversion/preprocess.py +++ b/src/calibre/ebooks/conversion/preprocess.py @@ -14,8 +14,14 @@ XMLDECL_RE = re.compile(r'^\s*<[?]xml.*?[?]>') SVG_NS = 'http://www.w3.org/2000/svg' XLINK_NS = 'http://www.w3.org/1999/xlink' -convert_entities = functools.partial(entity_to_unicode, exceptions=['quot', - 'apos', 'lt', 'gt', 'amp', '#60', '#62']) +convert_entities = functools.partial(entity_to_unicode, + result_exceptions = { + u'<' : '<', + u'>' : '>', + u"'" : ''', + u'"' : '"', + u'&' : '&', + }) _span_pat = re.compile('', re.DOTALL|re.IGNORECASE) LIGATURES = { diff --git a/src/calibre/ebooks/html/input.py b/src/calibre/ebooks/html/input.py index 413db1cc0b..6108aa329d 100644 --- a/src/calibre/ebooks/html/input.py +++ b/src/calibre/ebooks/html/input.py @@ -416,9 +416,9 @@ class HTMLInput(InputFormatPlugin): link = unquote(link).replace('/', os.sep) if not link.strip(): return link_ - if base and not os.path.isabs(link): - link = os.path.join(base, link) try: + if base and not os.path.isabs(link): + link = os.path.join(base, link) link = os.path.abspath(link) except: return link_ diff --git a/src/calibre/ebooks/lrf/objects.py b/src/calibre/ebooks/lrf/objects.py index 0045e679a3..8f69e94013 100644 --- a/src/calibre/ebooks/lrf/objects.py +++ b/src/calibre/ebooks/lrf/objects.py @@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal ' import struct, array, zlib, cStringIO, collections, re from calibre.ebooks.lrf import LRFParseError, PRS500_PROFILE -from calibre import entity_to_unicode +from calibre import entity_to_unicode, prepare_string_for_xml from calibre.ebooks.lrf.tags import Tag ruby_tags = { @@ -870,7 +870,7 @@ class Text(LRFStream): open_containers = collections.deque() for c in self.content: if isinstance(c, basestring): - s += c + s += prepare_string_for_xml(c) elif c is None: if open_containers: p = open_containers.pop() diff --git a/src/calibre/ebooks/metadata/html.py b/src/calibre/ebooks/metadata/html.py index d5aa9b8bef..45b592c709 100644 --- a/src/calibre/ebooks/metadata/html.py +++ b/src/calibre/ebooks/metadata/html.py @@ -11,7 +11,7 @@ import re from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.chardet import xml_to_unicode - +from calibre import entity_to_unicode def get_metadata(stream): src = stream.read() @@ -43,6 +43,10 @@ def get_metadata_(src, encoding=None): if match: author = match.group(2).replace(',', ';') + ent_pat = re.compile(r'&(\S+)?;') + title = ent_pat.sub(entity_to_unicode, title) + if author: + author = ent_pat.sub(entity_to_unicode, author) mi = MetaInformation(title, [author] if author else None) # Publisher diff --git a/src/calibre/ebooks/oeb/base.py b/src/calibre/ebooks/oeb/base.py index 79f9f15248..76e2cef3bb 100644 --- a/src/calibre/ebooks/oeb/base.py +++ b/src/calibre/ebooks/oeb/base.py @@ -787,7 +787,6 @@ class Manifest(object): data = self.oeb.decode(data) data = self.oeb.html_preprocessor(data) - # Remove DOCTYPE declaration as it messes up parsing # In particular, it causes tostring to insert xmlns # declarations, which messes up the coercing logic diff --git a/src/calibre/ebooks/oeb/transforms/cover.py b/src/calibre/ebooks/oeb/transforms/cover.py index ecdc1294ad..4d41ab14b4 100644 --- a/src/calibre/ebooks/oeb/transforms/cover.py +++ b/src/calibre/ebooks/oeb/transforms/cover.py @@ -136,6 +136,8 @@ class CoverManager(object): href = g['cover'].href else: href = self.default_cover() + if href is None: + return width, height = self.inspect_cover(href) if width is None or height is None: self.log.warning('Failed to read cover dimensions') diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 07a5e877b1..bddefe97f8 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -97,7 +97,8 @@ def _config(): help=_('Overwrite author and title with new metadata')) c.add_opt('enforce_cpu_limit', default=True, help=_('Limit max simultaneous jobs to number of CPUs')) - + c.add_opt('tag_browser_hidden_categories', default=set(), + help=_('tag browser categories not to display')) return ConfigProxy(c) config = _config() diff --git a/src/calibre/gui2/convert/regex_builder.py b/src/calibre/gui2/convert/regex_builder.py index 58e1d1ae45..6fa0fa5fe4 100644 --- a/src/calibre/gui2/convert/regex_builder.py +++ b/src/calibre/gui2/convert/regex_builder.py @@ -14,6 +14,7 @@ from calibre.gui2.convert.regex_builder_ui import Ui_RegexBuilder from calibre.gui2.convert.xexp_edit_ui import Ui_Form as Ui_Edit from calibre.gui2 import error_dialog, choose_files from calibre.ebooks.oeb.iterator import EbookIterator +from calibre.ebooks.conversion.preprocess import convert_entities from calibre.gui2.dialogs.choose_format import ChooseFormatDialog class RegexBuilder(QDialog, Ui_RegexBuilder): @@ -87,8 +88,10 @@ class RegexBuilder(QDialog, Ui_RegexBuilder): self.iterator = EbookIterator(pathtoebook) self.iterator.__enter__(only_input_plugin=True) text = [u''] + ent_pat = re.compile(r'&(\S+?);') for path in self.iterator.spine: html = open(path, 'rb').read().decode('utf-8', 'replace') + html = ent_pat.sub(convert_entities, html) text.append(html) self.preview.setPlainText('\n---\n'.join(text)) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index d3c2e4f10f..181d0c784b 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -1123,12 +1123,12 @@ class DeviceGUI(object): if cache: if id in cache['db_ids']: loc[i] = True - break + continue if mi.authors and \ re.sub('(?u)\W|[_]', '', authors_to_string(mi.authors).lower()) \ in cache['authors']: loc[i] = True - break + continue return loc def set_books_in_library(self, booklists, reset=False): diff --git a/src/calibre/gui2/dialogs/config/__init__.py b/src/calibre/gui2/dialogs/config/__init__.py index 5e10ea1621..5d855b5263 100644 --- a/src/calibre/gui2/dialogs/config/__init__.py +++ b/src/calibre/gui2/dialogs/config/__init__.py @@ -14,7 +14,7 @@ from PyQt4.Qt import QDialog, QListWidgetItem, QIcon, \ from calibre.constants import iswindows, isosx from calibre.gui2.dialogs.config.config_ui import Ui_Dialog from calibre.gui2.dialogs.config.create_custom_column import CreateCustomColumn -from calibre.gui2 import choose_dir, error_dialog, config, \ +from calibre.gui2 import choose_dir, error_dialog, config, gprefs, \ ALL_COLUMNS, NONE, info_dialog, choose_files, \ warning_dialog, ResizableDialog, question_dialog from calibre.utils.config import prefs @@ -480,6 +480,8 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): self.opt_enforce_cpu_limit.setChecked(config['enforce_cpu_limit']) self.device_detection_button.clicked.connect(self.debug_device_detection) self.port.editingFinished.connect(self.check_port_value) + self.show_splash_screen.setChecked(gprefs.get('show_splash_screen', + True)) def check_port_value(self, *args): port = self.port.value() @@ -852,6 +854,7 @@ class ConfigDialog(ResizableDialog, Ui_Dialog): config['get_social_metadata'] = self.opt_get_social_metadata.isChecked() config['overwrite_author_title_metadata'] = self.opt_overwrite_author_title_metadata.isChecked() config['enforce_cpu_limit'] = bool(self.opt_enforce_cpu_limit.isChecked()) + gprefs['show_splash_screen'] = bool(self.show_splash_screen.isChecked()) fmts = [] for i in range(self.viewer.count()): if self.viewer.item(i).checkState() == Qt.Checked: diff --git a/src/calibre/gui2/dialogs/config/config.ui b/src/calibre/gui2/dialogs/config/config.ui index 5d84e2e2af..db748dae7e 100644 --- a/src/calibre/gui2/dialogs/config/config.ui +++ b/src/calibre/gui2/dialogs/config/config.ui @@ -331,8 +331,8 @@ - - + + Use &Roman numerals for series number @@ -342,28 +342,35 @@ - + Enable system &tray icon (needs restart) - + Show &notifications in system tray - + + + + Show &splash screen at startup + + + + Show cover &browser in a separate window (needs restart) - + Search as you type @@ -373,21 +380,21 @@ - + Automatically send downloaded &news to ebook reader - + &Delete news from library when it is automatically sent to reader - + @@ -404,7 +411,7 @@ - + Toolbar @@ -452,7 +459,7 @@ - + @@ -527,12 +534,12 @@ - - ... - Add a user-defined column + + ... + :/images/plus.svg:/images/plus.svg diff --git a/src/calibre/gui2/dialogs/saved_search_editor.py b/src/calibre/gui2/dialogs/saved_search_editor.py new file mode 100644 index 0000000000..a9382201b9 --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.py @@ -0,0 +1,86 @@ +__license__ = 'GPL v3' + +__copyright__ = '2008, Kovid Goyal ' + + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem + +from calibre.gui2.dialogs.saved_search_editor_ui import Ui_SavedSearchEditor +from calibre.utils.config import prefs +from calibre.utils.search_query_parser import saved_searches +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.constants import islinux + +class SavedSearchEditor(QDialog, Ui_SavedSearchEditor): + + def __init__(self, window, initial_search=None): + QDialog.__init__(self, window) + Ui_SavedSearchEditor.__init__(self) + self.setupUi(self) + + self.connect(self.add_search_button, SIGNAL('clicked()'), self.add_search) + self.connect(self.search_name_box, SIGNAL('currentIndexChanged(int)'), + self.current_index_changed) + self.connect(self.delete_search_button, SIGNAL('clicked()'), self.del_search) + + self.current_search_name = None + self.searches = {} + self.searches_to_delete = [] + for name in saved_searches.names(): + self.searches[name] = saved_searches.lookup(name) + + self.populate_search_list() + if initial_search is not None and initial_search in self.searches: + self.select_search(initial_search) + + def populate_search_list(self): + self.search_name_box.clear() + for name in sorted(self.searches.keys()): + self.search_name_box.addItem(name) + + def add_search(self): + search_name = unicode(self.input_box.text()).strip() + if search_name == '': + return False + if search_name not in self.searches: + self.searches[search_name] = '' + self.populate_search_list() + self.select_search(search_name) + else: + self.select_search(search_name) + return True + + def del_search(self): + if self.current_search_name is not None: + if not confirm('

'+_('The current saved search will be ' + 'permanently deleted. Are you sure?') + +'

', 'saved_search_editor_delete', self): + return + del self.searches[self.current_search_name] + self.searches_to_delete.append(self.current_search_name) + self.current_search_name = None + self.search_name_box.removeItem(self.search_name_box.currentIndex()) + + def select_search(self, name): + self.search_name_box.setCurrentIndex(self.search_name_box.findText(name)) + + def current_index_changed(self, idx): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + name = unicode(self.search_name_box.itemText(idx)) + if name: + self.current_search_name = name + self.search_text.setPlainText(self.searches[name]) + else: + self.current_search_name = None + self.search_text.setPlainText('') + + def accept(self): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + for name in self.searches_to_delete: + saved_searches.delete(name) + for name in self.searches: + saved_searches.add(name, self.searches[name]) + QDialog.accept(self) diff --git a/src/calibre/gui2/dialogs/saved_search_editor.ui b/src/calibre/gui2/dialogs/saved_search_editor.ui new file mode 100644 index 0000000000..6d98d25667 --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.ui @@ -0,0 +1,185 @@ + + + SavedSearchEditor + + + + 0 + 0 + 548 + 148 + + + + Saved Search Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + Saved Search: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + search_name_box + + + + + + + + 160 + 0 + + + + + 145 + 0 + + + + Select a saved search to edit + + + false + + + + + + + Delete this selected saved search + + + ... + + + + :/images/minus.svg:/images/minus.svg + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 60 + 0 + + + + Enter a new saved search name. + + + + + + + Add the new saved search + + + ... + + + + :/images/plus.svg:/images/plus.svg + + + + + + + + + Change the contents of the saved search + + + + + + + + + + + buttonBox + accepted() + SavedSearchEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SavedSearchEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index fcf517e571..b7d64226ab 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -24,13 +24,12 @@ class Item: class TagCategories(QDialog, Ui_TagCategories): category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags'] - def __init__(self, window, db, index=None): + def __init__(self, window, db, on_category=None): QDialog.__init__(self, window) Ui_TagCategories.__init__(self) self.setupUi(self) self.db = db - self.index = index self.applied_items = [] cc_icon = QIcon(I('column.svg')) @@ -102,8 +101,10 @@ class TagCategories(QDialog, Ui_TagCategories): self.connect(self.applied_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) self.populate_category_list() - return - self.select_category(0) + if on_category is not None: + l = self.category_box.findText(on_category) + if l >= 0: + self.category_box.setCurrentIndex(l) def make_list_widget(self, item): n = item.name if item.exists else item.name + _(' (not on any book)') diff --git a/src/calibre/gui2/dialogs/tag_categories.ui b/src/calibre/gui2/dialogs/tag_categories.ui index 2904b2464e..d280d5061a 100644 --- a/src/calibre/gui2/dialogs/tag_categories.ui +++ b/src/calibre/gui2/dialogs/tag_categories.ui @@ -11,7 +11,7 @@
- Tag Editor + User Categories Editor diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py new file mode 100644 index 0000000000..c2cc1d7116 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -0,0 +1,89 @@ +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QListWidgetItem + +from calibre.gui2.dialogs.tag_list_editor_ui import Ui_TagListEditor +from calibre.gui2 import question_dialog, error_dialog + +class TagListEditor(QDialog, Ui_TagListEditor): + + def tag_cmp(self, x, y): + return cmp(x.lower(), y.lower()) + + def __init__(self, window, db, tag_to_match): + QDialog.__init__(self, window) + Ui_TagListEditor.__init__(self) + self.setupUi(self) + + self.to_rename = {} + self.to_delete = [] + self.db = db + self.all_tags = {} + for k,v in db.get_tags_with_ids(): + self.all_tags[v] = k + for tag in sorted(self.all_tags.keys(), cmp=self.tag_cmp): + item = QListWidgetItem(tag) + item.setData(Qt.UserRole, self.all_tags[tag]) + self.available_tags.addItem(item) + + items = self.available_tags.findItems(tag_to_match, Qt.MatchExactly) + if len(items) == 1: + self.available_tags.setCurrentItem(items[0]) + + self.connect(self.delete_button, SIGNAL('clicked()'), self.delete_tags) + self.connect(self.rename_button, SIGNAL('clicked()'), self.rename_tag) + self.connect(self.available_tags, SIGNAL('itemDoubleClicked(QListWidgetItem *)'), self._rename_tag) + self.connect(self.available_tags, SIGNAL('itemChanged(QListWidgetItem *)'), self.finish_editing) + + def finish_editing(self, item): + if item.text() != self.item_before_editing.text(): + if item.text() in self.all_tags.keys() or item.text() in self.to_rename.keys(): + error_dialog(self, 'Tag already used', + 'The tag %s is already used.'%(item.text())).exec_() + item.setText(self.item_before_editing.text()) + return + id,ign = self.item_before_editing.data(Qt.UserRole).toInt() + self.to_rename[item.text()] = id + + def rename_tag(self): + item = self.available_tags.currentItem() + self._rename_tag(item) + + def _rename_tag(self, item): + if item is None: + error_dialog(self, 'No tag selected', 'You must select one tag from the list of Available tags.').exec_() + return + self.item_before_editing = item.clone() + item.setFlags (item.flags() | Qt.ItemIsEditable); + self.available_tags.editItem(item) + + def delete_tags(self, item=None): + confirms, deletes = [], [] + items = self.available_tags.selectedItems() if item is None else [item] + if not items: + error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_() + return + for item in items: + if self.db.is_tag_used(unicode(item.text())): + confirms.append(item) + else: + deletes.append(item) + if confirms: + ct = ', '.join([unicode(item.text()) for item in confirms]) + if question_dialog(self, _('Are your sure?'), + '

'+_('The following tags are used by one or more books. ' + 'Are you certain you want to delete them?')+'
'+ct): + deletes += confirms + + for item in deletes: + self.to_delete.append(item) + self.available_tags.takeItem(self.available_tags.row(item)) + + def accept(self): + for text in self.to_rename: + self.db.rename_tag(self.to_rename[text], unicode(text)) + for item in self.to_delete: + self.db.delete_tag(unicode(item.text())) + QDialog.accept(self) + diff --git a/src/calibre/gui2/dialogs/tag_list_editor.ui b/src/calibre/gui2/dialogs/tag_list_editor.ui new file mode 100644 index 0000000000..383dc875ac --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.ui @@ -0,0 +1,163 @@ + + + TagListEditor + + + + 0 + 0 + 397 + 335 + + + + Tag Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + + + + + Tags in use + + + available_tags + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + Delete tag from database. This will unapply the tag from all books and then remove it from the database. + + + ... + + + + :/images/trash.svg:/images/trash.svg + + + + 32 + 32 + + + + + + + + Rename the tag everywhere it is used. + + + ... + + + + :/images/edit_input.svg:/images/edit_input.svg + + + + 32 + 32 + + + + Ctrl+S + + + + + + + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + TagListEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TagListEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index c47c821913..29ae1875c8 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -5,13 +5,15 @@ import sys, os, time, socket, traceback from functools import partial from PyQt4.Qt import QCoreApplication, QIcon, QMessageBox, QObject, QTimer, \ - QThread, pyqtSignal, Qt, QProgressDialog, QString + QThread, pyqtSignal, Qt, QProgressDialog, QString, QPixmap, \ + QSplashScreen, QApplication from calibre import prints, plugins -from calibre.constants import iswindows, __appname__, isosx, filesystem_encoding +from calibre.constants import iswindows, __appname__, isosx, DEBUG, \ + filesystem_encoding from calibre.utils.ipc import ADDRESS, RC from calibre.gui2 import ORG_NAME, APP_UID, initialize_file_icon_provider, \ - Application, choose_dir, error_dialog, question_dialog + Application, choose_dir, error_dialog, question_dialog, gprefs from calibre.gui2.main_window import option_parser as _option_parser from calibre.utils.config import prefs, dynamic from calibre.library.database2 import LibraryDatabase2 @@ -113,15 +115,25 @@ class GuiRunner(QObject): initialization''' def __init__(self, opts, args, actions, listener, app): + self.startup_time = time.time() self.opts, self.args, self.listener, self.app = opts, args, listener, app self.actions = actions self.main = None QObject.__init__(self) + self.splash_screen = None self.timer = QTimer.singleShot(1, self.initialize) + if DEBUG: + prints('Starting up...') def start_gui(self): from calibre.gui2.ui import Main - main = Main(self.library_path, self.db, self.listener, self.opts, self.actions) + main = Main(self.opts) + if self.splash_screen is not None: + self.splash_screen.showMessage(_('Initializing user interface...')) + self.splash_screen.finish(main) + main.initialize(self.library_path, self.db, self.listener, self.actions) + if DEBUG: + prints('Started up in', time.time() - self.startup_time) add_filesystem_book = partial(main.add_filesystem_book, allow_device=False) sys.excepthook = main.unhandled_exception if len(self.args) > 1: @@ -142,7 +154,7 @@ class GuiRunner(QObject): if db is None and tb is not None: # DB Repair failed - error_dialog(None, _('Repairing failed'), + error_dialog(self.splash_screen, _('Repairing failed'), _('The database repair failed. Starting with ' 'a new empty library.'), det_msg=tb, show=True) @@ -159,7 +171,7 @@ class GuiRunner(QObject): os.makedirs(x) except: x = os.path.expanduser('~') - candidate = choose_dir(None, 'choose calibre library', + candidate = choose_dir(self.splash_screen, 'choose calibre library', _('Choose a location for your new calibre e-book library'), default_dir=x) @@ -170,7 +182,7 @@ class GuiRunner(QObject): self.library_path = candidate db = LibraryDatabase2(candidate) except: - error_dialog(None, _('Bad database location'), + error_dialog(self.splash_screen, _('Bad database location'), _('Bad database location %r. calibre will now quit.' )%self.library_path, det_msg=traceback.format_exc(), show=True) @@ -184,7 +196,7 @@ class GuiRunner(QObject): try: db = LibraryDatabase2(self.library_path) except (sqlite.Error, DatabaseException): - repair = question_dialog(None, _('Corrupted database'), + repair = question_dialog(self.splash_screen, _('Corrupted database'), _('Your calibre database appears to be corrupted. Do ' 'you want calibre to try and repair it automatically? ' 'If you say No, a new empty calibre library will be created.'), @@ -203,14 +215,27 @@ class GuiRunner(QObject): self.repair.start() return except: - error_dialog(None, _('Bad database location'), + error_dialog(self.splash_screen, _('Bad database location'), _('Bad database location %r. Will start with ' ' a new, empty calibre library')%self.library_path, det_msg=traceback.format_exc(), show=True) self.initialize_db_stage2(db, None) + def show_splash_screen(self): + self.splash_pixmap = QPixmap() + self.splash_pixmap.load(I('library.png')) + self.splash_screen = QSplashScreen(self.splash_pixmap, + Qt.SplashScreen|Qt.WindowStaysOnTopHint) + self.splash_screen.showMessage(_('Starting %s: Loading books...') % + __appname__) + self.splash_screen.show() + QApplication.instance().processEvents() + def initialize(self, *args): + if gprefs.get('show_splash_screen', True): + self.show_splash_screen() + self.library_path = get_library_path() if self.library_path is None: self.initialization_failed() diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 15270e14b1..11db157ed4 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -8,10 +8,11 @@ Browsing book collection by tags. ''' from itertools import izip +from functools import partial from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QFont, QSize, QIcon, QPoint, \ - QAbstractItemModel, QVariant, QModelIndex + QAbstractItemModel, QVariant, QModelIndex, QMenu from calibre.gui2 import config, NONE from calibre.utils.config import prefs from calibre.library.field_metadata import TagsIcons @@ -19,9 +20,12 @@ from calibre.utils.search_query_parser import saved_searches class TagsView(QTreeView): # {{{ - need_refresh = pyqtSignal() - restriction_set = pyqtSignal(object) - tags_marked = pyqtSignal(object, object) + need_refresh = pyqtSignal() + restriction_set = pyqtSignal(object) + tags_marked = pyqtSignal(object, object) + user_category_edit = pyqtSignal(object) + tag_list_edit = pyqtSignal(object) + saved_search_edit = pyqtSignal(object) def __init__(self, *args): QTreeView.__init__(self, *args) @@ -31,13 +35,16 @@ class TagsView(QTreeView): # {{{ self.tag_match = None def set_database(self, db, tag_match, popularity, restriction): - self._model = TagsModel(db, parent=self) + self.hidden_categories = config['tag_browser_hidden_categories'] + self._model = TagsModel(db, parent=self, hidden_categories=self.hidden_categories) self.popularity = popularity self.restriction = restriction self.tag_match = tag_match self.db = db self.setModel(self._model) + self.setContextMenuPolicy(Qt.CustomContextMenu) self.clicked.connect(self.toggle) + self.customContextMenuRequested.connect(self.show_context_menu) self.popularity.setChecked(config['sort_by_popularity']) self.popularity.stateChanged.connect(self.sort_changed) self.restriction.activated[str].connect(self.search_restriction_set) @@ -45,10 +52,6 @@ class TagsView(QTreeView): # {{{ db.add_listener(self.database_changed) self.saved_searches_changed(recount=False) - def create_tag_category(self, name, tag_list): - self._model.create_tag_category(name, tag_list) - self.recount() - def database_changed(self, event, ids): self.need_refresh.emit() @@ -72,12 +75,87 @@ class TagsView(QTreeView): # {{{ self.recount() # Must happen after the emission of the restriction_set signal self.tags_marked.emit(self._model.tokens(), self.match_all) + def mouseReleaseEvent(self, event): + # Swallow everything except leftButton so context menus work correctly + if event.button() == Qt.LeftButton: + QTreeView.mouseReleaseEvent(self, event) + def toggle(self, index): modifiers = int(QApplication.keyboardModifiers()) exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) if self._model.toggle(index, exclusive): self.tags_marked.emit(self._model.tokens(), self.match_all) + def context_menu_handler(self, action=None, category=None): + if not action: + return + try: + if action == 'manage_tags': + self.tag_list_edit.emit(category) + return + if action == 'manage_categories': + self.user_category_edit.emit(category) + return + if action == 'manage_searches': + self.saved_search_edit.emit(category) + return + if action == 'hide': + self.hidden_categories.add(category) + elif action == 'show': + self.hidden_categories.discard(category) + elif action == 'defaults': + self.hidden_categories.clear() + config.set('tag_browser_hidden_categories', self.hidden_categories) + self.set_new_model() + except: + return + + def show_context_menu(self, point): + index = self.indexAt(point) + if not index.isValid(): + return False + item = index.internalPointer() + tag_name = '' + if item.type == TagTreeItem.TAG: + tag_name = item.tag.name + item = item.parent + if item.type == TagTreeItem.CATEGORY: + category = unicode(item.name.toString()) + self.context_menu = QMenu(self) + self.context_menu.addAction(_('Hide %s') % category, + partial(self.context_menu_handler, action='hide', category=category)) + + if self.hidden_categories: + self.context_menu.addSeparator() + m = self.context_menu.addMenu(_('Show category')) + for col in self.hidden_categories: + m.addAction(col, + partial(self.context_menu_handler, action='show', category=col)) + self.context_menu.addSeparator() + self.context_menu.addAction(_('Restore defaults'), + partial(self.context_menu_handler, action='defaults')) + + self.context_menu.addSeparator() + self.context_menu.addAction(_('Manage Tags'), + partial(self.context_menu_handler, action='manage_tags', + category=tag_name)) + + if category in prefs['user_categories'].keys(): + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=category)) + else: + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=None)) + + self.context_menu.addAction(_('Manage Saved Searches'), + partial(self.context_menu_handler, action='manage_searches', + category=tag_name)) + + self.context_menu.popup(self.mapToGlobal(point)) + return True + def clear(self): self.model().clear_state() @@ -110,13 +188,12 @@ class TagsView(QTreeView): # {{{ self.setCurrentIndex(idx) self.scrollTo(idx, QTreeView.PositionAtCenter) - ''' - If the number of user categories changed, or if custom columns have come or gone, - we must rebuild the model. Reason: it is much easier to do that than to reconstruct - the browser tree. - ''' + # If the number of user categories changed, if custom columns have come or + # gone, or if columns have been hidden or restored, we must rebuild the + # model. Reason: it is much easier than reconstructing the browser tree. def set_new_model(self): - self._model = TagsModel(self.db, parent=self) + self._model = TagsModel(self.db, parent=self, + hidden_categories=self.hidden_categories) self.setModel(self._model) # }}} @@ -200,7 +277,7 @@ class TagTreeItem(object): # {{{ class TagsModel(QAbstractItemModel): # {{{ - def __init__(self, db, parent=None): + def __init__(self, db, parent=None, hidden_categories=None): QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication @@ -220,6 +297,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db + self.hidden_categories = hidden_categories self.search_restriction = '' self.ignore_next_search = 0 @@ -237,6 +315,8 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() for i, r in enumerate(self.row_map): + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue if self.db.field_metadata[r]['kind'] != 'user': tt = _('The lookup/search name is "{0}"').format(r) else: @@ -271,12 +351,16 @@ class TagsModel(QAbstractItemModel): # {{{ def refresh(self): data = self.get_node_tree(config['sort_by_popularity']) # get category data + row_index = -1 for i, r in enumerate(self.row_map): - category = self.root_item.children[i] + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue + row_index += 1 + category = self.root_item.children[row_index] names = [t.tag.name for t in category.children] states = [t.tag.state for t in category.children] state_map = dict(izip(names, states)) - category_index = self.index(i, 0, QModelIndex()) + category_index = self.index(row_index, 0, QModelIndex()) if len(category.children) > 0: self.beginRemoveRows(category_index, 0, len(category.children)-1) @@ -401,16 +485,20 @@ class TagsModel(QAbstractItemModel): # {{{ def tokens(self): ans = [] tags_seen = set() + row_index = -1 for i, key in enumerate(self.row_map): + if self.hidden_categories and self.categories[i] in self.hidden_categories: + continue + row_index += 1 if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category continue - category_item = self.root_item.children[i] + category_item = self.root_item.children[row_index] for tag_item in category_item.children: tag = tag_item.tag if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' category = key if key != 'news' else 'tag' - if tag.name[0] == u'\u2605': # char is a star. Assume rating + 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: if category == 'tags': diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 8bc85e7195..773f44acd2 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -61,6 +61,8 @@ from calibre.library.database2 import LibraryDatabase2 from calibre.library.caches import CoverCache from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories +from calibre.gui2.dialogs.tag_list_editor import TagListEditor +from calibre.gui2.dialogs.saved_search_editor import SavedSearchEditor class SaveMenu(QMenu): @@ -127,13 +129,18 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): pixmap_to_data(pixmap)) self.last_time = datetime.datetime.now() - def __init__(self, library_path, db, listener, opts, actions, parent=None): + + def __init__(self, opts, parent=None): + MainWindow.__init__(self, opts, parent) + self.opts = opts + + def initialize(self, library_path, db, listener, actions): + opts = self.opts self.last_time = datetime.datetime.now() self.preferences_action, self.quit_action = actions self.library_path = library_path self.spare_servers = [] self.must_restart_before_config = False - MainWindow.__init__(self, opts, parent) # Initialize fontconfig in a separate thread as this can be a lengthy # process if run for the first time on this machine from calibre.utils.fonts import fontconfig @@ -537,19 +544,23 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.cover_cache = CoverCache(self.library_path) self.cover_cache.start() self.library_view.model().cover_cache = self.cover_cache - self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories) + self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_user_categories_edit) self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction) self.tags_view.tags_marked.connect(self.search.search_from_tags) for x in (self.saved_search.clear_to_help, self.mark_restriction_set): self.tags_view.restriction_set.connect(x) self.tags_view.tags_marked.connect(self.saved_search.clear_to_help) + self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) + self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.search.search.connect(self.tags_view.model().reinit) for x in (self.location_view.count_changed, self.tags_view.recount, self.restriction_count_changed): self.library_view.model().count_changed_signal.connect(x) self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared) - self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection) + self.connect(self.saved_search, SIGNAL('changed()'), + self.tags_view.saved_searches_changed, Qt.QueuedConnection) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) @@ -642,13 +653,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self._add_filesystem_book = Dispatcher(self.__add_filesystem_book) self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection) - def do_edit_categories(self): - d = TagCategories(self, self.library_view.model().db) + def do_user_categories_edit(self, on_category=None): + d = TagCategories(self, self.library_view.model().db, on_category) d.exec_() if d.result() == d.Accepted: self.tags_view.set_new_model() self.tags_view.recount() + def do_tags_list_edit(self, tag): + d = TagListEditor(self, self.library_view.model().db, tag) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.set_new_model() + self.tags_view.recount() + self.library_view.model().refresh() + + def do_saved_search_edit(self, search): + d = SavedSearchEditor(self, search) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.saved_searches_changed(recount=True) + self.saved_search.clear_to_help() + def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) self.search.setMaximumWidth(self.width()-150) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 93891ee92b..83c56c5395 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -17,7 +17,7 @@ from calibre.utils.config import tweaks from calibre.utils.date import parse_date, now, UNDEFINED_DATE from calibre.utils.search_query_parser import SearchQueryParser from calibre.utils.pyparsing import ParseException -# from calibre.library.field_metadata import FieldMetadata +from calibre.ebooks.metadata import title_sort class CoverCache(QThread): @@ -564,7 +564,8 @@ class ResultCache(SearchQueryParser): def seriescmp(self, x, y): sidx = self.FIELD_MAP['series'] try: - ans = cmp(self._data[x][sidx].lower(), self._data[y][sidx].lower()) + ans = cmp(title_sort(self._data[x][sidx].lower()), + title_sort(self._data[y][sidx].lower())) except AttributeError: # Some entries may be None ans = cmp(self._data[x][sidx], self._data[y][sidx]) if ans != 0: return ans diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 4107d327ce..0544293095 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -648,6 +648,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.conn.execute(st%dict(ltable='publishers', table='publishers', ltable_col='publisher')) self.conn.execute(st%dict(ltable='tags', table='tags', ltable_col='tag')) self.conn.execute(st%dict(ltable='series', table='series', ltable_col='series')) + for id_, tag in self.conn.get('SELECT id, name FROM tags', all=True): + if not tag.strip(): + self.conn.execute('DELETE FROM books_tags_link WHERE tag=?', + (id_,)) + self.conn.execute('DELETE FROM tags WHERE id=?', (id_,)) self.clean_custom() self.conn.commit() @@ -725,6 +730,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], icon=icon, tooltip = tooltip) for r in data if item_not_zero_func(r)] + if category == 'series': + categories[category].sort(cmp=lambda x,y:cmp(title_sort(x.name), + title_sort(y.name))) # We delayed computing the standard formats category because it does not # use a view, but is computed dynamically @@ -977,6 +985,20 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if notify: self.notify('metadata', [id]) + # Convenience method for tags_list_editor + def get_tags_with_ids(self): + result = self.conn.get('SELECT * FROM tags') + if not result: + return {} + r = [] + for k,v in result: + r.append((k,v)) + return r + + def rename_tag(self, id, new): + self.conn.execute('UPDATE tags SET name=? WHERE id=?', (new, id)) + self.conn.commit() + def get_tags(self, id): result = self.conn.get( 'SELECT name FROM tags WHERE id IN (SELECT tag FROM books_tags_link WHERE book=?)', diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py index 8638035c88..12bd786322 100644 --- a/src/calibre/library/server/content.py +++ b/src/calibre/library/server/content.py @@ -16,7 +16,7 @@ except ImportError: from calibre import fit_image, guess_type from calibre.utils.date import fromtimestamp - +from calibre.ebooks.metadata import title_sort class ContentServer(object): @@ -67,7 +67,7 @@ class ContentServer(object): def seriescmp(self, x, y): si = self.db.FIELD_MAP['series'] try: - ans = cmp(x[si].lower(), y[si].lower()) + ans = cmp(title_sort(x[si].lower()), title_sort(y[si].lower())) except AttributeError: # Some entries may be None ans = cmp(x[si], y[si]) if ans != 0: return ans diff --git a/src/calibre/manual/conversion.rst b/src/calibre/manual/conversion.rst index ee148c79c7..cd8abd0493 100644 --- a/src/calibre/manual/conversion.rst +++ b/src/calibre/manual/conversion.rst @@ -453,7 +453,7 @@ as HTML and then convert the resulting HTML file with |app|. When saving as HTML There is a Word macro package that can automate the conversion of Word documents using |app|. It also makes generating the Table of Contents much simpler. It is called BookCreator and is available for free -`here `_. +at `mobileread `_. Convert TXT documents ~~~~~~~~~~~~~~~~~~~~~~ @@ -493,7 +493,7 @@ TXT input supports a number of options to differentiate how paragraphs are detec allows for basic formatting to be added to TXT documents, such as bold, italics, section headings, tables, lists, a Table of Contents, etc. Marking chapter headings with a leading # and setting the chapter XPath detection expression to "//h:h1" is the easiest way to have a proper table of contents generated from a TXT document. - You can learn more about the markdown syntax `here `_. + You can learn more about the markdown syntax at `daringfireball `_. Convert PDF documents @@ -540,7 +540,7 @@ EPUB advanced formatting demo Various advanced formatting for EPUB files is demonstrated in this `demo file `_. The file was created from hand coded HTML using calibre and is meant to be used as a template for your own EPUB creation efforts. -The source HTML it was created from is available `here `_. The settings used to create the +The source HTML it was created from is available `demo.zip `_. The settings used to create the EPUB from the ZIP file are:: ebook-convert demo.zip .epub -vv --authors "Kovid Goyal" --language en --level1-toc '//*[@class="title"]' --disable-font-rescaling --page-breaks-before / --no-default-epub-cover diff --git a/src/calibre/manual/faq.rst b/src/calibre/manual/faq.rst index f7329fb54d..e606505194 100644 --- a/src/calibre/manual/faq.rst +++ b/src/calibre/manual/faq.rst @@ -133,7 +133,7 @@ Can I use the collections feature of the SONY reader? turned into a collection on the reader. Note that the PRS-500 does not support collections for books stored on the SD card. The PRS-505 does. How do I use |app| with my iPad/iPhone/iTouch? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can access your calibre library on a iPad/iPhone/iTouch over the air using the calibre content server. diff --git a/src/calibre/manual/news_recipe.rst b/src/calibre/manual/news_recipe.rst index 8f39cee387..c840cefb53 100644 --- a/src/calibre/manual/news_recipe.rst +++ b/src/calibre/manual/news_recipe.rst @@ -115,7 +115,7 @@ Pre/post processing of downloaded HTML .. automethod:: BasicNewsRecipe.postprocess_html - +.. automethod:: BasicNewsRecipe.populate_article_metadata Convenience methods diff --git a/src/calibre/utils/magick_draw.py b/src/calibre/utils/magick_draw.py index 0288107b45..5625da0869 100644 --- a/src/calibre/utils/magick_draw.py +++ b/src/calibre/utils/magick_draw.py @@ -51,6 +51,8 @@ class FontMetrics(object): def get_font_metrics(image, d_wand, text): + if isinstance(text, unicode): + text = text.encode('utf-8') ret = p.MagickQueryFontMetrics(image, d_wand, text) return FontMetrics(ret) diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py index efb24bcc9a..30282e3b2b 100644 --- a/src/calibre/web/feeds/news.py +++ b/src/calibre/web/feeds/news.py @@ -146,7 +146,7 @@ class BasicNewsRecipe(Recipe): #: If True empty feeds are removed from the output. #: This option has no effect if parse_index is overriden in #: the sub class. It is meant only for recipes that return a list - #: of feeds using :member:`feeds` or :method:`get_feeds`. + #: of feeds using `feeds` or :method:`get_feeds`. remove_empty_feeds = False #: List of regular expressions that determines which links to follow @@ -256,7 +256,7 @@ class BasicNewsRecipe(Recipe): #: The CSS that is used to style the templates, i.e., the navigation bars and #: the Tables of Contents. Rather than overriding this variable, you should - #: use :member:`extra_css` in your recipe to customize look and feel. + #: use `extra_css` in your recipe to customize look and feel. template_css = u''' .article_date { color: gray; font-family: monospace; @@ -506,7 +506,7 @@ class BasicNewsRecipe(Recipe): def get_obfuscated_article(self, url): ''' - If you set :member:`articles_are_obfuscated` this method is called with + If you set `articles_are_obfuscated` this method is called with every article URL. It should return the path to a file on the filesystem that contains the article HTML. That file is processed by the recursive HTML fetching engine, so it can contain links to pages/images on the web. @@ -517,20 +517,18 @@ class BasicNewsRecipe(Recipe): ''' raise NotImplementedError - def extract_author(self, soup): + def populate_article_metadata(self, article, soup, first): ''' - Parse downloaded articles for author, add to OEBBook object. - :param soup: + Called when each HTML page belonging to article is downloaded. + Intended to be used to get article metadata like author/summary/etc. + from the parsed HTML (soup). + :param article: A object of class :class:`calibre.web.feeds.Article`. + If you change the sumamry, remember to also change the + text_summary + :param soup: Parsed HTML belonging to this article + :param first: True iff the parsed HTML is the first page of the article. ''' - return None - - def extract_description(self, soup): - ''' - Parse downloaded articles for description, add to OEBBook object. - :param soup: - ''' - return None - + pass def postprocess_book(self, oeb, opts, log): ''' @@ -559,8 +557,8 @@ class BasicNewsRecipe(Recipe): self.username = options.username self.password = options.password self.lrf = options.lrf - self.output_profile = options.output_profile.name - self.touchscreen = getattr(options.output_profile,'touchscreen',False) + self.output_profile = options.output_profile + self.touchscreen = getattr(self.output_profile, 'touchscreen', False) self.output_dir = os.path.abspath(self.output_dir) if options.test: @@ -655,7 +653,15 @@ class BasicNewsRecipe(Recipe): for base in list(soup.findAll(['base', 'iframe'])): base.extract() - return self.postprocess_html(soup, first_fetch) + ans = self.postprocess_html(soup, first_fetch) + try: + article = self.feed_objects[f].articles[a] + except: + self.log.exception('Failed to get article object for postprocessing') + pass + else: + self.populate_article_metadata(article, ans, first_fetch) + return ans def download(self): @@ -879,6 +885,7 @@ class BasicNewsRecipe(Recipe): if hasattr(feed, 'reverse'): feed.reverse() + self.feed_objects = feeds for f, feed in enumerate(feeds): feed_dir = os.path.join(self.output_dir, 'feed_%d'%f) if not os.path.isdir(feed_dir): @@ -927,41 +934,9 @@ class BasicNewsRecipe(Recipe): #feeds.restore_duplicates() - # GwR Populate any missing author/description fields in feed for f, feed in enumerate(feeds): - feed_dir = os.path.join(self.output_dir, 'feed_%d'%f) - for article in feed.articles: - if article.summary == '' or article.author == '': - file = os.path.join(self.output_dir,feed_dir, article.url) - if os.path.exists(file): - with open(file, 'rb') as fi: - src = fi.read().decode('utf-8') - soup = BeautifulSoup(src) - if article.author == '': - author = self.extract_author(soup) - if author and not isinstance(author, unicode): - author = author.decode('utf-8', 'replace') - article.author = author - - if article.summary == '': - summary = article.summary = self.extract_description(soup) - if summary and not isinstance(summary, unicode): - summary = summary.decode('utf-8', 'replace') - if summary and '<' in summary: - try: - s = html.fragment_fromstring(summary, create_parent=True) - summary = html.tostring(s, method='text', encoding=unicode) - except: - print 'Failed to process article summary, deleting:' - print summary.encode('utf-8') - traceback.print_exc() - summary = u'' - article.text_summary = summary - - - for f, feed in enumerate(feeds): - feed_dir = os.path.join(self.output_dir, 'feed_%d'%f) html = self.feed2index(feed) + feed_dir = os.path.join(self.output_dir, 'feed_%d'%f) with open(os.path.join(feed_dir, 'index.html'), 'wb') as fi: fi.write(html) self.create_opf(feeds) @@ -1040,47 +1015,13 @@ class BasicNewsRecipe(Recipe): Create a generic cover for recipes that dont have a cover ''' try: - try: - from PIL import Image, ImageDraw, ImageFont - Image, ImageDraw, ImageFont - except ImportError: - import Image, ImageDraw, ImageFont - font_path = P('fonts/liberation/LiberationSerif-Bold.ttf') + from calibre.utils.magick_draw import create_cover_page, TextLine title = self.title if isinstance(self.title, unicode) else \ self.title.decode(preferred_encoding, 'replace') date = strftime(self.timefmt) - app = '['+__appname__ +' '+__version__+']' - - COVER_WIDTH, COVER_HEIGHT = 590, 750 - img = Image.new('RGB', (COVER_WIDTH, COVER_HEIGHT), 'white') - draw = ImageDraw.Draw(img) - # Title - font = ImageFont.truetype(font_path, 44) - width, height = draw.textsize(title, font=font) - left = max(int((COVER_WIDTH - width)/2.), 0) - top = 15 - draw.text((left, top), title, fill=(0,0,0), font=font) - bottom = top + height - # Date - font = ImageFont.truetype(font_path, 32) - width, height = draw.textsize(date, font=font) - left = max(int((COVER_WIDTH - width)/2.), 0) - draw.text((left, bottom+15), date, fill=(0,0,0), font=font) - # Vanity - font = ImageFont.truetype(font_path, 28) - width, height = draw.textsize(app, font=font) - left = max(int((COVER_WIDTH - width)/2.), 0) - top = COVER_HEIGHT - height - 15 - draw.text((left, top), app, fill=(0,0,0), font=font) - # Logo - logo = Image.open(I('library.png'), 'r') - width, height = logo.size - left = max(int((COVER_WIDTH - width)/2.), 0) - top = max(int((COVER_HEIGHT - height)/2.), 0) - img.paste(logo, (left, top)) - img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE) - - img.convert('RGB').save(cover_file, 'JPEG') + lines = [TextLine(title, 44), TextLine(date, 32)] + img_data = create_cover_page(lines, I('library.png'), output_format='jpg') + cover_file.write(img_data) cover_file.flush() except: self.log.exception('Failed to generate default cover') @@ -1173,21 +1114,20 @@ class BasicNewsRecipe(Recipe): pw.DestroyMagickWand(x) def create_opf(self, feeds, dir=None): - if dir is None: dir = self.output_dir mi = MetaInformation(self.short_title() + strftime(self.timefmt), [__appname__]) - mi.author_sort = __appname__ - if self.output_profile == 'iPad': - mi = MetaInformation(self.short_title(), [strftime('%A, %d %B %Y')]) - mi.author_sort = strftime('%Y-%m-%d') mi.publisher = __appname__ + mi.author_sort = __appname__ + if self.output_profile.name == 'iPad': + date_as_author = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y')) + mi.authors = [date_as_author] + mi.author_sort = strftime('%Y-%m-%d') mi.publication_type = 'periodical:'+self.publication_type mi.timestamp = nowf() mi.comments = self.description if not isinstance(mi.comments, unicode): mi.comments = mi.comments.decode('utf-8', 'replace') - mi.tags = ['News'] mi.pubdate = nowf() opf_path = os.path.join(dir, 'index.opf') ncx_path = os.path.join(dir, 'index.ncx') @@ -1230,7 +1170,7 @@ class BasicNewsRecipe(Recipe): entries = ['index.html'] toc = TOC(base_path=dir) - self.play_order_counter = 1 + self.play_order_counter = 0 self.play_order_map = {} def feed_index(num, parent): @@ -1342,7 +1282,6 @@ class BasicNewsRecipe(Recipe): Create a list of articles from the list of feeds returned by :meth:`BasicNewsRecipe.get_feeds`. Return a list of :class:`Feed` objects. ''' - print "\nweb.feeds.news:parse_feeds()\n" feeds = self.get_feeds() parsed_feeds = [] for obj in feeds: diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py index af0c8da6b4..60fd830e67 100644 --- a/src/calibre/web/feeds/templates.py +++ b/src/calibre/web/feeds/templates.py @@ -120,6 +120,7 @@ class TouchscreenNavBarTemplate(Template): href = '%s%s/%s/index.html'%(prefix, up, next) navbar.text = '| ' navbar.append(A('Next', href=href)) + href = '%s../index.html#article_%d'%(prefix, art) navbar.iterchildren(reversed=True).next().tail = ' | ' navbar.append(A('Section Menu', href=href)) @@ -130,6 +131,7 @@ class TouchscreenNavBarTemplate(Template): href = '%s../article_%d/index.html'%(prefix, art-1) navbar.iterchildren(reversed=True).next().tail = ' | ' navbar.append(A('Previous', href=href)) + navbar.iterchildren(reversed=True).next().tail = ' | ' if not bottom: navbar.append(HR()) @@ -165,8 +167,14 @@ class TouchscreenIndexTemplate(Template): def _generate(self, title, masthead, datefmt, feeds, extra_css=None, style=None): if isinstance(datefmt, unicode): datefmt = datefmt.encode(preferred_encoding) - date = strftime(datefmt) - masthead_img = IMG(src=masthead,alt="masthead") + date = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y')) + masthead_p = etree.Element("p") + masthead_p.set("style","text-align:center") + masthead_img = etree.Element("img") + masthead_img.set("src",masthead) + masthead_img.set("alt","masthead") + masthead_p.append(masthead_img) + head = HEAD(TITLE(title)) if style: head.append(STYLE(style, type='text/css')) @@ -178,11 +186,11 @@ class TouchscreenIndexTemplate(Template): if feed: tr = TR() tr.append(TD( CLASS('toc_item'), A(feed.title, href='feed_%d/index.html'%i))) - tr.append(TD( CLASS('article_count'),'%d' % len(feed.articles))) + tr.append(TD( CLASS('article_count'),'%3.3s' % len(feed.articles))) toc.append(tr) div = DIV( - PT(masthead_img,style='text-align:center'), + masthead_p, PT(date, style='text-align:center'), toc, CLASS('calibre_rescale_100')) diff --git a/src/calibre/web/fetch/simple.py b/src/calibre/web/fetch/simple.py index 37858268d4..c1f0d912d6 100644 --- a/src/calibre/web/fetch/simple.py +++ b/src/calibre/web/fetch/simple.py @@ -329,7 +329,7 @@ class RecursiveFetcher(object): try: data = self.fetch_url(iurl) if data == 'GIF89a\x01': - # Skip empty GIF files + # Skip empty GIF files as PIL errors on them anyway continue except Exception: self.log.exception('Could not fetch image %s'% iurl)