diff --git a/resources/recipes/ajiajin.recipe b/resources/recipes/ajiajin.recipe new file mode 100644 index 0000000000..344d3d21fb --- /dev/null +++ b/resources/recipes/ajiajin.recipe @@ -0,0 +1,23 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +ajiajin.com/blog +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class AjiajinBlog(BasicNewsRecipe): + title = u'Ajiajin blog' + __author__ = 'Hiroshi Miura' + oldest_article = 5 + publication_type = 'blog' + max_articles_per_feed = 100 + description = 'The next generation internet trends in Japan and Asia' + publisher = '' + category = 'internet, asia, japan' + language = 'en' + encoding = 'utf-8' + + feeds = [(u'blog', u'http://feeds.feedburner.com/Asiajin')] + + diff --git a/resources/recipes/chouchoublog.recipe b/resources/recipes/chouchoublog.recipe new file mode 100644 index 0000000000..8c953deef0 --- /dev/null +++ b/resources/recipes/chouchoublog.recipe @@ -0,0 +1,37 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +http://ameblo.jp/ +''' + +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class SakuraBlog(BasicNewsRecipe): + title = u'chou chou blog' + __author__ = 'Hiroshi Miura' + oldest_article = 4 + publication_type = 'blog' + max_articles_per_feed = 20 + description = 'Japanese popular dog blog' + publisher = '' + category = 'dog, pet, japan' + language = 'ja' + encoding = 'utf-8' + use_embedded_content = True + + feeds = [(u'blog', u'http://feedblog.ameba.jp/rss/ameblo/chouchou1218/rss20.xml')] + + def parse_feeds(self): + feeds = BasicNewsRecipe.parse_feeds(self) + for curfeed in feeds: + delList = [] + for a,curarticle in enumerate(curfeed.articles): + if re.search(r'rssad.jp', curarticle.url): + delList.append(curarticle) + if len(delList)>0: + for d in delList: + index = curfeed.articles.index(d) + curfeed.articles[index:index+1] = [] + return feeds + diff --git a/resources/recipes/dilbert.recipe b/resources/recipes/dilbert.recipe index 82966b1d15..2c3268da2f 100644 --- a/resources/recipes/dilbert.recipe +++ b/resources/recipes/dilbert.recipe @@ -3,15 +3,16 @@ __copyright__ = '2009, Darko Miletic ' ''' http://www.dilbert.com ''' -import re from calibre.web.feeds.recipes import BasicNewsRecipe +import re -class DosisDiarias(BasicNewsRecipe): +class DilbertBig(BasicNewsRecipe): title = 'Dilbert' - __author__ = 'Darko Miletic' + __author__ = 'Darko Miletic and Starson17' description = 'Dilbert' - oldest_article = 5 + reverse_article_order = True + oldest_article = 15 max_articles_per_feed = 100 no_stylesheets = True use_embedded_content = True @@ -29,20 +30,23 @@ class DosisDiarias(BasicNewsRecipe): feeds = [(u'Dilbert', u'http://feeds.dilbert.com/DilbertDailyStrip' )] - preprocess_regexps = [ - (re.compile('strip\..*\.gif', re.DOTALL|re.IGNORECASE), - lambda match: 'strip.zoom.gif') - ] - - def get_article_url(self, article): return article.get('feedburner_origlink', None) + preprocess_regexps = [ + (re.compile('strip\..*\.gif', re.DOTALL|re.IGNORECASE), lambda match: 'strip.zoom.gif') + ] + def preprocess_html(self, soup): for tag in soup.findAll(name='a'): if tag['href'].find('http://feedads') >= 0: tag.extract() return soup - - + extra_css = ''' + h1{font-family:Arial,Helvetica,sans-serif; font-weight:bold;font-size:large;} + h2{font-family:Arial,Helvetica,sans-serif; font-weight:normal;font-size:small;} + img {max-width:100%; min-width:100%;} + p{font-family:Arial,Helvetica,sans-serif;font-size:small;} + body{font-family:Helvetica,Arial,sans-serif;font-size:small;} + ''' diff --git a/resources/recipes/kahokushinpo.recipe b/resources/recipes/kahokushinpo.recipe new file mode 100644 index 0000000000..06879a1375 --- /dev/null +++ b/resources/recipes/kahokushinpo.recipe @@ -0,0 +1,31 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +www.kahoku.co.jp +''' + +from calibre.web.feeds.news import BasicNewsRecipe + + +class KahokuShinpoNews(BasicNewsRecipe): + title = u'\u6cb3\u5317\u65b0\u5831' + __author__ = 'Hiroshi Miura' + oldest_article = 2 + max_articles_per_feed = 20 + description = 'Tohoku regional news paper in Japan' + publisher = 'Kahoku Shinpo Sha' + category = 'news, japan' + language = 'ja' + encoding = 'Shift_JIS' + no_stylesheets = True + + feeds = [(u'news', u'http://www.kahoku.co.jp/rss/index_thk.xml')] + + keep_only_tags = [ dict(id="page_title"), + dict(id="news_detail"), + dict(id="bt_title"), + {'class':"photoLeft"}, + dict(id="bt_body") + ] + remove_tags = [ {'class':"button"}] + diff --git a/resources/recipes/nationalgeographic.recipe b/resources/recipes/nationalgeographic.recipe new file mode 100644 index 0000000000..f00c9206bd --- /dev/null +++ b/resources/recipes/nationalgeographic.recipe @@ -0,0 +1,38 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +nationalgeographic.com +''' + +from calibre.web.feeds.news import BasicNewsRecipe +import re + +class NationalGeographicNews(BasicNewsRecipe): + title = u'National Geographic News' + oldest_article = 7 + max_articles_per_feed = 100 + remove_javascript = True + no_stylesheets = True + use_embedded_content = False + + feeds = [(u'news', u'http://feeds.nationalgeographic.com/ng/News/News_Main')] + + remove_tags_before = dict(id='page_head') + remove_tags_after = [dict(id='social_buttons'),{'class':'aside'}] + remove_tags = [ + {'class':'hidden'} + + ] + + def parse_feeds(self): + feeds = BasicNewsRecipe.parse_feeds(self) + for curfeed in feeds: + delList = [] + for a,curarticle in enumerate(curfeed.articles): + if re.search(r'ads\.pheedo\.com', curarticle.url): + delList.append(curarticle) + if len(delList)>0: + for d in delList: + index = curfeed.articles.index(d) + curfeed.articles[index:index+1] = [] + return feeds diff --git a/resources/recipes/nationalgeographicjp.recipe b/resources/recipes/nationalgeographicjp.recipe new file mode 100644 index 0000000000..5798acb102 --- /dev/null +++ b/resources/recipes/nationalgeographicjp.recipe @@ -0,0 +1,20 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +nationalgeographic.co.jp +''' + +from calibre.web.feeds.news import BasicNewsRecipe +import re + +class NationalGeoJp(BasicNewsRecipe): + title = u'\u30ca\u30b7\u30e7\u30ca\u30eb\u30fb\u30b8\u30aa\u30b0\u30e9\u30d5\u30a3\u30c3\u30af\u30cb\u30e5\u30fc\u30b9' + oldest_article = 7 + max_articles_per_feed = 100 + no_stylesheets = True + + feeds = [(u'news', u'http://www.nationalgeographic.co.jp/news/rss.php')] + + def print_version(self, url): + return re.sub(r'news_article.php','news_printer_friendly.php', url) + diff --git a/resources/recipes/nikkei_sub_shakai.recipe b/resources/recipes/nikkei_sub_shakai.recipe index ed86493265..9a53e910e6 100644 --- a/resources/recipes/nikkei_sub_shakai.recipe +++ b/resources/recipes/nikkei_sub_shakai.recipe @@ -10,8 +10,8 @@ import mechanize from calibre.ptempfile import PersistentTemporaryFile -class NikkeiNet_sub_life(BasicNewsRecipe): - title = u'\u65e5\u7d4c\u65b0\u805e\u96fb\u5b50\u7248(\u751f\u6d3b)' +class NikkeiNet_sub_shakai(BasicNewsRecipe): + title = u'\u65e5\u7d4c\u65b0\u805e\u96fb\u5b50\u7248(Social)' __author__ = 'Hiroshi Miura' description = 'News and current market affairs from Japan' cover_url = 'http://parts.nikkei.com/parts/ds/images/common/logo_r1.svg' diff --git a/resources/recipes/paperli_topic.recipe b/resources/recipes/paperli_topic.recipe new file mode 100644 index 0000000000..1ccf5f7945 --- /dev/null +++ b/resources/recipes/paperli_topic.recipe @@ -0,0 +1,58 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +paperli +''' + +from calibre.web.feeds.news import BasicNewsRecipe +from calibre import strftime + +class paperli_topics(BasicNewsRecipe): + # Customize this recipe and change paperli_tag and title below to + # download news on your favorite tag + paperli_tag = 'climate' + title = u'The #climate Daily - paperli' +#------------------------------------------------------------- + __author__ = 'Hiroshi Miura' + oldest_article = 7 + max_articles_per_feed = 100 + description = 'paper.li page about '+ paperli_tag + publisher = 'paper.li' + category = 'paper.li' + language = 'en' + encoding = 'utf-8' + remove_javascript = True + masthead_title = u'The '+ paperli_tag +' Daily' + timefmt = '[%y/%m/%d]' + base_url = 'http://paper.li' + index = base_url+'/tag/'+paperli_tag + + + def parse_index(self): + # get topics + topics = [] + soup = self.index_to_soup(self.index) + topics_lists = soup.find('div',attrs={'class':'paper-nav-bottom'}) + for item in topics_lists.findAll('li', attrs={'class':""}): + itema = item.find('a',href=True) + topics.append({'title': itema.string, 'url': itema['href']}) + + #get feeds + feeds = [] + for topic in topics: + newsarticles = [] + soup = self.index_to_soup(''.join([self.base_url, topic['url'] ])) + topstories = soup.findAll('div',attrs={'class':'yui-u'}) + for itt in topstories: + itema = itt.find('a',href=True,attrs={'class':'ts'}) + if itema is not None: + itemd = itt.find('div',text=True, attrs={'class':'text'}) + newsarticles.append({ + 'title' :itema.string + ,'date' :strftime(self.timefmt) + ,'url' :itema['href'] + ,'description':itemd.string + }) + feeds.append((topic['title'], newsarticles)) + return feeds + diff --git a/resources/recipes/science_based_medicine.recipe b/resources/recipes/science_based_medicine.recipe new file mode 100644 index 0000000000..7aa28cb170 --- /dev/null +++ b/resources/recipes/science_based_medicine.recipe @@ -0,0 +1,42 @@ +#!/usr/bin/env python + +import re +from calibre.web.feeds.news import BasicNewsRecipe +from calibre.ebooks.BeautifulSoup import Tag + +class SBM(BasicNewsRecipe): + title = 'Science Based Medicine' + __author__ = 'BuzzKill' + description = 'Exploring issues and controversies in the relationship between science and medicine' + oldest_article = 5 + max_articles_per_feed = 15 + no_stylesheets = True + use_embedded_content = False + encoding = 'utf-8' + publisher = 'SBM' + category = 'science, sbm, ebm, blog, pseudoscience' + language = 'en' + + lang = 'en-US' + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : lang + , 'pretty_print' : True + } + + keep_only_tags = [ + dict(name='a', attrs={'title':re.compile(r'Posts by.*', re.DOTALL|re.IGNORECASE)}), + dict(name='div', attrs={'class':'entry'}) + ] + + feeds = [(u'Science Based Medicine', u'http://www.sciencebasedmedicine.org/?feed=rss2')] + + def preprocess_html(self, soup): + mtag = Tag(soup,'meta',[('http-equiv','Content-Type'),('context','text/html; charset=utf-8')]) + soup.head.insert(0,mtag) + soup.html['lang'] = self.lang + return self.adeify_images(soup) + diff --git a/resources/recipes/uninohimitu.recipe b/resources/recipes/uninohimitu.recipe new file mode 100644 index 0000000000..aac412744c --- /dev/null +++ b/resources/recipes/uninohimitu.recipe @@ -0,0 +1,36 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Hiroshi Miura ' +''' +http://ameblo.jp/sauta19/ +''' + +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class UniNoHimituKichiBlog(BasicNewsRecipe): + title = u'Uni secret base' + __author__ = 'Hiroshi Miura' + oldest_article = 2 + publication_type = 'blog' + max_articles_per_feed = 20 + description = 'Japanese famous Cat blog' + publisher = '' + category = 'cat, pet, japan' + language = 'ja' + encoding = 'utf-8' + + feeds = [(u'blog', u'http://feedblog.ameba.jp/rss/ameblo/sauta19/rss20.xml')] + + def parse_feeds(self): + feeds = BasicNewsRecipe.parse_feeds(self) + for curfeed in feeds: + delList = [] + for a,curarticle in enumerate(curfeed.articles): + if re.search(r'rssad.jp', curarticle.url): + delList.append(curarticle) + if len(delList)>0: + for d in delList: + index = curfeed.articles.index(d) + curfeed.articles[index:index+1] = [] + return feeds + diff --git a/resources/recipes/zeitde.recipe b/resources/recipes/zeitde.recipe index 64345ea675..389bdec670 100644 --- a/resources/recipes/zeitde.recipe +++ b/resources/recipes/zeitde.recipe @@ -60,8 +60,8 @@ class ZeitDe(BasicNewsRecipe): for tag in soup.findAll(name=['ul','li']): tag.name = 'div' - soup.html['xml:lang'] = self.lang - soup.html['lang'] = self.lang + soup.html['xml:lang'] = self.language.replace('_', '-') + soup.html['lang'] = self.language.replace('_', '-') mtag = '' soup.head.insert(0,mtag) return soup diff --git a/src/calibre/devices/errors.py b/src/calibre/devices/errors.py index 7464d6635e..3d88eb741f 100644 --- a/src/calibre/devices/errors.py +++ b/src/calibre/devices/errors.py @@ -36,6 +36,11 @@ class UserFeedback(DeviceError): self.details = details self.msg = msg +class OpenFeedback(DeviceError): + def __init__(self, msg): + self.feedback_msg = msg + DeviceError.__init__(self, msg) + class DeviceBusy(ProtocolError): """ Raised when device is busy """ def __init__(self, uerr=""): diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py index 48d751fc29..2a92f46e8d 100644 --- a/src/calibre/devices/interface.py +++ b/src/calibre/devices/interface.py @@ -216,6 +216,9 @@ class DevicePlugin(Plugin): an implementation of this function that should serve as a good example for USB Mass storage devices. + + This method can raise an OpenFeedback exception to display a message to + the user. ''' raise NotImplementedError() diff --git a/src/calibre/ebooks/conversion/cli.py b/src/calibre/ebooks/conversion/cli.py index 62a941142b..3178fe1b43 100644 --- a/src/calibre/ebooks/conversion/cli.py +++ b/src/calibre/ebooks/conversion/cli.py @@ -120,7 +120,7 @@ def add_pipeline_options(parser, plumber): [ 'base_font_size', 'disable_font_rescaling', 'font_size_mapping', - 'line_height', + 'line_height', 'minimum_line_height', 'linearize_tables', 'extra_css', 'smarten_punctuation', 'margin_top', 'margin_left', 'margin_right', diff --git a/src/calibre/ebooks/conversion/plumber.py b/src/calibre/ebooks/conversion/plumber.py index 9a863d7e66..f5beba375d 100644 --- a/src/calibre/ebooks/conversion/plumber.py +++ b/src/calibre/ebooks/conversion/plumber.py @@ -160,13 +160,30 @@ OptionRecommendation(name='disable_font_rescaling', ) ), +OptionRecommendation(name='minimum_line_height', + recommended_value=120.0, level=OptionRecommendation.LOW, + help=_( + 'The minimum line height, as a percentage of the element\'s ' + 'calculated font size. calibre will ensure that every element ' + 'has a line height of at least this setting, irrespective of ' + 'what the input document specifies. Set to zero to disable. ' + 'Default is 120%. Use this setting in preference to ' + 'the direct line height specification, unless you know what ' + 'you are doing. For example, you can achieve "double spaced" ' + 'text by setting this to 240.' + ) + ), + OptionRecommendation(name='line_height', recommended_value=0, level=OptionRecommendation.LOW, - help=_('The line height in pts. Controls spacing between consecutive ' - 'lines of text. By default no line height manipulation is ' - 'performed.' - ) + help=_( + 'The line height in pts. Controls spacing between consecutive ' + 'lines of text. Only applies to elements that do not define ' + 'their own line height. In most cases, the minimum line height ' + 'option is more useful. ' + 'By default no line height manipulation is performed.' + ) ), OptionRecommendation(name='linearize_tables', diff --git a/src/calibre/ebooks/fb2/fb2ml.py b/src/calibre/ebooks/fb2/fb2ml.py index 51bfaa7293..5efc360f1f 100644 --- a/src/calibre/ebooks/fb2/fb2ml.py +++ b/src/calibre/ebooks/fb2/fb2ml.py @@ -73,6 +73,10 @@ class FB2MLizer(object): text = re.sub(r'(?miu)

\s*

', '', text) text = re.sub(r'(?miu)\s+

', '

', text) text = re.sub(r'(?miu)

', '

\n\n

', text) + + if self.opts.insert_blank_line: + text = re.sub(r'(?miu)

', '

', text) + return text def fb2_header(self): @@ -293,6 +297,18 @@ class FB2MLizer(object): s_out, s_tags = self.handle_simple_tag('emphasis', tag_stack+tags) fb2_out += s_out tags += s_tags + elif tag in ('del', 'strike'): + s_out, s_tags = self.handle_simple_tag('strikethrough', tag_stack+tags) + fb2_out += s_out + tags += s_tags + elif tag == 'sub': + s_out, s_tags = self.handle_simple_tag('sub', tag_stack+tags) + fb2_out += s_out + tags += s_tags + elif tag == 'sup': + s_out, s_tags = self.handle_simple_tag('sup', tag_stack+tags) + fb2_out += s_out + tags += s_tags # Processes style information. if style['font-style'] == 'italic': @@ -303,6 +319,10 @@ class FB2MLizer(object): s_out, s_tags = self.handle_simple_tag('strong', tag_stack+tags) fb2_out += s_out tags += s_tags + elif style['text-decoration'] == 'line-through': + s_out, s_tags = self.handle_simple_tag('strikethrough', tag_stack+tags) + fb2_out += s_out + tags += s_tags # Process element text. if hasattr(elem_tree, 'text') and elem_tree.text: diff --git a/src/calibre/ebooks/html/input.py b/src/calibre/ebooks/html/input.py index 059aeca324..6f875ae803 100644 --- a/src/calibre/ebooks/html/input.py +++ b/src/calibre/ebooks/html/input.py @@ -314,6 +314,8 @@ class HTMLInput(InputFormatPlugin): rewrite_links, urlnormalize, urldefrag, BINARY_MIME, OEB_STYLES, \ xpath from calibre import guess_type + from calibre.ebooks.oeb.transforms.metadata import \ + meta_info_to_oeb_metadata import cssutils self.OEB_STYLES = OEB_STYLES oeb = create_oebbook(log, None, opts, self, @@ -321,15 +323,7 @@ class HTMLInput(InputFormatPlugin): self.oeb = oeb metadata = oeb.metadata - if mi.title: - metadata.add('title', mi.title) - if mi.authors: - for a in mi.authors: - metadata.add('creator', a, attrib={'role':'aut'}) - if mi.publisher: - metadata.add('publisher', mi.publisher) - if mi.isbn: - metadata.add('identifier', mi.isbn, attrib={'scheme':'ISBN'}) + meta_info_to_oeb_metadata(mi, metadata, log) if not metadata.language: oeb.logger.warn(u'Language not specified') metadata.add('language', get_lang().replace('_', '-')) diff --git a/src/calibre/ebooks/metadata/html.py b/src/calibre/ebooks/metadata/html.py index f4eaa7cc61..fd42b2882f 100644 --- a/src/calibre/ebooks/metadata/html.py +++ b/src/calibre/ebooks/metadata/html.py @@ -170,7 +170,27 @@ def get_metadata_(src, encoding=None): if match: series = match.group(1) if series: + pat = re.compile(r'\[([.0-9]+)\]') + match = pat.search(series) + series_index = None + if match is not None: + try: + series_index = float(match.group(1)) + except: + pass + series = series.replace(match.group(), '').strip() + mi.series = ent_pat.sub(entity_to_unicode, series) + if series_index is None: + pat = get_meta_regexp_("Seriesnumber") + match = pat.search(src) + if match: + try: + series_index = float(match.group(1)) + except: + pass + if series_index is not None: + mi.series_index = series_index # RATING rating = None diff --git a/src/calibre/ebooks/mobi/mobiml.py b/src/calibre/ebooks/mobi/mobiml.py index 8d20179250..001cf2c1e9 100644 --- a/src/calibre/ebooks/mobi/mobiml.py +++ b/src/calibre/ebooks/mobi/mobiml.py @@ -184,7 +184,7 @@ class MobiMLizer(object): para.attrib['value'] = str(istates[-2].list_num) elif tag in NESTABLE_TAGS and istate.rendered: para = wrapper = bstate.nested[-1] - elif left > 0 and indent >= 0: + elif not self.opts.mobi_ignore_margins and left > 0 and indent >= 0: ems = self.profile.mobi_ems_per_blockquote para = wrapper = etree.SubElement(parent, XHTML('blockquote')) para = wrapper diff --git a/src/calibre/ebooks/mobi/output.py b/src/calibre/ebooks/mobi/output.py index 4159c6dd40..a6f6c52b7f 100644 --- a/src/calibre/ebooks/mobi/output.py +++ b/src/calibre/ebooks/mobi/output.py @@ -39,6 +39,12 @@ class MOBIOutput(OutputFormatPlugin): OptionRecommendation(name='personal_doc', recommended_value='[PDOC]', help=_('Tag marking book to be filed with Personal Docs') ), + OptionRecommendation(name='mobi_ignore_margins', + recommended_value=False, + help=_('Ignore margins in the input document. If False, then ' + 'the MOBI output plugin will try to convert margins specified' + ' in the input document, otherwise it will ignore them.') + ), ]) def check_for_periodical(self): diff --git a/src/calibre/ebooks/oeb/stylizer.py b/src/calibre/ebooks/oeb/stylizer.py index 6c0c384eb3..616cd3b800 100644 --- a/src/calibre/ebooks/oeb/stylizer.py +++ b/src/calibre/ebooks/oeb/stylizer.py @@ -633,12 +633,12 @@ class Style(object): parent = self._getparent() if 'line-height' in self._style: lineh = self._style['line-height'] + if lineh == 'normal': + lineh = '1.2' try: - float(lineh) + result = float(lineh) * self.fontSize except ValueError: result = self._unit_convert(lineh, base=self.fontSize) - else: - result = float(lineh) * self.fontSize elif parent is not None: # TODO: proper inheritance result = parent.lineHeight diff --git a/src/calibre/ebooks/oeb/transforms/flatcss.py b/src/calibre/ebooks/oeb/transforms/flatcss.py index 7b83421097..653aa4533b 100644 --- a/src/calibre/ebooks/oeb/transforms/flatcss.py +++ b/src/calibre/ebooks/oeb/transforms/flatcss.py @@ -245,6 +245,8 @@ class CSSFlattener(object): del node.attrib['bgcolor'] if cssdict.get('font-weight', '').lower() == 'medium': cssdict['font-weight'] = 'normal' # ADE chokes on font-weight medium + + fsize = font_size if not self.context.disable_font_rescaling: _sbase = self.sbase if self.sbase is not None else \ self.context.source.fbase @@ -258,6 +260,14 @@ class CSSFlattener(object): fsize = self.fmap[font_size] cssdict['font-size'] = "%0.5fem" % (fsize / psize) psize = fsize + + try: + minlh = self.context.minimum_line_height / 100. + if style['line-height'] < minlh * fsize: + cssdict['line-height'] = str(minlh) + except: + self.oeb.logger.exception('Failed to set minimum line-height') + if cssdict: if self.lineh and self.fbase and tag != 'body': self.clean_edges(cssdict, style, psize) @@ -290,6 +300,7 @@ class CSSFlattener(object): lineh = self.lineh / psize cssdict['line-height'] = "%0.5fem" % lineh + if (self.context.remove_paragraph_spacing or self.context.insert_blank_line) and tag in ('p', 'div'): if item_id != 'calibre_jacket' or self.context.output_profile.name == 'Kindle': diff --git a/src/calibre/ebooks/txt/input.py b/src/calibre/ebooks/txt/input.py index b444bf1cf4..44b98304ea 100644 --- a/src/calibre/ebooks/txt/input.py +++ b/src/calibre/ebooks/txt/input.py @@ -77,10 +77,14 @@ class TXTInput(InputFormatPlugin): base = os.getcwdu() if hasattr(stream, 'name'): base = os.path.dirname(stream.name) - htmlfile = open(os.path.join(base, 'temp_calibre_txt_input_to_html.html'), - 'wb') - htmlfile.write(html.encode('utf-8')) - htmlfile.close() + fname = os.path.join(base, 'index.html') + c = 0 + while os.path.exists(fname): + c += 1 + fname = 'index%d.html'%c + htmlfile = open(fname, 'wb') + with htmlfile: + htmlfile.write(html.encode('utf-8')) cwd = os.getcwdu() odi = options.debug_pipeline options.debug_pipeline = None diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 57ca2a1880..f96c64080d 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -9,7 +9,7 @@ from PyQt4.Qt import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, \ QByteArray, QTranslator, QCoreApplication, QThread, \ QEvent, QTimer, pyqtSignal, QDate, QDesktopServices, \ QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \ - QIcon, QApplication, QDialog, QPushButton, QUrl + QIcon, QApplication, QDialog, QPushButton, QUrl, QFont ORG_NAME = 'KovidsBrain' APP_UID = 'libprs500' @@ -52,6 +52,7 @@ gprefs.defaults['show_splash_screen'] = True gprefs.defaults['toolbar_icon_size'] = 'medium' gprefs.defaults['toolbar_text'] = 'auto' gprefs.defaults['show_child_bar'] = False +gprefs.defaults['font'] = None # }}} @@ -613,6 +614,10 @@ class Application(QApplication): qt_app = self self._file_open_paths = [] self._file_open_lock = RLock() + self.original_font = QFont(QApplication.font()) + fi = gprefs['font'] + if fi is not None: + QApplication.setFont(QFont(*fi)) def _send_file_open_events(self): with self._file_open_lock: diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py index 60a943ccb9..11949632e9 100644 --- a/src/calibre/gui2/actions/edit_metadata.py +++ b/src/calibre/gui2/actions/edit_metadata.py @@ -154,15 +154,17 @@ class EditMetadataAction(InterfaceAction): d.view_format.connect(lambda fmt:self.gui.iactions['View'].view_format(row_list[current_row], fmt)) - if d.exec_() != d.Accepted: - d.view_format.disconnect() + ret = d.exec_() + d.break_cycles() + if ret != d.Accepted: break - d.view_format.disconnect() + changed.add(d.id) if d.row_delta == 0: break current_row += d.row_delta + if changed: self.gui.library_view.model().refresh_ids(list(changed)) current = self.gui.library_view.currentIndex() diff --git a/src/calibre/gui2/convert/__init__.py b/src/calibre/gui2/convert/__init__.py index 0c3a8b5a4e..c1efe5b9af 100644 --- a/src/calibre/gui2/convert/__init__.py +++ b/src/calibre/gui2/convert/__init__.py @@ -10,7 +10,7 @@ import textwrap from functools import partial from PyQt4.Qt import QWidget, QSpinBox, QDoubleSpinBox, QLineEdit, QTextEdit, \ - QCheckBox, QComboBox, Qt, QIcon, pyqtSignal + QCheckBox, QComboBox, Qt, QIcon, pyqtSignal, QLabel from calibre.customize.conversion import OptionRecommendation from calibre.ebooks.conversion.config import load_defaults, \ @@ -81,6 +81,21 @@ class Widget(QWidget): self.apply_recommendations(defaults) self.setup_help(get_help) + def process_child(child): + for g in child.children(): + if isinstance(g, QLabel): + buddy = g.buddy() + if buddy is not None and hasattr(buddy, '_help'): + g._help = buddy._help + htext = unicode(buddy.toolTip()).strip() + g.setToolTip(htext) + g.setWhatsThis(htext) + g.__class__.enterEvent = lambda obj, event: self.set_help(getattr(obj, '_help', obj.toolTip())) + else: + process_child(g) + process_child(self) + + def restore_defaults(self, get_option): defaults = GuiRecommendations() defaults.merge_recommendations(get_option, OptionRecommendation.LOW, diff --git a/src/calibre/gui2/convert/look_and_feel.py b/src/calibre/gui2/convert/look_and_feel.py index ec3f0b944d..98b9cb8155 100644 --- a/src/calibre/gui2/convert/look_and_feel.py +++ b/src/calibre/gui2/convert/look_and_feel.py @@ -21,7 +21,7 @@ class LookAndFeelWidget(Widget, Ui_Form): def __init__(self, parent, get_option, get_help, db=None, book_id=None): Widget.__init__(self, parent, ['change_justification', 'extra_css', 'base_font_size', - 'font_size_mapping', 'line_height', + 'font_size_mapping', 'line_height', 'minimum_line_height', 'linearize_tables', 'smarten_punctuation', 'disable_font_rescaling', 'insert_blank_line', 'remove_paragraph_spacing', 'remove_paragraph_spacing_indent_size','input_encoding', diff --git a/src/calibre/gui2/convert/look_and_feel.ui b/src/calibre/gui2/convert/look_and_feel.ui index c683300854..367233e2c0 100644 --- a/src/calibre/gui2/convert/look_and_feel.ui +++ b/src/calibre/gui2/convert/look_and_feel.ui @@ -97,7 +97,7 @@ - + Line &height: @@ -107,7 +107,7 @@ - + pt @@ -117,7 +117,7 @@ - + Input character &encoding: @@ -127,17 +127,17 @@ - + - + Remove &spacing between paragraphs - + @@ -164,21 +164,21 @@ - + Text justification: - + &Linearize tables - + Extra &CSS @@ -190,37 +190,60 @@ - + - + &Transliterate unicode characters to ASCII - + Insert &blank line - + Keep &ligatures - + Smarten &punctuation + + + + Minimum &line height: + + + opt_minimum_line_height + + + + + + + % + + + 1 + + + 900.000000000000000 + + + diff --git a/src/calibre/gui2/convert/mobi_output.py b/src/calibre/gui2/convert/mobi_output.py index 23c0b30253..14aca24db5 100644 --- a/src/calibre/gui2/convert/mobi_output.py +++ b/src/calibre/gui2/convert/mobi_output.py @@ -25,6 +25,7 @@ class PluginWidget(Widget, Ui_Form): def __init__(self, parent, get_option, get_help, db=None, book_id=None): Widget.__init__(self, parent, ['prefer_author_sort', 'rescale_images', 'toc_title', + 'mobi_ignore_margins', 'dont_compress', 'no_inline_toc', 'masthead_font','personal_doc'] ) self.db, self.book_id = db, book_id diff --git a/src/calibre/gui2/convert/mobi_output.ui b/src/calibre/gui2/convert/mobi_output.ui index 176ce681c0..e9eab45e1a 100644 --- a/src/calibre/gui2/convert/mobi_output.ui +++ b/src/calibre/gui2/convert/mobi_output.ui @@ -55,7 +55,7 @@ - + Kindle options @@ -101,7 +101,7 @@ - + Qt::Vertical @@ -114,6 +114,13 @@ + + + + Ignore &margins + + + diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 008649f534..07bfeccc4f 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -12,7 +12,7 @@ from PyQt4.Qt import QMenu, QAction, QActionGroup, QIcon, SIGNAL, \ from calibre.customize.ui import available_input_formats, available_output_formats, \ device_plugins from calibre.devices.interface import DevicePlugin -from calibre.devices.errors import UserFeedback +from calibre.devices.errors import UserFeedback, OpenFeedback from calibre.gui2.dialogs.choose_format import ChooseFormatDialog from calibre.utils.ipc.job import BaseJob from calibre.devices.scanner import DeviceScanner @@ -122,7 +122,8 @@ def device_name_for_plugboards(device_class): class DeviceManager(Thread): # {{{ - def __init__(self, connected_slot, job_manager, open_feedback_slot, sleep_time=2): + def __init__(self, connected_slot, job_manager, open_feedback_slot, + open_feedback_msg, sleep_time=2): ''' :sleep_time: Time to sleep between device probes in secs ''' @@ -143,6 +144,7 @@ class DeviceManager(Thread): # {{{ self.ejected_devices = set([]) self.mount_connection_requests = Queue.Queue(0) self.open_feedback_slot = open_feedback_slot + self.open_feedback_msg = open_feedback_msg def report_progress(self, *args): pass @@ -163,6 +165,9 @@ class DeviceManager(Thread): # {{{ dev.reset(detected_device=detected_device, report_progress=self.report_progress) dev.open() + except OpenFeedback, e: + self.open_feedback_msg(dev.get_gui_name(), e.feedback_msg) + continue except: tb = traceback.format_exc() if DEBUG or tb not in self.reported_errors: @@ -594,11 +599,16 @@ class DeviceMixin(object): # {{{ _('Error communicating with device'), ' ') self.device_error_dialog.setModal(Qt.NonModal) self.device_manager = DeviceManager(Dispatcher(self.device_detected), - self.job_manager, Dispatcher(self.status_bar.show_message)) + self.job_manager, Dispatcher(self.status_bar.show_message), + Dispatcher(self.show_open_feedback)) self.device_manager.start() if tweaks['auto_connect_to_folder']: self.connect_to_folder_named(tweaks['auto_connect_to_folder']) + def show_open_feedback(self, devname, msg): + self.__of_dev_mem__ = d = info_dialog(self, devname, msg) + d.show() + def auto_convert_question(self, msg, autos): autos = u'\n'.join(map(unicode, map(force_unicode, autos))) return self.ask_a_yes_no_question( diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index a640c50fb8..e0f1f83c73 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -102,7 +102,7 @@ class MyBlockingBusy(QDialog): remove_all, remove, add, au, aus, do_aus, rating, pub, do_series, \ do_autonumber, do_remove_format, remove_format, do_swap_ta, \ do_remove_conv, do_auto_author, series, do_series_restart, \ - series_start_value, do_title_case, clear_series = self.args + series_start_value, do_title_case, cover_action, clear_series = self.args # first loop: do author and title. These will commit at the end of each @@ -129,6 +129,23 @@ class MyBlockingBusy(QDialog): self.db.set_title(id, titlecase(title), notify=False) if au: self.db.set_authors(id, string_to_authors(au), notify=False) + if cover_action == 'remove': + self.db.remove_cover(id) + elif cover_action == 'generate': + from calibre.ebooks import calibre_cover + from calibre.ebooks.metadata import fmt_sidx + from calibre.gui2 import config + mi = self.db.get_metadata(id, index_is_id=True) + series_string = None + if mi.series: + series_string = _('Book %s of %s')%( + fmt_sidx(mi.series_index, + use_roman=config['use_roman_numerals_for_series_number']), + mi.series) + + cdata = calibre_cover(mi.title, mi.format_field('authors')[-1], + series_string=series_string) + self.db.set_cover(id, cdata) elif self.current_phase == 2: # All of these just affect the DB, so we can tolerate a total rollback if do_auto_author: @@ -678,11 +695,16 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): do_remove_conv = self.remove_conversion_settings.isChecked() do_auto_author = self.auto_author_sort.isChecked() do_title_case = self.change_title_to_title_case.isChecked() + cover_action = None + if self.cover_remove.isChecked(): + cover_action = 'remove' + elif self.cover_generate.isChecked(): + cover_action = 'generate' args = (remove_all, remove, add, au, aus, do_aus, rating, pub, do_series, do_autonumber, do_remove_format, remove_format, do_swap_ta, do_remove_conv, do_auto_author, series, do_series_restart, - series_start_value, do_title_case, clear_series) + series_start_value, do_title_case, cover_action, clear_series) bb = MyBlockingBusy(_('Applying changes to %d books.\nPhase {0} {1}%%.') %len(self.ids), args, self.db, self.ids, diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui index 344bde0fa0..cd644f88ba 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.ui +++ b/src/calibre/gui2/dialogs/metadata_bulk.ui @@ -381,7 +381,7 @@ Future conversion of these books will use the default settings. - + Qt::Vertical @@ -394,6 +394,39 @@ Future conversion of these books will use the default settings. + + + + Change &cover + + + + + + &No change + + + true + + + + + + + &Remove cover + + + + + + + &Generate default cover + + + + + + diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py index 3205b1d23c..4a9bb784c8 100644 --- a/src/calibre/gui2/dialogs/metadata_single.py +++ b/src/calibre/gui2/dialogs/metadata_single.py @@ -240,37 +240,39 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): self.cover_fetcher = CoverFetcher(None, None, isbn, self.timeout, title, author) self.cover_fetcher.start() - self._hangcheck = QTimer(self) - self._hangcheck.timeout.connect(self.hangcheck, - type=Qt.QueuedConnection) self.cf_start_time = time.time() self.pi.start(_('Downloading cover...')) - self._hangcheck.start(100) + QTimer.singleShot(100, self.hangcheck) def hangcheck(self): - if self.cover_fetcher.is_alive() and \ - time.time()-self.cf_start_time < self.COVER_FETCH_TIMEOUT: + cf = self.cover_fetcher + if cf is None: + # Called after dialog closed + return + + if cf.is_alive() and \ + time.time()-self.cf_start_time < self.COVER_FETCH_TIMEOUT: + QTimer.singleShot(100, self.hangcheck) return - self._hangcheck.stop() try: - if self.cover_fetcher.is_alive(): + if cf.is_alive(): error_dialog(self, _('Cannot fetch cover'), _('Could not fetch cover.
')+ _('The download timed out.')).exec_() return - if self.cover_fetcher.needs_isbn: + if cf.needs_isbn: error_dialog(self, _('Cannot fetch cover'), _('Could not find cover for this book. Try ' 'specifying the ISBN first.')).exec_() return - if self.cover_fetcher.exception is not None: - err = self.cover_fetcher.exception + if cf.exception is not None: + err = cf.exception error_dialog(self, _('Cannot fetch cover'), _('Could not fetch cover.
')+unicode(err)).exec_() return - if self.cover_fetcher.errors and self.cover_fetcher.cover_data is None: - details = u'\n\n'.join([e[-1] + ': ' + e[1] for e in self.cover_fetcher.errors]) + if cf.errors and cf.cover_data is None: + details = u'\n\n'.join([e[-1] + ': ' + e[1] for e in cf.errors]) error_dialog(self, _('Cannot fetch cover'), _('Could not fetch cover.
') + _('For the error message from each cover source, ' @@ -278,7 +280,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): return pix = QPixmap() - pix.loadFromData(self.cover_fetcher.cover_data) + pix.loadFromData(cf.cover_data) if pix.isNull(): error_dialog(self, _('Bad cover'), _('The cover is not a valid picture')).exec_() @@ -287,11 +289,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): self.update_cover_tooltip() self.cover_changed = True self.cpixmap = pix - self.cover_data = self.cover_fetcher.cover_data + self.cover_data = cf.cover_data finally: self.fetch_cover_button.setEnabled(True) self.unsetCursor() - self.pi.stop() + if self.pi is not None: + self.pi.stop() # }}} @@ -438,8 +441,8 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): def __init__(self, window, row, db, prev=None, next_=None): ResizableDialog.__init__(self, window) + self.cover_fetcher = None self.bc_box.layout().setAlignment(self.cover, Qt.AlignCenter|Qt.AlignHCenter) - self.cancel_all = False base = unicode(self.author_sort.toolTip()) self.ok_aus_tooltip = '

' + textwrap.fill(base+'

'+ _(' The green color indicates that the current ' @@ -570,7 +573,6 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): QObject.connect(self.series, SIGNAL('editTextChanged(QString)'), self.enable_series_index) self.series.lineEdit().editingFinished.connect(self.increment_series_index) - self.show() pm = QPixmap() if cover: pm.loadFromData(cover) @@ -590,6 +592,8 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): self.original_author = unicode(self.authors.text()).strip() self.original_title = unicode(self.title.text()).strip() + self.show() + def create_custom_column_editors(self): w = self.central_widget.widget(1) layout = w.layout() @@ -828,10 +832,6 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): self.accept() def accept(self): - cf = getattr(self, 'cover_fetcher', None) - if cf is not None and hasattr(cf, 'terminate'): - cf.terminate() - cf.wait() try: if self.formats_changed: self.sync_formats() @@ -888,14 +888,12 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): show=True) raise self.save_state() + self.cover_fetcher = None QDialog.accept(self) def reject(self, *args): - cf = getattr(self, 'cover_fetcher', None) - if cf is not None and hasattr(cf, 'terminate'): - cf.terminate() - cf.wait() self.save_state() + self.cover_fetcher = None QDialog.reject(self, *args) def read_state(self): @@ -910,3 +908,48 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): dynamic.set('metasingle_window_geometry', bytes(self.saveGeometry())) dynamic.set('metasingle_splitter_state', bytes(self.splitter.saveState())) + + def break_cycles(self): + # Break any reference cycles that could prevent python + # from garbage collecting this dialog + def disconnect(signal): + try: + signal.disconnect() + except: + pass # Fails if view format was never connected + disconnect(self.view_format) + for b in ('next_button', 'prev_button'): + x = getattr(self, b, None) + if x is not None: + disconnect(x.clicked) + +if __name__ == '__main__': + from calibre.library import db + from PyQt4.Qt import QApplication + from calibre.utils.mem import memory + import gc + + + app = QApplication([]) + db = db() + + # Initialize all Qt Objects once + d = MetadataSingleDialog(None, 4, db) + d.break_cycles() + d.reject() + del d + + for i in range(5): + gc.collect() + before = memory() + + d = MetadataSingleDialog(None, 4, db) + d.reject() + d.break_cycles() + del d + + for i in range(5): + gc.collect() + print 'Used memory:', memory(before)/1024.**2, 'MB' + + diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index 60092e4bd2..7573f04012 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -145,7 +145,7 @@ class TagCategories(QDialog, Ui_TagCategories): index = self.all_items[node.data(Qt.UserRole).toPyObject()].index if index not in self.applied_items: self.applied_items.append(index) - self.applied_items.sort(key=lambda x:sort_key(self.all_items[x])) + self.applied_items.sort(key=lambda x:sort_key(self.all_items[x].name)) self.display_filtered_categories(None) def unapply_tags(self, node=None): diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py index a7d6fe03e7..ced0e9a505 100644 --- a/src/calibre/gui2/dialogs/tag_list_editor.py +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -105,9 +105,13 @@ class TagListEditor(QDialog, Ui_TagListEditor): if not question_dialog(self, _('Are your sure?'), '

'+_('Are you certain you want to delete the following items?')+'
'+ct): return - + row = self.available_tags.row(deletes[0]) for item in deletes: (id,ign) = item.data(Qt.UserRole).toInt() self.to_delete.append(id) self.available_tags.takeItem(self.available_tags.row(item)) + if row >= self.available_tags.count(): + row = self.available_tags.count() - 1 + if row >= 0: + self.available_tags.scrollToItem(self.available_tags.item(row)) diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index 27a6a2352a..fc70f0579d 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -123,6 +123,8 @@ class Stack(QStackedWidget): # {{{ _('Tag Browser'), I('tags.png'), parent=parent, side_index=0, initial_side_size=200, shortcut=_('Shift+Alt+T')) + parent.tb_splitter.state_changed.connect( + self.tb_widget.set_pane_is_visible, Qt.QueuedConnection) parent.tb_splitter.addWidget(self.tb_widget) parent.tb_splitter.addWidget(parent.cb_splitter) parent.tb_splitter.setCollapsible(parent.tb_splitter.other_index, False) diff --git a/src/calibre/gui2/preferences/look_feel.py b/src/calibre/gui2/preferences/look_feel.py index 10c2fcfe95..b2ba87d1e0 100644 --- a/src/calibre/gui2/preferences/look_feel.py +++ b/src/calibre/gui2/preferences/look_feel.py @@ -5,10 +5,11 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' +from PyQt4.Qt import QApplication, QFont, QFontInfo, QFontDialog from calibre.gui2.preferences import ConfigWidgetBase, test_widget from calibre.gui2.preferences.look_feel_ui import Ui_Form -from calibre.gui2 import config, gprefs +from calibre.gui2 import config, gprefs, qt_app from calibre.utils.localization import available_translations, \ get_language, get_lang from calibre.utils.config import prefs @@ -56,12 +57,64 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): (_('Never'), 'never')] r('toolbar_text', gprefs, choices=choices) + self.current_font = None + self.change_font_button.clicked.connect(self.change_font) + + + def initialize(self): + ConfigWidgetBase.initialize(self) + self.current_font = gprefs['font'] + self.update_font_display() + + def restore_defaults(self): + ConfigWidgetBase.restore_defaults(self) + ofont = self.current_font + self.current_font = None + if ofont is not None: + self.changed_signal.emit() + self.update_font_display() + + def build_font_obj(self): + font_info = self.current_font + if font_info is not None: + font = QFont(*font_info) + else: + font = qt_app.original_font + return font + + def update_font_display(self): + font = self.build_font_obj() + fi = QFontInfo(font) + name = unicode(fi.family()) + + self.font_display.setFont(font) + self.font_display.setText(_('Current font:') + ' ' + name + + ' [%dpt]'%fi.pointSize()) + + def change_font(self, *args): + fd = QFontDialog(self.build_font_obj(), self) + if fd.exec_() == fd.Accepted: + font = fd.selectedFont() + fi = QFontInfo(font) + self.current_font = (unicode(fi.family()), fi.pointSize(), + fi.weight(), fi.italic()) + self.update_font_display() + self.changed_signal.emit() + + def commit(self, *args): + rr = ConfigWidgetBase.commit(self, *args) + if self.current_font != gprefs['font']: + gprefs['font'] = self.current_font + QApplication.setFont(self.font_display.font()) + rr = True + return rr + + def refresh_gui(self, gui): gui.search.search_as_you_type(config['search_as_you_type']) - + self.update_font_display() if __name__ == '__main__': - from PyQt4.Qt import QApplication app = QApplication([]) test_widget('Interface', 'Look & Feel') diff --git a/src/calibre/gui2/preferences/look_feel.ui b/src/calibre/gui2/preferences/look_feel.ui index 1de55d51ef..91f45a155f 100644 --- a/src/calibre/gui2/preferences/look_feel.ui +++ b/src/calibre/gui2/preferences/look_feel.ui @@ -183,7 +183,7 @@ - + Qt::Vertical @@ -196,6 +196,20 @@ + + + + true + + + + + + + Change &font (needs restart) + + + diff --git a/src/calibre/gui2/preferences/main.py b/src/calibre/gui2/preferences/main.py index fc01a33cf6..f7d49427c8 100644 --- a/src/calibre/gui2/preferences/main.py +++ b/src/calibre/gui2/preferences/main.py @@ -251,10 +251,28 @@ class Preferences(QMainWindow): self.close() self.run_wizard_requested.emit() + def set_tooltips_for_labels(self): + + def process_child(child): + for g in child.children(): + if isinstance(g, QLabel): + buddy = g.buddy() + if buddy is not None and hasattr(buddy, 'toolTip'): + htext = unicode(buddy.toolTip()).strip() + etext = unicode(g.toolTip()).strip() + if htext and not etext: + g.setToolTip(htext) + g.setWhatsThis(htext) + else: + process_child(g) + + process_child(self.showing_widget) + def show_plugin(self, plugin): self.showing_widget = plugin.create_widget(self.scroll_area) self.showing_widget.genesis(self.gui) self.showing_widget.initialize() + self.set_tooltips_for_labels() self.scroll_area.setWidget(self.showing_widget) self.stack.setCurrentIndex(1) self.showing_widget.show() diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 2ede698c85..f75061da12 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -87,6 +87,13 @@ class TagsView(QTreeView): # {{{ self.setDragDropMode(self.DropOnly) self.setDropIndicatorShown(True) self.setAutoExpandDelay(500) + self.pane_is_visible = False + + def set_pane_is_visible(self, to_what): + pv = self.pane_is_visible + self.pane_is_visible = to_what + if to_what and not pv: + self.recount() def set_database(self, db, tag_match, sort_by): self.hidden_categories = config['tag_browser_hidden_categories'] @@ -94,6 +101,7 @@ class TagsView(QTreeView): # {{{ hidden_categories=self.hidden_categories, search_restriction=None, drag_drop_finished=self.drag_drop_finished) + self.pane_is_visible = True # because TagsModel.init did a recount self.sort_by = sort_by self.tag_match = tag_match self.db = db @@ -300,7 +308,7 @@ class TagsView(QTreeView): # {{{ return self.isExpanded(idx) def recount(self, *args): - if self.disable_recounting: + if self.disable_recounting or not self.pane_is_visible: return self.refresh_signal_processed = True ci = self.currentIndex() @@ -969,6 +977,7 @@ class TagBrowserWidget(QWidget): # {{{ self._layout.setContentsMargins(0,0,0,0) parent.tags_view = TagsView(parent) + self.tags_view = parent.tags_view self._layout.addWidget(parent.tags_view) parent.sort_by = QComboBox(parent) @@ -998,6 +1007,9 @@ class TagBrowserWidget(QWidget): # {{{ _('Add your own categories to the Tag Browser')) parent.edit_categories.setStatusTip(parent.edit_categories.toolTip()) + def set_pane_is_visible(self, to_what): + self.tags_view.set_pane_is_visible(to_what) + # }}} diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index cb25f75d4a..7279b7f8df 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -234,7 +234,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ ######################### Search Restriction ########################## SearchRestrictionMixin.__init__(self) - self.apply_named_search_restriction(db.prefs['gui_restriction']) + if db.prefs['gui_restriction']: + self.apply_named_search_restriction(db.prefs['gui_restriction']) ########################### Cover Flow ################################ diff --git a/src/calibre/library/__init__.py b/src/calibre/library/__init__.py index 8ff23c0a0a..177c5063ac 100644 --- a/src/calibre/library/__init__.py +++ b/src/calibre/library/__init__.py @@ -19,12 +19,15 @@ def generate_test_db(library_path, max_tags=10 ): import random, string, os, sys, time + from calibre.constants import preferred_encoding if not os.path.exists(library_path): os.makedirs(library_path) + letters = string.letters.decode(preferred_encoding) + def randstr(length): - return ''.join(random.choice(string.letters) for i in + return ''.join(random.choice(letters) for i in xrange(length)) all_tags = [randstr(tag_length) for j in xrange(num_of_tags)] diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 1229b60577..33e4295f05 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -10,11 +10,10 @@ import os, sys, shutil, cStringIO, glob, time, functools, traceback, re from itertools import repeat from math import floor from Queue import Queue -from operator import itemgetter from PyQt4.QtGui import QImage - +from calibre import prints from calibre.ebooks.metadata import title_sort, author_to_author_sort from calibre.ebooks.metadata.opf2 import metadata_to_opf from calibre.library.database import LibraryDatabase @@ -1039,43 +1038,175 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): tn=field['table'], col=field['link_column']), (id_,)) return set(x[0] for x in ans) +########## data structures for get_categories + CATEGORY_SORTS = ('name', 'popularity', 'rating') - def get_categories(self, sort='name', ids=None, icon_map=None): - self.books_list_filter.change([] if not ids else ids) + class TCat_Tag(object): - categories = {} + def __init__(self, name, sort): + self.n = name + self.s = sort + self.c = 0 + self.rt = 0 + self.rc = 0 + self.id = None + + def set_all(self, c, rt, rc, id): + self.c = c + self.rt = rt + self.rc = rc + self.id = id + + def __str__(self): + return unicode(self) + + def __unicode__(self): + return 'n=%s s=%s c=%d rt=%d rc=%d id=%s'%\ + (self.n, self.s, self.c, self.rt, self.rc, self.id) + + + def get_categories(self, sort='name', ids=None, icon_map=None): + #start = last = time.clock() if icon_map is not None and type(icon_map) != TagsIcons: raise TypeError('icon_map passed to get_categories must be of type TagIcons') + if sort not in self.CATEGORY_SORTS: + raise ValueError('sort ' + sort + ' not a valid value') + + self.books_list_filter.change([] if not ids else ids) + id_filter = None if not ids else frozenset(ids) tb_cats = self.field_metadata - #### First, build the standard and custom-column categories #### + tcategories = {} + tids = {} + md = [] + + # First, build the maps. We need a category->items map and an + # item -> (item_id, sort_val) map to use in the books loop for category in tb_cats.keys(): cat = tb_cats[category] - if not cat['is_category'] or cat['kind'] in ['user', 'search']: + if not cat['is_category'] or cat['kind'] in ['user', 'search'] \ + or category in ['news', 'formats']: continue - tn = cat['table'] - categories[category] = [] #reserve the position in the ordered list - if tn is None: # Nothing to do for the moment + # Get the ids for the item values + if not cat['is_custom']: + funcs = { + 'authors' : self.get_authors_with_ids, + 'series' : self.get_series_with_ids, + 'publisher': self.get_publishers_with_ids, + 'tags' : self.get_tags_with_ids, + 'rating' : self.get_ratings_with_ids, + } + func = funcs.get(category, None) + if func: + list = func() + else: + raise ValueError(category + ' has no get with ids function') + else: + list = self.get_custom_items_with_ids(label=cat['label']) + tids[category] = {} + if category == 'authors': + for l in list: + (id, val, sort_val) = (l[0], l[1], l[2]) + tids[category][val] = (id, sort_val) + else: + for l in list: + (id, val) = (l[0], l[1]) + tids[category][val] = (id, val) + # add an empty category to the category map + tcategories[category] = {} + # create a list of category/field_index for the books scan to use. + # This saves iterating through field_metadata for each book + md.append((category, cat['rec_index'], cat['is_multiple'])) + + #print 'end phase "collection":', time.clock() - last, 'seconds' + #last = time.clock() + + # Now scan every book looking for category items. + # Code below is duplicated because it shaves off 10% of the loop time + id_dex = self.FIELD_MAP['id'] + rating_dex = self.FIELD_MAP['rating'] + tag_class = LibraryDatabase2.TCat_Tag + for book in self.data.iterall(): + if id_filter and book[id_dex] not in id_filter: continue - cn = cat['column'] - if ids is None: - query = '''SELECT id, {0}, count, avg_rating, sort - FROM tag_browser_{1}'''.format(cn, tn) - else: - query = '''SELECT id, {0}, count, avg_rating, sort - FROM tag_browser_filtered_{1}'''.format(cn, tn) - if sort == 'popularity': - query += ' ORDER BY count DESC, sort ASC' - elif sort == 'name': - query += ' ORDER BY sort COLLATE icucollate' - else: - query += ' ORDER BY avg_rating DESC, sort ASC' - data = self.conn.get(query) + rating = book[rating_dex] + # We kept track of all possible category field_map positions above + for (cat, dex, mult) in md: + if book[dex] is None: + continue + if not mult: + val = book[dex] + try: + (item_id, sort_val) = tids[cat][val] # let exceptions fly + item = tcategories[cat].get(val, None) + if not item: + item = tag_class(val, sort_val) + tcategories[cat][val] = item + item.c += 1 + item.id = item_id + if rating > 0: + item.rt += rating + item.rc += 1 + except: + prints('get_categories: item', val, 'is not in', cat, 'list!') + else: + vals = book[dex].split(mult) + for val in vals: + try: + (item_id, sort_val) = tids[cat][val] # let exceptions fly + item = tcategories[cat].get(val, None) + if not item: + item = tag_class(val, sort_val) + tcategories[cat][val] = item + item.c += 1 + item.id = item_id + if rating > 0: + item.rt += rating + item.rc += 1 + except: + prints('get_categories: item', val, 'is not in', cat, 'list!') + + #print 'end phase "books":', time.clock() - last, 'seconds' + #last = time.clock() + + # Now do news + tcategories['news'] = {} + cat = tb_cats['news'] + tn = cat['table'] + cn = cat['column'] + if ids is None: + query = '''SELECT id, {0}, count, avg_rating, sort + FROM tag_browser_{1}'''.format(cn, tn) + else: + query = '''SELECT id, {0}, count, avg_rating, sort + FROM tag_browser_filtered_{1}'''.format(cn, tn) + # results will be sorted later + data = self.conn.get(query) + for r in data: + item = LibraryDatabase2.TCat_Tag(r[1], r[1]) + item.set_all(c=r[2], rt=r[2]*r[3], rc=r[2], id=r[0]) + tcategories['news'][r[1]] = item + + #print 'end phase "news":', time.clock() - last, 'seconds' + #last = time.clock() + + # Build the real category list by iterating over the temporary copy + # and building the Tag instances. + categories = {} + tag_class = Tag + for category in tb_cats.keys(): + if category not in tcategories: + continue + cat = tb_cats[category] + + # prepare the place where we will put the array of Tags + categories[category] = [] # icon_map is not None if get_categories is to store an icon and # possibly a tooltip in the tag structure. - icon, tooltip = None, '' + icon = None + tooltip = '' label = tb_cats.key_to_label(category) if icon_map: if not tb_cats.is_custom_field(category): @@ -1087,23 +1218,46 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): tooltip = self.custom_column_label_map[label]['name'] datatype = cat['datatype'] - avgr = itemgetter(3) - item_not_zero_func = lambda x: x[2] > 0 + avgr = lambda x: 0.0 if x.rc == 0 else x.rt/x.rc + # Duplicate the build of items below to avoid using a lambda func + # in the main Tag loop. Saves a few % if datatype == 'rating': - # eliminate the zero ratings line as well as count == 0 - item_not_zero_func = (lambda x: x[1] > 0 and x[2] > 0) formatter = (lambda x:u'\u2605'*int(x/2)) - avgr = itemgetter(1) + avgr = lambda x : x.n + # eliminate the zero ratings line as well as count == 0 + items = [v for v in tcategories[category].values() if v.c > 0 and v.n != 0] elif category == 'authors': # Clean up the authors strings to human-readable form formatter = (lambda x: x.replace('|', ',')) + items = [v for v in tcategories[category].values() if v.c > 0] else: formatter = (lambda x:unicode(x)) + items = [v for v in tcategories[category].values() if v.c > 0] - categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0], - avg=avgr(r), sort=r[4], icon=icon, + # sort the list + if sort == 'name': + def get_sort_key(x): + sk = x.s + if isinstance(sk, unicode): + sk = sort_key(sk) + return sk + kf = get_sort_key + reverse=False + elif sort == 'popularity': + kf = lambda x: x.c + reverse=True + else: + kf = avgr + reverse=True + items.sort(key=kf, reverse=reverse) + + categories[category] = [tag_class(formatter(r.n), count=r.c, id=r.id, + avg=avgr(r), sort=r.s, icon=icon, tooltip=tooltip, category=category) - for r in data if item_not_zero_func(r)] + for r in items] + + #print 'end phase "tags list":', time.clock() - last, 'seconds' + #last = time.clock() # Needed for legacy databases that have multiple ratings that # map to n stars @@ -1189,8 +1343,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): icon_map['search'] = icon_map['search'] categories['search'] = items + #print 'last phase ran in:', time.clock() - last, 'seconds' + #print 'get_categories ran in:', time.clock() - start, 'seconds' + return categories + ############# End get_categories + def tags_older_than(self, tag, delta): tag = tag.lower().strip() now = nowf() @@ -1486,6 +1645,12 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # Note: we generally do not need to refresh_ids because library_view will # refresh everything. + def get_ratings_with_ids(self): + result = self.conn.get('SELECT id,rating FROM ratings') + if not result: + return [] + return result + def dirty_books_referencing(self, field, id, commit=True): # Get the list of books to dirty -- all books that reference the item table = self.field_metadata[field]['table'] diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index af57d563ac..7090a2afa8 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -119,10 +119,8 @@ class SafeFormat(TemplateFormatter): try: b = self.book.get_user_metadata(key, False) except: - if DEBUG: - traceback.print_exc() + traceback.print_exc() b = None - if b is not None and b['datatype'] == 'composite': if key in self.composite_values: return self.composite_values[key] @@ -135,8 +133,7 @@ class SafeFormat(TemplateFormatter): return val.replace('/', '_').replace('\\', '_') return '' except: - if DEBUG: - traceback.print_exc() + traceback.print_exc() return key def get_components(template, mi, id, timefmt='%b %Y', length=250, @@ -155,6 +152,8 @@ def get_components(template, mi, id, timefmt='%b %Y', length=250, format_args['tags'] = mi.format_tags() if format_args['tags'].startswith('/'): format_args['tags'] = format_args['tags'][1:] + else: + format_args['tags'] = '' if mi.series: format_args['series'] = tsfmt(mi.series) if mi.series_index is not None: @@ -254,6 +253,7 @@ def do_save_book_to_disk(id_, mi, cover, plugboards, if not os.path.exists(dirpath): raise + ocover = mi.cover if opts.save_cover and cover and os.access(cover, os.R_OK): with open(base_path+'.jpg', 'wb') as f: with open(cover, 'rb') as s: @@ -267,6 +267,8 @@ def do_save_book_to_disk(id_, mi, cover, plugboards, with open(base_path+'.opf', 'wb') as f: f.write(opf) + mi.cover = ocover + written = False for fmt in formats: global plugboard_save_to_disk_value, plugboard_any_format_value diff --git a/src/calibre/utils/mem.py b/src/calibre/utils/mem.py new file mode 100644 index 0000000000..f48aec34c6 --- /dev/null +++ b/src/calibre/utils/mem.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +## {{{ http://code.activestate.com/recipes/286222/ (r1) +import os + +_proc_status = '/proc/%d/status' % os.getpid() + +_scale = {'kB': 1024.0, 'mB': 1024.0*1024.0, + 'KB': 1024.0, 'MB': 1024.0*1024.0} + +def _VmB(VmKey): + '''Private. + ''' + global _proc_status, _scale + # get pseudo file /proc//status + try: + t = open(_proc_status) + v = t.read() + t.close() + except: + return 0.0 # non-Linux? + # get VmKey line e.g. 'VmRSS: 9999 kB\n ...' + i = v.index(VmKey) + v = v[i:].split(None, 3) # whitespace + if len(v) < 3: + return 0.0 # invalid format? + # convert Vm value to bytes + return float(v[1]) * _scale[v[2]] + + +def memory(since=0.0): + '''Return memory usage in bytes. + ''' + return _VmB('VmSize:') - since + + +def resident(since=0.0): + '''Return resident memory usage in bytes. + ''' + return _VmB('VmRSS:') - since + + +def stacksize(since=0.0): + '''Return stack size in bytes. + ''' + return _VmB('VmStk:') - since +## end of http://code.activestate.com/recipes/286222/ }}} + + +