[Sync] Sync with trunk. Revision 7229

This commit is contained in:
Li Fanxi 2010-12-15 23:48:44 +08:00
commit 90e45787ed
164 changed files with 237204 additions and 125010 deletions

View File

@ -4,6 +4,81 @@
# for important features/bug fixes.
# Also, each release can have new and improved recipes.
- version: 0.7.33
date: 2010-12-10
new features:
- title: "Language sensitive sorting"
type: major
description: >
"calibre now sorts using language specific rules. The language used is the language of the calibre interface, which can be changed via Preferences->Look & Feel. There is also
a tweak that allows you to use a different language from the one used for the calibre interface. Powered by the ICU library."
- title: "Add an action to merge only formats and leave metadata alone (Shift+Alt+M)"
tickets: [7709]
- title: "Add a tweak to control which custom columns are displayed in the Book details panel."
- title: "Implement a more sophisticated 'functional programming' template language. See the User Manual for details."
- title: "Speed up deleting of large numbers of books and show progress while doing so"
- title: "Adding books: Dont refresh the Tag Browser while adding multiple books. Should speed up the adding of large numbers of books."
- title: "Edit metadata dialog: When trying to download metadata, if there are multiple matches indicate which matches have a cover and summary in the list. Also add an option to automatically download the cover of the selected match."
- title: "Drivers for the HTC Legend and Samsung Epic"
- title: "FB2 Output: Convert SVG images in the input document to raster images"
- title: "News download: Localize the navigation bars in the downloaded news to the language the user has selected for their calibre interface"
bug fixes:
- title: "Various fixes to the Title Case function"
tickets: [7846]
- title: "Content server: Fix --url-prefix being ignored for links at the Top level"
- title: "News download: When generating periodicals for the SONY use the local timezone in the SONY specific metadata"
- title: "Fix bug in cover cache that could cause it to keep a large number of covers in memory. Showed up when adding large numbers of books to calibre."
tickets: [7813]
- title: "Adding books: Run in the main thread to prevent unfortunate interactions with the metadata backup. Also fix regression that broke the Abort button."
- title: "Fix a crash on OS X if OK is clicked inthe edit metadata button while downloading a cover"
tickets: [7716]
- title: "E-book viewer: Fix a regression that prevented booksmarks from working with some EPUB files"
tickets: [7812]
- title: "Save to disk: Refactor to not open a database connection in the worker process. Also fix a bug that could lead to save failures not being reported."
- title: "Fix regression in 0.7.32 that broke opening formats in the ebook viewer from the edit metadata dialog"
- title: "FB2 Output: Generate output 100% compliant with the FB2 spec"
- title: "Fix Saved search dropdown box looses selected search"
tickets: [7787]
- title: "TXT Output: Fix an issue where the br to space conversion was not being handled properly."
improved recipes:
- Le Monde
- Ming Pao
- New Yorker
new recipes:
- title: "ToyoKeizai News and Nikkei Social News"
author: "Hiroshi Miura"
- title: "St. Louis Post Dispatch"
author: "cisaak"
- title: "Heise Open and Technology Review"
author: "Anton Gillert"
- version: 0.7.32
date: 2010-12-03

View File

@ -181,19 +181,25 @@ max_content_server_tags_shown=5
# content_server_will_display is a list of custom fields to be displayed.
# content_server_wont_display is a list of custom fields not to be displayed.
# wont_display has priority over will_display.
# The special value '*' means all custom fields.
# The special value '*' means all custom fields. The value [] means no entries.
# Defaults:
# content_server_will_display = ['*']
# content_server_wont_display = ['']
# content_server_wont_display = []
# Examples:
# To display only the custom fields #mytags and #genre:
# content_server_will_display = ['#mytags', '#genre']
# content_server_wont_display = ['']
# content_server_wont_display = []
# To display all fields except #mycomments:
# content_server_will_display = ['*']
# content_server_wont_display['#mycomments']
content_server_will_display = ['*']
content_server_wont_display = ['']
content_server_wont_display = []
# Same as above (content server) but for the book details pane. Same syntax.
# As above, this tweak affects only display of custom fields. The standard
# fields are not affected
book_details_will_display = ['*']
book_details_wont_display = []
# Set the maximum number of sort 'levels' that calibre will use to resort the

View File

@ -0,0 +1,23 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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')]

View File

@ -0,0 +1,37 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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

View File

@ -3,15 +3,16 @@ __copyright__ = '2009, Darko Miletic <darko.miletic at gmail.com>'
'''
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;}
'''

View File

@ -0,0 +1,38 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Anton Gillert <atx at binaryninja.de>'
'''
Fetch Heise Open.
'''
from calibre.web.feeds.news import BasicNewsRecipe
class HeiseOpenDe(BasicNewsRecipe):
title = 'Heise Open'
description = 'Opensource news from Germany'
__author__ = 'Anton Gillert'
use_embedded_content = False
language = 'de'
timefmt = ' [%d %b %Y]'
max_articles_per_feed = 40
no_stylesheets = True
feeds = [ ('Heise Open', 'http://www.heise.de/open/news/news-atom.xml') ]
def print_version(self, url):
return url + '?view=print'
remove_tags = [dict(id='navi_top'),
dict(id='navi_bottom'),
dict(name='div', attrs={'class':'navi_top_logo'}),
dict(name='img', attrs={'src':'/open/icons/open_logo_2009_weiss.gif'}),
dict(name='h5', attrs={'style':'margin: 0.5em 0;'}),
dict(name='p', attrs={'class':'news_datum'}),
dict(name='p', attrs={'class':'size80'})]
remove_tags_after = [dict(name='p', attrs={'class':'size80'})]
def get_cover_url(self):
return 'http://www.heise.de/open/icons/open_logo_2009_weiss.gif'

View File

@ -0,0 +1,31 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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"}]

View File

@ -0,0 +1,36 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
__license__ = 'GPL v3'
__copyright__ = '2010, Vadim Dyadkin, dyadkin@gmail.com'
__author__ = 'Vadim Dyadkin'
from calibre.web.feeds.news import BasicNewsRecipe
class Computerra(BasicNewsRecipe):
title = u'\u041a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0440\u0430'
recursion = 50
oldest_article = 100
__author__ = 'Vadim Dyadkin'
max_articles_per_feed = 100
use_embedded_content = False
simultaneous_downloads = 5
language = 'ru'
description = u'\u041a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u044b, \u043e\u043a\u043e\u043b\u043e\u043d\u0430\u0443\u0447\u043d\u044b\u0435 \u0438 \u043e\u043a\u043e\u043b\u043e\u0444\u0438\u043b\u043e\u0441\u043e\u0444\u0441\u043a\u0438\u0435 \u0441\u0442\u0430\u0442\u044c\u0438, \u0433\u0430\u0434\u0436\u0435\u0442\u044b.'
keep_only_tags = [dict(name='div', attrs={'id': 'content'}),]
feeds = [(u'\u041a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0440\u0430', 'http://feeds.feedburner.com/ct_news/'),]
remove_tags = [dict(name='div', attrs={'id': ['fin', 'idc-container', 'idc-noscript',]}),
dict(name='ul', attrs={'class': "related_post"}),
dict(name='p', attrs={'class': 'info'}),
dict(name='a', attrs={'rel': 'tag', 'class': 'twitter-share-button', 'type': 'button_count'}),
dict(name='h2', attrs={}),]
extra_css = 'body { text-align: justify; }'
def get_article_url(self, article):
return article.get('feedburner:origLink', article.get('guid'))

View File

@ -1,106 +1,89 @@
#!/usr/bin/env python
__license__ = 'GPL v3'
__copyright__ = '2008, Mathieu Godlewski <mathieu at godlewski.fr>'
'''
lemonde.fr
'''
import re
from calibre.web.feeds.news import BasicNewsRecipe
from calibre.web.feeds.recipes import BasicNewsRecipe
class LeMonde(BasicNewsRecipe):
title = 'LeMonde.fr'
__author__ = 'Mathieu Godlewski and Sujata Raman'
description = 'Global news in french'
oldest_article = 3
language = 'fr'
title = 'Le Monde'
__author__ = 'veezh'
description = 'Actualités'
oldest_article = 1
max_articles_per_feed = 100
no_stylesheets = True
#delay = 1
use_embedded_content = False
encoding = 'cp1252'
publisher = 'lemonde.fr'
language = 'fr'
conversion_options = {
'comments' : description
,'language' : language
,'publisher' : publisher
,'linearize_tables': True
}
max_articles_per_feed = 30
no_stylesheets = True
remove_javascript = True
remove_empty_feeds = True
filterDuplicates = True
# cover_url='http://abonnes.lemonde.fr/titresdumonde/'+date.today().strftime("%y%m%d")+'/1.jpg'
def preprocess_html(self, soup):
for alink in soup.findAll('a'):
if alink.string is not None:
tstr = alink.string
alink.replaceWith(tstr)
return soup
extra_css = '''
.dateline{color:#666666;font-family:verdana,sans-serif;font-size:x-small;}
.author{font-family:verdana,sans-serif;font-size:x-small;color:#222222;}
.articleImage{color:#666666;font-family:verdana,sans-serif;font-size:x-small;}
.mainText{font-family:Georgia,serif;color:#222222;}
.LM_articleText{font-family:Arial,Helvetica,sans-serif;}
.LM_titleZone{font-family:Arial,Helvetica,sans-serif;}
.mainContent{font-family:Georgia,serif;}
.LM_content{font-family:Georgia,serif;}
.LM_caption{font-family:Georgia,serif;font-size:-small;}
.LM_imageSource{font-family:Arial,Helvetica,sans-serif;font-size:x-small;color:#666666;}
h1{font-family:Arial,Helvetica,sans-serif;font-size:medium;color:#000000;}
.post{font-family:Arial,Helvetica,sans-serif;}
.mainTitle{font-family:Georgia,serif;}
.content{font-family:Georgia,serif;}
.entry{font-family:Georgia,serif;}
h2{font-family:Arial,Helvetica,sans-serif;font-size:large;}
small{font-family:Arial,Helvetica,sans-serif; color:#ED1B23;}
'''
feeds = [
('A la Une', 'http://www.lemonde.fr/rss/une.xml'),
('International', 'http://www.lemonde.fr/rss/sequence/0,2-3210,1-0,0.xml'),
('Europe', 'http://www.lemonde.fr/rss/sequence/0,2-3214,1-0,0.xml'),
('Societe', 'http://www.lemonde.fr/rss/sequence/0,2-3224,1-0,0.xml'),
('Economie', 'http://www.lemonde.fr/rss/sequence/0,2-3234,1-0,0.xml'),
('Medias', 'http://www.lemonde.fr/rss/sequence/0,2-3236,1-0,0.xml'),
('Rendez-vous', 'http://www.lemonde.fr/rss/sequence/0,2-3238,1-0,0.xml'),
('Sports', 'http://www.lemonde.fr/rss/sequence/0,2-3242,1-0,0.xml'),
('Planete', 'http://www.lemonde.fr/rss/sequence/0,2-3244,1-0,0.xml'),
('Culture', 'http://www.lemonde.fr/rss/sequence/0,2-3246,1-0,0.xml'),
('Technologies', 'http://www.lemonde.fr/rss/sequence/0,2-651865,1-0,0.xml'),
('Cinema', 'http://www.lemonde.fr/rss/sequence/0,2-3476,1-0,0.xml'),
('Voyages', 'http://www.lemonde.fr/rss/sequence/0,2-3546,1-0,0.xml'),
('Livres', 'http://www.lemonde.fr/rss/sequence/0,2-3260,1-0,0.xml'),
('Examens', 'http://www.lemonde.fr/rss/sequence/0,2-3404,1-0,0.xml'),
('Opinions', 'http://www.lemonde.fr/rss/sequence/0,2-3232,1-0,0.xml')
]
keep_only_tags = [dict(name='div', attrs={'id':["mainTitle","mainContent","LM_content","content"]}),
dict(name='div', attrs={'class':["post"]})
]
remove_tags = [dict(name='img', attrs={'src':'http://medias.lemonde.fr/mmpub/img/lgo/lemondefr_pet.gif'}),
dict(name='div', attrs={'id':'xiti-logo-noscript'}),
dict(name='br', attrs={}),
dict(name='iframe', attrs={}),
dict(name='table', attrs={'id':["toolBox"]}),
dict(name='table', attrs={'class':["bottomToolBox"]}),
dict(name='div', attrs={'class':["pageNavigation","LM_pagination","fenetreBoxesContainer","breakingNews","LM_toolsBottom","LM_comments","LM_tools","pave_meme_sujet_hidden","boxMemeSujet"]}),
dict(name='div', attrs={'id':["miniUne","LM_sideBar"]}),
]
preprocess_regexps = [ (re.compile(i[0], re.IGNORECASE|re.DOTALL), i[1]) for i in
[
(r'<html.*(<div class="post".*?>.*?</div>.*?<div class="entry">.*?</div>).*You can start editing here.*</html>', lambda match : '<html><body>'+match.group(1)+'</body></html>'),
(r'<p>&nbsp;</p>', lambda match : ''),
(r'<img src="http://medias\.lemonde\.fr/mmpub/img/let/(.)\.gif"[^>]*><div class=ar-txt>', lambda match : '<div class=ar-txt>'+match.group(1).upper()),
(r'<img src="http://medias\.lemonde\.fr/mmpub/img/let/q(.)\.gif"[^>]*><div class=ar-txt>', lambda match : '<div class=ar-txt>"'+match.group(1).upper()),
(r'(<div class=desc><b>.*</b></div>).*</body>', lambda match : match.group(1)),
preprocess_regexps = [
(re.compile(r' \''), lambda match: ' &lsquo;'),
(re.compile(r'\''), lambda match: '&rsquo;'),
(re.compile(r'"<'), lambda match: '&nbsp;&raquo;<'),
(re.compile(r'>"'), lambda match: '>&laquo;&nbsp;'),
(re.compile(r'&rsquo;"'), lambda match: '&rsquo;«&nbsp;'),
(re.compile(r' "'), lambda match: ' &laquo;&nbsp;'),
(re.compile(r'" '), lambda match: '&nbsp;&raquo; '),
(re.compile(r'\("'), lambda match: '(&laquo;&nbsp;'),
(re.compile(r'"\)'), lambda match: '&nbsp;&raquo;)'),
(re.compile(r'"\.'), lambda match: '&nbsp;&raquo;.'),
(re.compile(r'",'), lambda match: '&nbsp;&raquo;,'),
(re.compile(r'"\?'), lambda match: '&nbsp;&raquo;?'),
(re.compile(r'":'), lambda match: '&nbsp;&raquo;:'),
(re.compile(r'";'), lambda match: '&nbsp;&raquo;;'),
(re.compile(r'"\!'), lambda match: '&nbsp;&raquo;!'),
(re.compile(r' :'), lambda match: '&nbsp;:'),
(re.compile(r' ;'), lambda match: '&nbsp;;'),
(re.compile(r' \?'), lambda match: '&nbsp;?'),
(re.compile(r' \!'), lambda match: '&nbsp;!'),
(re.compile(r'\s»'), lambda match: '&nbsp;»'),
(re.compile(r'«\s'), lambda match: '«&nbsp;'),
(re.compile(r' %'), lambda match: '&nbsp;%'),
(re.compile(r'\.jpg&nbsp;&raquo; border='), lambda match: '.jpg'),
(re.compile(r'\.png&nbsp;&raquo; border='), lambda match: '.png'),
]
]
article_match_regexps = [ (re.compile(i)) for i in
[
(r'http://www\.lemonde\.fr/\S+/article/.*'),
(r'http://www\.lemonde\.fr/\S+/portfolio/.*'),
(r'http://www\.lemonde\.fr/\S+/article_interactif/.*'),
(r'http://\S+\.blog\.lemonde\.fr/.*'),
]
]
keep_only_tags = [
dict(name='div', attrs={'class':['contenu']})
]
# def print_version(self, url):
# return re.sub('http://www\.lemonde\.fr/.*_([0-9]+)_[0-9]+\.html.*','http://www.lemonde.fr/web/imprimer_element/0,40-0,50-\\1,0.html' ,url)
remove_tags_after = [dict(id='appel_temoignage')]
# Used to filter duplicated articles
articles_list = []
def get_article_url(self, article):
link = article.get('link')
if 'blog' not in link:
return link
feeds = [
('A la une', 'http://www.lemonde.fr/rss/une.xml'),
('International', 'http://www.lemonde.fr/rss/tag/international.xml'),
('Europe', 'http://www.lemonde.fr/rss/tag/europe.xml'),
(u'Société', 'http://www.lemonde.fr/rss/tag/societe.xml'),
('Economie', 'http://www.lemonde.fr/rss/tag/economie.xml'),
(u'Médias', 'http://www.lemonde.fr/rss/tag/actualite-medias.xml'),
(u'Planète', 'http://www.lemonde.fr/rss/tag/planete.xml'),
('Culture', 'http://www.lemonde.fr/rss/tag/culture.xml'),
('Technologies', 'http://www.lemonde.fr/rss/tag/technologies.xml'),
('Livres', 'http://www.lemonde.fr/rss/tag/livres.xml'),
]
def get_cover_url(self):
cover_url = None
@ -111,42 +94,3 @@ class LeMonde(BasicNewsRecipe):
cover_url = link_item.img['src']
return cover_url
def get_article_url(self, article):
url=article.get('link', None)
url=url[0:url.find("#")]
if url in self.articles_list:
self.log_debug(_('Skipping duplicated article: %s')%url)
return False
if self.is_article_wanted(url):
self.articles_list.append(url)
if '/portfolio/' in url or '/video/' in url:
url = None
return url
self.log_debug(_('Skipping filtered article: %s')%url)
url = article.get('guid', None)
return False
def is_article_wanted(self, url):
if self.article_match_regexps:
for m in self.article_match_regexps:
if m.search(url):
return True
return False
return False
def preprocess_html(self, soup):
for item in soup.findAll(style=True):
del item['style']
for item in soup.findAll(face=True):
del item['face']
for tag in soup.findAll(name=['ul','li']):
tag.name = 'div'
return soup

View File

@ -1,4 +1,5 @@
from calibre.web.feeds.news import BasicNewsRecipe
import re
class MainichiDailyITNews(BasicNewsRecipe):
title = u'\u6bce\u65e5\u65b0\u805e(IT&\u5bb6\u96fb)'
@ -14,6 +15,7 @@ class MainichiDailyITNews(BasicNewsRecipe):
remove_tags_before = {'class':"NewsTitle"}
remove_tags = [{'class':"RelatedArticle"}]
remove_tags_after = {'class':"Credit"}
def parse_feeds(self):
@ -29,4 +31,4 @@ class MainichiDailyITNews(BasicNewsRecipe):
index = curfeed.articles.index(d)
curfeed.articles[index:index+1] = []
return feeds remove_tags_after = {'class':"Credit"}
return feeds

View File

@ -1,8 +1,9 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Eddie Lau'
'''
modified from Singtao Toronto calibre recipe by rty
Change Log:
2010/12/07: add entertainment section, use newspaper front page as ebook cover, suppress date display in section list
(to avoid wrong date display in case the user generates the ebook in a time zone different from HKT)
2010/11/22: add English section, remove eco-news section which is not updated daily, correct
ordering of articles
2010/11/12: add news image and eco-news section
@ -17,14 +18,15 @@ from calibre.web.feeds.recipes import BasicNewsRecipe
from contextlib import nested
from calibre import __appname__, strftime
from calibre import __appname__
from calibre.ebooks.BeautifulSoup import BeautifulSoup
from calibre.ebooks.metadata.opf2 import OPFCreator
from calibre.ebooks.metadata.toc import TOC
from calibre.ebooks.metadata import MetaInformation
from calibre.utils.date import now as nowf
class MPHKRecipe(BasicNewsRecipe):
IsKindleUsed = True # to avoid generating periodical in which CJK characters can't be displayed in section/article view
title = 'Ming Pao - Hong Kong'
oldest_article = 1
max_articles_per_feed = 100
@ -39,13 +41,13 @@ class MPHKRecipe(BasicNewsRecipe):
encoding = 'Big5-HKSCS'
recursions = 0
conversion_options = {'linearize_tables':True}
extra_css = 'img {display: block; margin-left: auto; margin-right: auto; margin-top: 10px; margin-bottom: 10px;}'
#extra_css = 'img {float:right; margin:4px;}'
timefmt = ''
extra_css = 'img {display: block; margin-left: auto; margin-right: auto; margin-top: 10px; margin-bottom: 10px;} font>b {font-size:200%; font-weight:bold;}'
masthead_url = 'http://news.mingpao.com/image/portals_top_logo_news.gif'
keep_only_tags = [dict(name='h1'),
#dict(name='font', attrs={'style':['font-size:14pt; line-height:160%;']}), # for entertainment page
dict(name='font', attrs={'style':['font-size:14pt; line-height:160%;']}), # for entertainment page title
dict(attrs={'class':['photo']}),
dict(attrs={'id':['newscontent']}),
dict(attrs={'id':['newscontent']}), # entertainment page content
dict(attrs={'id':['newscontent01','newscontent02']})]
remove_tags = [dict(name='style'),
dict(attrs={'id':['newscontent135']})] # for the finance page
@ -55,51 +57,68 @@ class MPHKRecipe(BasicNewsRecipe):
lambda match: '<h1>'),
(re.compile(r'</h5>', re.DOTALL|re.IGNORECASE),
lambda match: '</h1>'),
(re.compile(r'<p><a href=.+?</a></p>', re.DOTALL|re.IGNORECASE), # for entertainment page
lambda match: '')
]
def image_url_processor(cls, baseurl, url):
# trick: break the url at the first occurance of digit, add an additional
# '_' at the front
# not working, may need to move this to preprocess_html() method
#minIdx = 10000
#i0 = url.find('0')
#if i0 >= 0 and i0 < minIdx:
# minIdx = i0
#i1 = url.find('1')
#if i1 >= 0 and i1 < minIdx:
# minIdx = i1
#i2 = url.find('2')
#if i2 >= 0 and i2 < minIdx:
# minIdx = i2
#i3 = url.find('3')
#if i3 >= 0 and i0 < minIdx:
# minIdx = i3
#i4 = url.find('4')
#if i4 >= 0 and i4 < minIdx:
# minIdx = i4
#i5 = url.find('5')
#if i5 >= 0 and i5 < minIdx:
# minIdx = i5
#i6 = url.find('6')
#if i6 >= 0 and i6 < minIdx:
# minIdx = i6
#i7 = url.find('7')
#if i7 >= 0 and i7 < minIdx:
# minIdx = i7
#i8 = url.find('8')
#if i8 >= 0 and i8 < minIdx:
# minIdx = i8
#i9 = url.find('9')
#if i9 >= 0 and i9 < minIdx:
# minIdx = i9
#return url[0:minIdx] + '_' + url[minIdx+1:]
# minIdx = 10000
# i0 = url.find('0')
# if i0 >= 0 and i0 < minIdx:
# minIdx = i0
# i1 = url.find('1')
# if i1 >= 0 and i1 < minIdx:
# minIdx = i1
# i2 = url.find('2')
# if i2 >= 0 and i2 < minIdx:
# minIdx = i2
# i3 = url.find('3')
# if i3 >= 0 and i0 < minIdx:
# minIdx = i3
# i4 = url.find('4')
# if i4 >= 0 and i4 < minIdx:
# minIdx = i4
# i5 = url.find('5')
# if i5 >= 0 and i5 < minIdx:
# minIdx = i5
# i6 = url.find('6')
# if i6 >= 0 and i6 < minIdx:
# minIdx = i6
# i7 = url.find('7')
# if i7 >= 0 and i7 < minIdx:
# minIdx = i7
# i8 = url.find('8')
# if i8 >= 0 and i8 < minIdx:
# minIdx = i8
# i9 = url.find('9')
# if i9 >= 0 and i9 < minIdx:
# minIdx = i9
return url
def get_fetchdate(self):
def get_dtlocal(self):
dt_utc = datetime.datetime.utcnow()
# convert UTC to local hk time - at around HKT 6.00am, all news are available
dt_local = dt_utc - datetime.timedelta(-2.0/24)
return dt_local.strftime("%Y%m%d")
return dt_local
def get_fetchdate(self):
return self.get_dtlocal().strftime("%Y%m%d")
def get_fetchday(self):
# convert UTC to local hk time - at around HKT 6.00am, all news are available
return self.get_dtlocal().strftime("%d")
def get_cover_url(self):
cover = 'http://news.mingpao.com/' + self.get_fetchdate() + '/' + self.get_fetchdate() + '_' + self.get_fetchday() + 'gacov.jpg'
br = BasicNewsRecipe.get_browser()
try:
br.open(cover)
except:
cover = None
return cover
def parse_index(self):
feeds = []
@ -127,9 +146,9 @@ class MPHKRecipe(BasicNewsRecipe):
# if eco_articles:
# feeds.append((u'\u74b0\u4fdd Eco News', eco_articles))
# special - entertainment
#ent_articles = self.parse_ent_section('http://ol.mingpao.com/cfm/star1.cfm')
#if ent_articles:
# feeds.append(('Entertainment', ent_articles))
ent_articles = self.parse_ent_section('http://ol.mingpao.com/cfm/star1.cfm')
if ent_articles:
feeds.append((u'\u5f71\u8996 Entertainment', ent_articles))
return feeds
def parse_section(self, url):
@ -164,6 +183,7 @@ class MPHKRecipe(BasicNewsRecipe):
return current_articles
def parse_eco_section(self, url):
dateStr = self.get_fetchdate()
soup = self.index_to_soup(url)
divs = soup.findAll(attrs={'class': ['bullet']})
current_articles = []
@ -173,23 +193,25 @@ class MPHKRecipe(BasicNewsRecipe):
title = self.tag_to_string(a)
url = a.get('href', False)
url = 'http://tssl.mingpao.com/htm/marketing/eco/cfm/' +url
if url not in included_urls and url.rfind('Redirect') == -1:
if url not in included_urls and url.rfind('Redirect') == -1 and not url.rfind('.txt') == -1 and not url.rfind(dateStr) == -1:
current_articles.append({'title': title, 'url': url, 'description':''})
included_urls.append(url)
return current_articles
#def parse_ent_section(self, url):
# dateStr = self.get_fetchdate()
# soup = self.index_to_soup(url)
# a = soup.findAll('a', href=True)
# current_articles = []
# included_urls = []
# for i in a:
# title = self.tag_to_string(i)
# url = 'http://ol.mingpao.com/cfm/' + i.get('href', False)
# if url not in included_urls and not url.rfind('.txt') == -1 and not url.rfind(dateStr) == -1 and not title == '':
# current_articles.append({'title': title, 'url': url, 'description': ''})
# return current_articles
def parse_ent_section(self, url):
soup = self.index_to_soup(url)
a = soup.findAll('a', href=True)
a.reverse()
current_articles = []
included_urls = []
for i in a:
title = self.tag_to_string(i)
url = 'http://ol.mingpao.com/cfm/' + i.get('href', False)
if (url not in included_urls) and (not url.rfind('.txt') == -1) and (not url.rfind('star') == -1):
current_articles.append({'title': title, 'url': url, 'description': ''})
included_urls.append(url)
current_articles.reverse()
return current_articles
def preprocess_html(self, soup):
for item in soup.findAll(style=True):
@ -201,21 +223,26 @@ class MPHKRecipe(BasicNewsRecipe):
return soup
def create_opf(self, feeds, dir=None):
#super(MPHKRecipe,self).create_opf(feeds, dir)
if self.IsKindleUsed == False:
super(MPHKRecipe,self).create_opf(feeds, dir)
return
if dir is None:
dir = self.output_dir
title = self.short_title()
if self.output_profile.periodical_date_in_title:
title += strftime(self.timefmt)
title += ' ' + self.get_fetchdate()
#if self.output_profile.periodical_date_in_title:
# title += strftime(self.timefmt)
mi = MetaInformation(title, [__appname__])
mi.publisher = __appname__
mi.author_sort = __appname__
mi.publication_type = self.publication_type+':'+self.short_title()
mi.timestamp = nowf()
#mi.timestamp = nowf()
mi.timestamp = self.get_dtlocal()
mi.comments = self.description
if not isinstance(mi.comments, unicode):
mi.comments = mi.comments.decode('utf-8', 'replace')
mi.pubdate = nowf()
#mi.pubdate = nowf()
mi.pubdate = self.get_dtlocal()
opf_path = os.path.join(dir, 'index.opf')
ncx_path = os.path.join(dir, 'index.ncx')
opf = OPFCreator(dir, mi)

View File

@ -0,0 +1,38 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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

View File

@ -0,0 +1,20 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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)

View File

@ -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'

View File

@ -0,0 +1,58 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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

View File

@ -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)

View File

@ -14,7 +14,7 @@ class TheHeiseOnline(BasicNewsRecipe):
oldest_article = 3
description = 'In association with Heise Online'
publisher = 'Heise Media UK Ltd.'
category = 'news, technology, security'
category = 'news, technology, security, OSS, internet'
max_articles_per_feed = 100
language = 'en'
encoding = 'utf-8'
@ -27,6 +27,12 @@ class TheHeiseOnline(BasicNewsRecipe):
feeds = [
(u'The H News Feed', u'http://www.h-online.com/news/atom.xml')
]
cover_url = 'http://www.h-online.com/icons/logo_theH.gif'
remove_tags = [
dict(id="logo"),
dict(id="footer")
]
def print_version(self, url):
return url + '?view=print'

View File

@ -4,6 +4,7 @@ __copyright__ = '2008-2010, Darko Miletic <darko.miletic at gmail.com>'
economictimes.indiatimes.com
'''
from calibre.web.feeds.news import BasicNewsRecipe
class TheEconomicTimes(BasicNewsRecipe):
@ -32,18 +33,17 @@ class TheEconomicTimes(BasicNewsRecipe):
, 'language' : language
}
keep_only_tags = [dict(attrs={'class':'printdiv'})]
remove_tags = [dict(name=['object','link','embed','iframe','base','table','meta'])]
remove_attributes = ['name']
remove_tags_before = dict(name='h1')
feeds = [(u'All articles', u'http://economictimes.indiatimes.com/rssfeedsdefault.cms')]
def print_version(self, url):
rest, sep, art = url.rpartition('/articleshow/')
return 'http://m.economictimes.com/PDAET/articleshow/' + art
return 'http://economictimes.indiatimes.com/articleshow/' + art + '?prtpage=1'
def get_article_url(self, article):
rurl = article.get('link', None)
rurl = article.get('guid', None)
if (rurl.find('/quickieslist/') > 0) or (rurl.find('/quickiearticleshow/') > 0):
return None
return rurl

View File

@ -8,9 +8,10 @@ class TimesOfIndia(BasicNewsRecipe):
max_articles_per_feed = 25
no_stylesheets = True
keep_only_tags = [dict(attrs={'class':'prttabl'})]
keep_only_tags = [dict(attrs={'class':'maintable12'})]
remove_tags = [
dict(style=lambda x: x and 'float' in x)
dict(style=lambda x: x and 'float' in x),
dict(attrs={'class':'prvnxtbg'}),
]
feeds = [

View File

@ -0,0 +1,68 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
www.toyokeizai.net
'''
from calibre.web.feeds.news import BasicNewsRecipe
import re
class Toyokeizai(BasicNewsRecipe):
title = u'ToyoKeizai News'
__author__ = 'Hiroshi Miura'
oldest_article = 1
max_articles_per_feed = 50
description = 'Japanese traditional economy and business magazine, only for advanced subscribers supported'
publisher = 'Toyokeizai Shinbun Sha'
category = 'economy, magazine, japan'
language = 'ja'
encoding = 'euc-jp'
index = 'http://member.toyokeizai.net/news/'
remove_javascript = True
no_stylesheets = True
masthead_title = u'TOYOKEIZAI'
needs_subscription = True
timefmt = '[%y/%m/%d]'
recursions = 5
match_regexps =[ r'page/\d+']
keep_only_tags = [
dict(name='div', attrs={'class':['news']}),
dict(name='div', attrs={'class':["news_cont"]}),
dict(name='div', attrs={'class':["news_con"]}),
# dict(name='div', attrs={'class':["norightsMessage"]})
]
remove_tags = [{'class':"mt35 mgz"},
{'class':"mt20 newzia"},
{'class':"mt20 fontS"},
{'class':"bk_btn_m"},
dict(id='newzia_connect_member')
]
def parse_index(self):
feeds = []
soup = self.index_to_soup(self.index)
topstories = soup.find('ul',attrs={'class':'list6'})
if topstories:
newsarticles = []
for itt in topstories.findAll('li'):
itema = itt.find('a',href=True)
itemd = itt.find('span')
newsarticles.append({
'title' :itema.string
,'date' :re.compile(r"\- ").sub("",itemd.string)
,'url' :'http://member.toyokeizai.net' + itema['href']
,'description':itema['title']
})
feeds.append(('news', newsarticles))
return feeds
def get_browser(self):
br = BasicNewsRecipe.get_browser()
if self.username is not None and self.password is not None:
br.open('http://member.toyokeizai.net/norights/form/')
br.select_form(nr=0)
br['kaiin_id'] = self.username
br['password'] = self.password
br.submit()
return br

View File

@ -0,0 +1,37 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Anton Gillert <atx at binaryninja.de>'
'''
Fetch Technology Review.
'''
from time import strftime
from calibre.web.feeds.news import BasicNewsRecipe
class TechnologyReviewDe(BasicNewsRecipe):
title = 'Technology Review'
description = 'Technology news from Germany'
__author__ = 'Anton Gillert'
use_embedded_content = False
language = 'de'
timefmt = ' [%d %b %Y]'
max_articles_per_feed = 40
no_stylesheets = True
feeds = [ ('Technology Review', 'http://www.heise.de/tr/news-atom.xml') ]
def print_version(self, url):
return url + '?view=print'
remove_tags = [dict(id='navi_top'),
dict(id='navi_bottom'),
dict(name='div', attrs={'class':'navi_top_logo'}),
dict(name='img', attrs={'src':'/tr/icons/tr_logo2006.gif'}),
dict(name='p', attrs={'class':'size80'})]
remove_tags_after = [dict(name='p', attrs={'class':'size80'})]
def get_cover_url(self):
return 'http://www.heise-medien.de/presseinfo/bilder/tr/' + strftime("%y/tr%m%Y.jpg")

View File

@ -0,0 +1,36 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Hiroshi Miura <miurahr@linux.com>'
'''
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

View File

@ -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 = '<meta http-equiv="Content-Type" content="text/html; charset=' + self.encoding + '">'
soup.head.insert(0,mtag)
return soup

View File

@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__appname__ = 'calibre'
__version__ = '0.7.32'
__version__ = '0.7.33'
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
import re

View File

@ -65,6 +65,7 @@ class ORIZON(CYBOOK):
BCD = [0x319]
VENDOR_NAME = ['BOOKEEN', 'LINUX']
WINDOWS_MAIN_MEM = re.compile(r'(CYBOOK_ORIZON__-FD)|(FILE-STOR_GADGET)')
WINDOWS_CARD_A_MEM = re.compile('(CYBOOK_ORIZON__-SD)|(FILE-STOR_GADGET)')

View File

@ -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=""):

View File

@ -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()

View File

@ -240,7 +240,7 @@ class CollectionsBookList(BookList):
return 1
if y is None:
return -1
if isinstance(x, (unicode, str)):
if isinstance(x, basestring) and isinstance(y, basestring):
c = strcmp(force_unicode(x), force_unicode(y))
else:
c = cmp(x, y)

View File

@ -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',

View File

@ -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',

View File

@ -101,6 +101,13 @@ class EPUBOutput(OutputFormatPlugin):
)
),
OptionRecommendation(name='epub_flatten', recommended_value=False,
help=_('This option is needed only if you intend to use the EPUB'
' with FBReaderJ. It will flatten the file system inside the'
' EPUB, putting all files into the top level.')
),
])
recommendations = set([('pretty_print', True, OptionRecommendation.HIGH)])
@ -142,8 +149,12 @@ class EPUBOutput(OutputFormatPlugin):
def convert(self, oeb, output_path, input_plugin, opts, log):
self.log, self.opts, self.oeb = log, opts, oeb
#from calibre.ebooks.oeb.transforms.filenames import UniqueFilenames
#UniqueFilenames()(oeb, opts)
if self.opts.epub_flatten:
from calibre.ebooks.oeb.transforms.filenames import FlatFilenames
FlatFilenames()(oeb, opts)
else:
from calibre.ebooks.oeb.transforms.filenames import UniqueFilenames
UniqueFilenames()(oeb, opts)
self.workaround_ade_quirks()
self.workaround_webkit_quirks()

View File

@ -6,9 +6,11 @@ __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
from uuid import uuid4
import time
from calibre.constants import __appname__, __version__
from calibre import strftime, prepare_string_for_xml as xml
from calibre.utils.date import parse_date
SONY_METADATA = u'''\
<?xml version="1.0" encoding="utf-8"?>
@ -87,7 +89,8 @@ def sony_metadata(oeb):
pass
try:
date = unicode(m.date[0]).split('T')[0]
date = parse_date(unicode(m.date[0]),
as_utc=False).strftime('%Y-%m-%d')
except:
date = strftime('%Y-%m-%d')
try:
@ -101,7 +104,7 @@ def sony_metadata(oeb):
publisher=xml(publisher), issue_date=xml(date),
language=xml(language))
updated = strftime('%Y-%m-%dT%H:%M:%SZ')
updated = strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
def cal_id(x):
for k, v in x.attrib.items():

View File

@ -73,6 +73,10 @@ class FB2MLizer(object):
text = re.sub(r'(?miu)<p>\s*</p>', '', text)
text = re.sub(r'(?miu)\s+</p>', '</p>', text)
text = re.sub(r'(?miu)</p><p>', '</p>\n\n<p>', text)
if self.opts.insert_blank_line:
text = re.sub(r'(?miu)</p>', '</p><empty-line />', 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:

View File

@ -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('_', '-'))

View File

@ -145,7 +145,7 @@ class MetadataSource(Plugin): # {{{
setattr(w, '_'+x, cb)
cb.setChecked(c.get(x, True))
w._layout.addWidget(cb)
if self.has_html_comments:
cb = QCheckBox(_('Convert comments downloaded from %s to plain text')%(self.name))
setattr(w, '_textcomments', cb)
@ -276,12 +276,13 @@ def result_index(source, result):
return -1
def merge_results(one, two):
for x in two:
idx = result_index(one, x)
if idx < 0:
one.append(x)
else:
one[idx].smart_update(x)
if two is not None and one is not None:
for x in two:
idx = result_index(one, x)
if idx < 0:
one.append(x)
else:
one[idx].smart_update(x)
class MetadataSources(object):
@ -337,7 +338,7 @@ def search(title=None, author=None, publisher=None, isbn=None, isbndb_key=None,
manager(title, author, publisher, isbn, verbose)
manager.join()
results = list(fetchers[0].results)
results = list(fetchers[0].results) if fetchers else []
for fetcher in fetchers[1:]:
merge_results(results, fetcher.results)

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -29,6 +29,9 @@ from calibre.ebooks.metadata import MetaInformation
from calibre.ebooks.metadata.opf2 import OPFCreator, OPF
from calibre.ebooks.metadata.toc import TOC
class TopazError(ValueError):
pass
class EXTHHeader(object):
def __init__(self, raw, codec, title):
@ -239,7 +242,7 @@ class MobiReader(object):
self.base_css_rules = textwrap.dedent('''
blockquote { margin: 0em 0em 0em 2em; text-align: justify }
p { margin: 0em; text-align: justify }
p { margin: 0em; text-align: justify; text-indent: 1.5em }
.bold { font-weight: bold }
@ -259,7 +262,7 @@ class MobiReader(object):
raw = stream.read()
if raw.startswith('TPZ'):
raise ValueError(_('This is an Amazon Topaz book. It cannot be processed.'))
raise TopazError(_('This is an Amazon Topaz book. It cannot be processed.'))
self.header = raw[0:72]
self.name = self.header[:32].replace('\x00', '')
@ -832,6 +835,15 @@ class MobiReader(object):
im.save(open(path, 'wb'), format='JPEG')
def get_metadata(stream):
stream.seek(0)
try:
raw = stream.read(3)
except:
raw = ''
stream.seek(0)
if raw == 'TPZ':
from calibre.ebooks.metadata.topaz import get_metadata
return get_metadata(stream)
from calibre.utils.logging import Log
log = Log()
mi = MetaInformation(os.path.basename(stream.name), [_('Unknown')])
@ -861,7 +873,10 @@ def get_metadata(stream):
cover_index = mh.first_image_index + mh.exth.cover_offset
data = mh.section_data(int(cover_index))
else:
data = mh.section_data(mh.first_image_index)
try:
data = mh.section_data(mh.first_image_index)
except:
data = ''
buf = cStringIO.StringIO(data)
try:
im = PILImage.open(buf)

View File

@ -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

View File

@ -13,15 +13,16 @@ import cssutils
from calibre.ebooks.oeb.base import rewrite_links, urlnormalize
class RenameFiles(object):
class RenameFiles(object): # {{{
'''
Rename files and adjust all links pointing to them. Note that the spine
and manifest are not touched by this transform.
'''
def __init__(self, rename_map):
def __init__(self, rename_map, renamed_items_map = None):
self.rename_map = rename_map
self.renamed_items_map = renamed_items_map
def __call__(self, oeb, opts):
self.log = oeb.logger
@ -49,7 +50,6 @@ class RenameFiles(object):
if self.oeb.toc:
self.fix_toc_entry(self.oeb.toc)
def fix_toc_entry(self, toc):
if toc.href:
href = urlnormalize(toc.href)
@ -66,18 +66,22 @@ class RenameFiles(object):
self.fix_toc_entry(x)
def url_replacer(self, orig_url):
url = urlnormalize(orig_url)
path, frag = urldefrag(url)
href = self.current_item.abshref(path)
replacement = self.rename_map.get(href, None)
if replacement is None:
return orig_url
replacement = self.current_item.relhref(replacement)
if frag:
replacement += '#' + frag
return replacement
url = urlnormalize(orig_url)
path, frag = urldefrag(url)
if self.renamed_items_map:
orig_item = self.renamed_items_map.get(self.current_item.href, self.current_item)
else:
orig_item = self.current_item
class UniqueFilenames(object):
href = orig_item.abshref(path)
replacement = self.current_item.relhref(self.rename_map.get(href, href))
if frag:
replacement += '#' + frag
return replacement
# }}}
class UniqueFilenames(object): # {{{
'Ensure that every item in the manifest has a unique filename'
@ -127,4 +131,48 @@ class UniqueFilenames(object):
candidate = base + suffix + ext
if candidate not in self.seen_filenames:
return suffix
# }}}
class FlatFilenames(object): # {{{
'Ensure that every item in the manifest has a unique filename without subdirectories.'
def __call__(self, oeb, opts):
self.log = oeb.logger
self.opts = opts
self.oeb = oeb
self.rename_map = {}
self.renamed_items_map = {}
for item in list(oeb.manifest.items):
# Flatten URL by removing directories.
# Example: a/b/c/index.html -> a_b_c_index.html
nhref = item.href.replace("/", "_")
if item.href == nhref:
# URL hasn't changed, skip item.
continue
data = item.data
nhref = oeb.manifest.generate(href=nhref)[1]
nitem = oeb.manifest.add(item.id, nhref, item.media_type, data=data,
fallback=item.fallback)
self.rename_map[item.href] = nhref
self.renamed_items_map[nhref] = item
if item.spine_position is not None:
oeb.spine.insert(item.spine_position, nitem, item.linear)
oeb.spine.remove(item)
oeb.manifest.remove(item)
if self.rename_map:
self.log('Found non-flat filenames, renaming to support broken'
' EPUB readers like FBReader...')
from pprint import pformat
self.log.debug(pformat(self.rename_map))
self.log.debug(pformat(self.renamed_items_map))
renamer = RenameFiles(self.rename_map, self.renamed_items_map)
renamer(oeb, opts)
# }}}

View File

@ -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':

View File

@ -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

View File

@ -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:

View File

@ -6,6 +6,7 @@ __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
from functools import partial
from zipfile import ZipFile
from PyQt4.Qt import QToolButton, QAction, QIcon, QObject
@ -108,6 +109,30 @@ class InterfaceAction(QObject):
setattr(self, attr, action)
return action
def load_resource(self, name):
'''
If this plugin comes in a ZIP file (user added plugin), this method
will allow you to load resources from the ZIP file.
For example to load an image::
pixmap = QPixmap()
pixmap.loadFromData(self.load_resource('images/icon.png'))
icon = QIcon(pixmap)
:param name: Path to resource in zip file using / as separator
'''
if self.plugin_path is None:
raise ValueError('This plugin was not loaded from a ZIP file')
with ZipFile(self.plugin_path, 'r') as zf:
for candidate in zf.namelist():
if candidate == name:
return zf.read(name)
raise ValueError('The name %r was not found in the plugin zip'
' file'%name)
def genesis(self):
'''
Setup this plugin. Only called once during initialization. self.gui is

View File

@ -61,6 +61,7 @@ class AddAction(InterfaceAction):
self._adder = Adder(self.gui,
self.gui.library_view.model().db,
self.Dispatcher(self._files_added), spare_server=self.gui.spare_server)
self.gui.tags_view.disable_recounting = True
self._adder.add_recursive(root, single)
def add_recursive_single(self, *args):
@ -119,6 +120,7 @@ class AddAction(InterfaceAction):
if self.gui.current_view() is not self.gui.library_view:
return
db = self.gui.library_view.model().db
cover_changed = False
current_idx = self.gui.library_view.currentIndex()
if not current_idx.isValid(): return
cid = db.id(current_idx.row())
@ -132,12 +134,16 @@ class AddAction(InterfaceAction):
if not pmap.isNull():
accept = True
db.set_cover(cid, pmap)
cover_changed = True
elif ext in BOOK_EXTENSIONS:
db.add_format_with_hooks(cid, ext, path, index_is_id=True)
accept = True
if accept:
event.accept()
self.gui.library_view.model().current_changed(current_idx, current_idx)
if cover_changed:
if self.gui.cover_flow:
self.gui.cover_flow.dataChanged()
def __add_filesystem_book(self, paths, allow_device=True):
if isinstance(paths, basestring):
@ -201,9 +207,11 @@ class AddAction(InterfaceAction):
self._adder = Adder(self.gui,
None if to_device else self.gui.library_view.model().db,
self.Dispatcher(self.__adder_func), spare_server=self.gui.spare_server)
self.gui.tags_view.disable_recounting = True
self._adder.add(paths)
def _files_added(self, paths=[], names=[], infos=[], on_card=None):
self.gui.tags_view.disable_recounting = False
if paths:
self.gui.upload_books(paths,
list(map(ascii_filename, names)),
@ -214,6 +222,7 @@ class AddAction(InterfaceAction):
self.gui.library_view.model().books_added(self._adder.number_of_books_added)
if hasattr(self.gui, 'db_images'):
self.gui.db_images.reset()
self.gui.tags_view.recount()
if getattr(self._adder, 'merged_books', False):
books = u'\n'.join([x if isinstance(x, unicode) else
x.decode(preferred_encoding, 'replace') for x in

View File

@ -160,15 +160,17 @@ class ChooseLibraryAction(InterfaceAction):
self.action_choose.triggered.connect(self.choose_library,
type=Qt.QueuedConnection)
self.choose_menu = QMenu(self.gui)
self.choose_menu.addAction(self.action_choose)
self.qaction.setMenu(self.choose_menu)
self.quick_menu = QMenu(_('Quick switch'))
self.quick_menu_action = self.choose_menu.addMenu(self.quick_menu)
self.rename_menu = QMenu(_('Rename library'))
self.rename_menu_action = self.choose_menu.addMenu(self.rename_menu)
self.delete_menu = QMenu(_('Delete library'))
self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu)
if not os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
self.choose_menu.addAction(self.action_choose)
self.quick_menu = QMenu(_('Quick switch'))
self.quick_menu_action = self.choose_menu.addMenu(self.quick_menu)
self.rename_menu = QMenu(_('Rename library'))
self.rename_menu_action = self.choose_menu.addMenu(self.rename_menu)
self.delete_menu = QMenu(_('Delete library'))
self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu)
self.rename_separator = self.choose_menu.addSeparator()
@ -223,6 +225,8 @@ class ChooseLibraryAction(InterfaceAction):
self.library_changed(self.gui.library_view.model().db)
def build_menus(self):
if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
return
db = self.gui.library_view.model().db
locations = list(self.stats.locations(db))
for ac in self.switch_actions:
@ -387,6 +391,11 @@ class ChooseLibraryAction(InterfaceAction):
c.exec_()
def change_library_allowed(self):
if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
warning_dialog(self.gui, _('Not allowed'),
_('You cannot change libraries while using the environment'
' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)
return False
if self.gui.job_manager.has_jobs():
warning_dialog(self.gui, _('Not allowed'),
_('You cannot change libraries while jobs'

View File

@ -12,7 +12,7 @@ from threading import Thread
from PyQt4.Qt import QMenu, QToolButton
from calibre.gui2.actions import InterfaceAction
from calibre.gui2 import error_dialog, Dispatcher
from calibre.gui2 import error_dialog, Dispatcher, warning_dialog
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.utils.config import prefs, tweaks
@ -106,6 +106,9 @@ class CopyToLibraryAction(InterfaceAction):
def build_menus(self):
self.menu.clear()
if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None):
self.menu.addAction('disabled', self.cannot_do_dialog)
return
db = self.gui.library_view.model().db
locations = list(self.stats.locations(db))
for name, loc in locations:
@ -160,5 +163,9 @@ class CopyToLibraryAction(InterfaceAction):
self.gui.iactions['Remove Books'].library_ids_deleted(
self.worker.processed, row)
def cannot_do_dialog(self):
warning_dialog(self.gui, _('Not allowed'),
_('You cannot use other libraries while using the environment'
' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True)

View File

@ -5,13 +5,67 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
from PyQt4.Qt import QMenu
from functools import partial
from PyQt4.Qt import QMenu, QObject, QTimer
from calibre.gui2 import error_dialog
from calibre.gui2.dialogs.delete_matching_from_device import DeleteMatchingFromDeviceDialog
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.actions import InterfaceAction
single_shot = partial(QTimer.singleShot, 10)
class MultiDeleter(QObject):
def __init__(self, gui, rows, callback):
from calibre.gui2.dialogs.progress import ProgressDialog
QObject.__init__(self, gui)
self.model = gui.library_view.model()
self.ids = list(map(self.model.id, rows))
self.gui = gui
self.failures = []
self.deleted_ids = []
self.callback = callback
single_shot(self.delete_one)
self.pd = ProgressDialog(_('Deleting...'), parent=gui,
cancelable=False, min=0, max=len(self.ids))
self.pd.setModal(True)
self.pd.show()
def delete_one(self):
if not self.ids:
self.cleanup()
return
id_ = self.ids.pop()
title = 'id:%d'%id_
try:
title_ = self.model.db.title(id_, index_is_id=True)
if title_:
title = title_
self.model.db.delete_book(id_, notify=False, commit=False)
self.deleted_ids.append(id_)
except:
import traceback
self.failures.append((id_, title, traceback.format_exc()))
single_shot(self.delete_one)
self.pd.value += 1
self.pd.set_msg(_('Deleted') + ' ' + title)
def cleanup(self):
self.pd.hide()
self.pd = None
self.model.db.commit()
self.model.db.clean()
self.model.books_deleted()
self.gui.tags_view.recount()
self.callback(self.deleted_ids)
if self.failures:
msg = ['==> '+x[1]+'\n'+x[2] for x in self.failures]
error_dialog(self.gui, _('Failed to delete'),
_('Failed to delete some books, click the Show Details button'
' for details.'), det_msg='\n\n'.join(msg), show=True)
class DeleteAction(InterfaceAction):
name = 'Remove Books'
@ -179,8 +233,13 @@ class DeleteAction(InterfaceAction):
row = None
if ci.isValid():
row = ci.row()
ids_deleted = view.model().delete_books(rows)
self.library_ids_deleted(ids_deleted, row)
if len(rows) < 5:
ids_deleted = view.model().delete_books(rows)
self.library_ids_deleted(ids_deleted, row)
else:
self.__md = MultiDeleter(self.gui, rows,
partial(self.library_ids_deleted, current_row=row))
else:
if not confirm('<p>'+_('The selected books will be '
'<b>permanently deleted</b> '

View File

@ -54,6 +54,10 @@ class EditMetadataAction(InterfaceAction):
mb.addAction(_('Merge into first selected book - keep others'),
partial(self.merge_books, safe_merge=True),
Qt.AltModifier+Qt.Key_M)
mb.addSeparator()
mb.addAction(_('Merge only formats into first selected book - delete others'),
partial(self.merge_books, merge_only_formats=True),
Qt.AltModifier+Qt.ShiftModifier+Qt.Key_M)
self.merge_menu = mb
self.action_merge.setMenu(mb)
md.addSeparator()
@ -150,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()
@ -206,7 +212,7 @@ class EditMetadataAction(InterfaceAction):
self.gui.library_view.select_rows(ids)
# Merge books {{{
def merge_books(self, safe_merge=False):
def merge_books(self, safe_merge=False, merge_only_formats=False):
'''
Merge selected books in library.
'''
@ -220,6 +226,12 @@ class EditMetadataAction(InterfaceAction):
return error_dialog(self.gui, _('Cannot merge books'),
_('At least two books must be selected for merging'),
show=True)
if len(rows) > 5:
if not confirm('<p>'+_('You are about to merge more than 5 books. '
'Are you <b>sure</b> you want to proceed?')
+'</p>', 'merge_too_many_books', self.gui):
return
dest_id, src_books, src_ids = self.books_to_merge(rows)
title = self.gui.library_view.model().db.title(dest_id, index_is_id=True)
if safe_merge:
@ -234,6 +246,22 @@ class EditMetadataAction(InterfaceAction):
return
self.add_formats(dest_id, src_books)
self.merge_metadata(dest_id, src_ids)
elif merge_only_formats:
if not confirm('<p>'+_(
'Book formats from the selected books will be merged '
'into the <b>first selected book</b> (%s). '
'Metadata in the first selected book will not be changed.'
'Author, Title, ISBN and all other metadata will <i>not</i> be merged.<br><br>'
'After merger the second and subsequently '
'selected books, with any metadata they have will be <b>deleted</b>. <br><br>'
'All book formats of the first selected book will be kept '
'and any duplicate formats in the second and subsequently selected books '
'will be permanently <b>deleted</b> from your calibre library.<br><br> '
'Are you <b>sure</b> you want to proceed?')%title
+'</p>', 'merge_only_formats', self.gui):
return
self.add_formats(dest_id, src_books)
self.delete_books_after_merge(src_ids)
else:
if not confirm('<p>'+_(
'Book formats and metadata from the selected books will be merged '
@ -243,15 +271,10 @@ class EditMetadataAction(InterfaceAction):
'subsequently selected books will be <b>deleted</b>. <br><br>'
'All book formats of the first selected book will be kept '
'and any duplicate formats in the second and subsequently selected books '
'will be permanently <b>deleted</b> from your computer.<br><br> '
'will be permanently <b>deleted</b> from your calibre library.<br><br> '
'Are you <b>sure</b> you want to proceed?')%title
+'</p>', 'merge_books', self.gui):
return
if len(rows)>5:
if not confirm('<p>'+_('You are about to merge more than 5 books. '
'Are you <b>sure</b> you want to proceed?')
+'</p>', 'merge_too_many_books', self.gui):
return
self.add_formats(dest_id, src_books)
self.merge_metadata(dest_id, src_ids)
self.delete_books_after_merge(src_ids)

View File

@ -3,41 +3,55 @@ UI for adding books to the database and saving books to disk
'''
import os, shutil, time
from Queue import Queue, Empty
from threading import Thread
from functools import partial
from PyQt4.Qt import QThread, SIGNAL, QObject, QTimer, Qt, \
QProgressDialog
from PyQt4.Qt import QThread, QObject, Qt, QProgressDialog, pyqtSignal, QTimer
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2 import question_dialog, error_dialog, info_dialog
from calibre.ebooks.metadata.opf2 import OPF
from calibre.ebooks.metadata import MetaInformation
from calibre.constants import preferred_encoding, filesystem_encoding
from calibre.constants import preferred_encoding, filesystem_encoding, DEBUG
from calibre.utils.config import prefs
from calibre import prints
single_shot = partial(QTimer.singleShot, 75)
class DuplicatesAdder(QObject): # {{{
added = pyqtSignal(object)
adding_done = pyqtSignal()
class DuplicatesAdder(QThread): # {{{
# Add duplicate books
def __init__(self, parent, db, duplicates, db_adder):
QThread.__init__(self, parent)
QObject.__init__(self, parent)
self.db, self.db_adder = db, db_adder
self.duplicates = duplicates
self.duplicates = list(duplicates)
self.count = 0
single_shot(self.add_one)
def add_one(self):
if not self.duplicates:
self.adding_done.emit()
return
mi, cover, formats = self.duplicates.pop()
formats = [f for f in formats if not f.lower().endswith('.opf')]
id = self.db.create_book_entry(mi, cover=cover,
add_duplicates=True)
# here we add all the formats for dupe book record created above
self.db_adder.add_formats(id, formats)
self.db_adder.number_of_books_added += 1
self.count += 1
self.added.emit(self.count)
single_shot(self.add_one)
def run(self):
count = 1
for mi, cover, formats in self.duplicates:
formats = [f for f in formats if not f.lower().endswith('.opf')]
id = self.db.create_book_entry(mi, cover=cover,
add_duplicates=True)
# here we add all the formats for dupe book record created above
self.db_adder.add_formats(id, formats)
self.db_adder.number_of_books_added += 1
self.emit(SIGNAL('added(PyQt_PyObject)'), count)
count += 1
self.emit(SIGNAL('adding_done()'))
# }}}
class RecursiveFind(QThread): # {{{
update = pyqtSignal(object)
found = pyqtSignal(object)
def __init__(self, parent, db, root, single):
QThread.__init__(self, parent)
self.db = db
@ -50,8 +64,8 @@ class RecursiveFind(QThread): # {{{
for dirpath in os.walk(root):
if self.canceled:
return
self.emit(SIGNAL('update(PyQt_PyObject)'),
_('Searching in')+' '+dirpath[0])
self.update.emit(
_('Searching in')+' '+dirpath[0])
self.books += list(self.db.find_books_in_directory(dirpath[0],
self.single_book_per_directory))
@ -71,46 +85,55 @@ class RecursiveFind(QThread): # {{{
msg = unicode(err)
except:
msg = repr(err)
self.emit(SIGNAL('found(PyQt_PyObject)'), msg)
self.found.emit(msg)
return
self.books = [formats for formats in self.books if formats]
if not self.canceled:
self.emit(SIGNAL('found(PyQt_PyObject)'), self.books)
self.found.emit(self.books)
# }}}
class DBAdder(Thread): # {{{
class DBAdder(QObject): # {{{
def __init__(self, parent, db, ids, nmap):
QObject.__init__(self, parent)
def __init__(self, db, ids, nmap):
self.db, self.ids, self.nmap = db, dict(**ids), dict(**nmap)
self.end = False
self.critical = {}
self.number_of_books_added = 0
self.duplicates = []
self.names, self.paths, self.infos = [], [], []
Thread.__init__(self)
self.daemon = True
self.input_queue = Queue()
self.output_queue = Queue()
self.merged_books = set([])
def run(self):
while not self.end:
try:
id, opf, cover = self.input_queue.get(True, 0.2)
except Empty:
continue
name = self.nmap.pop(id)
title = None
try:
title = self.add(id, opf, cover, name)
except:
import traceback
self.critical[name] = traceback.format_exc()
title = name
self.output_queue.put(title)
def end(self):
self.input_queue.put((None, None, None))
def start(self):
try:
id, opf, cover = self.input_queue.get_nowait()
except Empty:
single_shot(self.start)
return
if id is None and opf is None and cover is None:
return
name = self.nmap.pop(id)
title = None
if DEBUG:
st = time.time()
try:
title = self.add(id, opf, cover, name)
except:
import traceback
self.critical[name] = traceback.format_exc()
title = name
self.output_queue.put(title)
if DEBUG:
prints('Added', title, 'to db in:', time.time() - st, 'seconds')
single_shot(self.start)
def process_formats(self, opf, formats):
imp = opf[:-4]+'.import'
@ -201,10 +224,10 @@ class Adder(QObject): # {{{
self.pd.setModal(True)
self.pd.show()
self._parent = parent
self.rfind = self.worker = self.timer = None
self.rfind = self.worker = None
self.callback = callback
self.callback_called = False
self.connect(self.pd, SIGNAL('canceled()'), self.canceled)
self.pd.canceled_signal.connect(self.canceled)
def add_recursive(self, root, single=True):
self.path = root
@ -213,10 +236,8 @@ class Adder(QObject): # {{{
self.pd.set_max(0)
self.pd.value = 0
self.rfind = RecursiveFind(self, self.db, root, single)
self.connect(self.rfind, SIGNAL('update(PyQt_PyObject)'),
self.pd.set_msg, Qt.QueuedConnection)
self.connect(self.rfind, SIGNAL('found(PyQt_PyObject)'),
self.add, Qt.QueuedConnection)
self.rfind.update.connect(self.pd.set_msg, type=Qt.QueuedConnection)
self.rfind.found.connect(self.add, type=Qt.QueuedConnection)
self.rfind.start()
def add(self, books):
@ -246,12 +267,12 @@ class Adder(QObject): # {{{
self.pd.set_min(0)
self.pd.set_max(len(self.ids))
self.pd.value = 0
self.db_adder = DBAdder(self.db, self.ids, self.nmap)
self.db_adder = DBAdder(self, self.db, self.ids, self.nmap)
self.db_adder.start()
self.last_added_at = time.time()
self.entry_count = len(self.ids)
self.continue_updating = True
QTimer.singleShot(200, self.update)
single_shot(self.update)
def canceled(self):
self.continue_updating = False
@ -260,14 +281,14 @@ class Adder(QObject): # {{{
if self.worker is not None:
self.worker.canceled = True
if hasattr(self, 'db_adder'):
self.db_adder.end = True
self.db_adder.end()
self.pd.hide()
if not self.callback_called:
self.callback(self.paths, self.names, self.infos)
self.callback_called = True
def duplicates_processed(self):
self.db_adder.end = True
self.db_adder.end()
if not self.callback_called:
self.callback(self.paths, self.names, self.infos)
self.callback_called = True
@ -300,7 +321,7 @@ class Adder(QObject): # {{{
if (time.time() - self.last_added_at) > self.ADD_TIMEOUT:
self.continue_updating = False
self.pd.hide()
self.db_adder.end = True
self.db_adder.end()
if not self.callback_called:
self.callback([], [], [])
self.callback_called = True
@ -311,7 +332,7 @@ class Adder(QObject): # {{{
'find the problem book.'), show=True)
if self.continue_updating:
QTimer.singleShot(200, self.update)
single_shot(self.update)
def process_duplicates(self):
@ -332,11 +353,8 @@ class Adder(QObject): # {{{
self.__p_d = pd
self.__d_a = DuplicatesAdder(self._parent, self.db, duplicates,
self.db_adder)
self.connect(self.__d_a, SIGNAL('added(PyQt_PyObject)'),
pd.setValue)
self.connect(self.__d_a, SIGNAL('adding_done()'),
self.duplicates_processed)
self.__d_a.start()
self.__d_a.added.connect(pd.setValue)
self.__d_a.adding_done.connect(self.duplicates_processed)
else:
return self.duplicates_processed()
@ -407,14 +425,12 @@ class Saver(QObject): # {{{
self.worker = SaveWorker(self.rq, db, self.ids, path, self.opts,
spare_server=self.spare_server)
self.pd.canceled_signal.connect(self.canceled)
self.timer = QTimer(self)
self.connect(self.timer, SIGNAL('timeout()'), self.update)
self.timer.start(200)
self.continue_updating = True
single_shot(self.update)
def canceled(self):
if self.timer is not None:
self.timer.stop()
self.continue_updating = False
if self.worker is not None:
self.worker.canceled = True
self.pd.hide()
@ -424,27 +440,35 @@ class Saver(QObject): # {{{
def update(self):
if not self.ids or not self.worker.is_alive():
self.timer.stop()
self.pd.hide()
if not self.continue_updating:
return
if not self.worker.is_alive():
# Check that all ids were processed
while self.ids:
# Get all queued results since worker is dead
before = len(self.ids)
self.get_result()
if before == len(self.ids):
# No results available => worker died unexpectedly
for i in list(self.ids):
self.failures.add(('id:%d'%i, 'Unknown error'))
self.ids.remove(i)
break
if not self.ids:
self.continue_updating = False
self.pd.hide()
if not self.callback_called:
try:
self.worker.join(1.5)
# Give the worker time to clean up and set worker.error
self.worker.join(2)
except:
pass # The worker was not yet started
self.callback(self.worker.path, self.failures, self.worker.error)
self.callback_called = True
return
self.callback(self.worker.path, self.failures, self.worker.error)
self.get_result()
if self.continue_updating:
self.get_result()
single_shot(self.update)
def get_result(self):

View File

@ -253,7 +253,12 @@ class BookInfo(QWebView):
% (left_pane, right_pane)))
def mouseDoubleClickEvent(self, ev):
ev.ignore()
if self.width() - ev.x() < 25 or \
self.height() - ev.y() < 25:
# Filter out double clicks on the scroll bar
ev.accept()
else:
ev.ignore()
# }}}

View File

@ -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,

View File

@ -21,7 +21,7 @@ class PluginWidget(Widget, Ui_Form):
Widget.__init__(self, parent,
['dont_split_on_page_breaks', 'flow_size',
'no_default_epub_cover', 'no_svg_cover',
'preserve_cover_aspect_ratio',]
'preserve_cover_aspect_ratio', 'epub_flatten']
)
for i in range(2):
self.opt_no_svg_cover.toggle()

View File

@ -81,6 +81,13 @@
</property>
</spacer>
</item>
<item row="1" column="1">
<widget class="QCheckBox" name="opt_epub_flatten">
<property name="text">
<string>&amp;Flatten EPUB file structure</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>

View File

@ -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',

View File

@ -97,7 +97,7 @@
</item>
</layout>
</item>
<item row="3" column="0">
<item row="4" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Line &amp;height:</string>
@ -107,7 +107,7 @@
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<item row="4" column="1" colspan="2">
<widget class="QDoubleSpinBox" name="opt_line_height">
<property name="suffix">
<string> pt</string>
@ -117,7 +117,7 @@
</property>
</widget>
</item>
<item row="4" column="0">
<item row="5" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Input character &amp;encoding:</string>
@ -127,17 +127,17 @@
</property>
</widget>
</item>
<item row="4" column="1" colspan="3">
<item row="5" column="1" colspan="3">
<widget class="QLineEdit" name="opt_input_encoding"/>
</item>
<item row="5" column="0" colspan="2">
<item row="6" column="0" colspan="2">
<widget class="QCheckBox" name="opt_remove_paragraph_spacing">
<property name="text">
<string>Remove &amp;spacing between paragraphs</string>
</property>
</widget>
</item>
<item row="5" column="2" colspan="2">
<item row="6" column="2" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label_4">
@ -164,21 +164,21 @@
</item>
</layout>
</item>
<item row="6" column="0">
<item row="7" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Text justification:</string>
</property>
</widget>
</item>
<item row="7" column="0">
<item row="8" column="0">
<widget class="QCheckBox" name="opt_linearize_tables">
<property name="text">
<string>&amp;Linearize tables</string>
</property>
</widget>
</item>
<item row="10" column="0" colspan="4">
<item row="11" column="0" colspan="4">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Extra &amp;CSS</string>
@ -190,37 +190,60 @@
</layout>
</widget>
</item>
<item row="6" column="2" colspan="2">
<item row="7" column="2" colspan="2">
<widget class="QComboBox" name="opt_change_justification"/>
</item>
<item row="7" column="1" colspan="3">
<item row="8" column="1" colspan="3">
<widget class="QCheckBox" name="opt_asciiize">
<property name="text">
<string>&amp;Transliterate unicode characters to ASCII</string>
</property>
</widget>
</item>
<item row="8" column="0">
<item row="9" column="0">
<widget class="QCheckBox" name="opt_insert_blank_line">
<property name="text">
<string>Insert &amp;blank line</string>
</property>
</widget>
</item>
<item row="8" column="1" colspan="2">
<item row="9" column="1" colspan="2">
<widget class="QCheckBox" name="opt_keep_ligatures">
<property name="text">
<string>Keep &amp;ligatures</string>
</property>
</widget>
</item>
<item row="9" column="0">
<item row="10" column="0">
<widget class="QCheckBox" name="opt_smarten_punctuation">
<property name="text">
<string>Smarten &amp;punctuation</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Minimum &amp;line height:</string>
</property>
<property name="buddy">
<cstring>opt_minimum_line_height</cstring>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QDoubleSpinBox" name="opt_minimum_line_height">
<property name="suffix">
<string> %</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="maximum">
<double>900.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
<resources>

View File

@ -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

View File

@ -55,7 +55,7 @@
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<item row="6" column="0" colspan="2">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Kindle options</string>
@ -101,7 +101,7 @@
</layout>
</widget>
</item>
<item row="6" column="0">
<item row="7" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
@ -114,6 +114,13 @@
</property>
</spacer>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="opt_mobi_ignore_margins">
<property name="text">
<string>Ignore &amp;margins</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>

View File

@ -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(

View File

@ -23,10 +23,6 @@ class BookInfo(QDialog, Ui_BookInfo):
self.comments.sizeHint = self.comments_size_hint
self.view_func = view_func
desktop = QCoreApplication.instance().desktop()
screen_height = desktop.availableGeometry().height() - 100
self.resize(self.size().width(), screen_height)
self.view = view
self.current_row = None
@ -40,8 +36,13 @@ class BookInfo(QDialog, Ui_BookInfo):
self.fit_cover.stateChanged.connect(self.toggle_cover_fit)
self.cover.resizeEvent = self.cover_view_resized
desktop = QCoreApplication.instance().desktop()
screen_height = desktop.availableGeometry().height() - 100
self.resize(self.size().width(), screen_height)
def comments_size_hint(self):
return QSize(350, 350)
return QSize(350, 250)
def toggle_cover_fit(self, state):
dynamic.set('book_info_dialog_fit_cover', self.fit_cover.isChecked())

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>917</width>
<height>783</height>
<height>480</height>
</rect>
</property>
<property name="windowTitle">

View File

@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
__license__ = 'GPL v3'
from PyQt4.Qt import Qt, QDialog
from PyQt4.Qt import Qt, QDialog, QDialogButtonBox
from calibre.gui2.dialogs.comments_dialog_ui import Ui_CommentsDialog
class CommentsDialog(QDialog, Ui_CommentsDialog):
@ -20,3 +20,6 @@ class CommentsDialog(QDialog, Ui_CommentsDialog):
if text is not None:
self.textbox.setPlainText(text)
self.textbox.setTabChangesFocus(True)
self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK'))
self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel'))

View File

@ -19,15 +19,6 @@
<property name="windowTitle">
<string>Edit Comments</string>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>311</width>
<height>211</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPlainTextEdit" name="textbox"/>
@ -43,7 +34,6 @@
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections>

View File

@ -31,4 +31,5 @@ def confirm(msg, name, parent=None, pixmap='dialog_warning.png'):
d = Dialog(msg, name, parent)
d.label.setPixmap(QPixmap(I(pixmap)))
d.setWindowIcon(QIcon(I(pixmap)))
d.resize(d.sizeHint())
return d.exec_() == d.Accepted

View File

@ -8,13 +8,14 @@ from PyQt4.Qt import Qt, QDialog, QTableWidgetItem, QAbstractItemView
from calibre.ebooks.metadata import author_to_author_sort
from calibre.gui2 import error_dialog
from calibre.gui2.dialogs.edit_authors_dialog_ui import Ui_EditAuthorsDialog
from calibre.utils.icu import sort_key, strcmp
class tableItem(QTableWidgetItem):
def __ge__(self, other):
return unicode(self.text()).lower() >= unicode(other.text()).lower()
return sort_key(unicode(self.text())) >= sort_key(unicode(other.text()))
def __lt__(self, other):
return unicode(self.text()).lower() < unicode(other.text()).lower()
return sort_key(unicode(self.text())) < sort_key(unicode(other.text()))
class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog):
@ -36,6 +37,7 @@ class EditAuthorsDialog(QDialog, Ui_EditAuthorsDialog):
self.authors = {}
auts = db.get_authors_with_ids()
self.table.setRowCount(len(auts))
setattr(self.table, '__lt__', lambda x, y: True if strcmp(x, y) < 0 else False)
select_item = None
for row, (id, author, sort) in enumerate(auts):
author = author.replace('|', ',')

View File

@ -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,

View File

@ -381,7 +381,7 @@ Future conversion of these books will use the default settings.</string>
</property>
</widget>
</item>
<item row="15" column="0" colspan="3">
<item row="14" column="0" colspan="3">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
@ -394,6 +394,29 @@ Future conversion of these books will use the default settings.</string>
</property>
</spacer>
</item>
<item row="13" column="0" colspan="3">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Change &amp;cover</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QRadioButton" name="cover_generate">
<property name="text">
<string>&amp;Generate default cover</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="cover_remove">
<property name="text">
<string>&amp;Remove cover</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab">

View File

@ -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'),
_('<b>Could not fetch cover.</b><br/>')+
_('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'),
_('<b>Could not fetch cover.</b><br/>')+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'),
_('<b>Could not fetch cover.</b><br/>') +
_('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 = '<p>' + textwrap.fill(base+'<br><br>'+
_(' 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'

View File

@ -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):

View File

@ -105,9 +105,13 @@ class TagListEditor(QDialog, Ui_TagListEditor):
if not question_dialog(self, _('Are your sure?'),
'<p>'+_('Are you certain you want to delete the following items?')+'<br>'+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))

View File

@ -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)

View File

@ -349,10 +349,19 @@ class CcTemplateDelegate(QStyledItemDelegate): # {{{
QStyledItemDelegate.__init__(self, parent)
def createEditor(self, parent, option, index):
return EnLineEdit(parent)
m = index.model()
text = m.custom_columns[m.column_map[index.column()]]['display']['composite_template']
editor = CommentsDialog(parent, text)
editor.setWindowTitle(_("Edit template"))
editor.textbox.setTabChangesFocus(False)
editor.textbox.setTabStopWidth(20)
d = editor.exec_()
if d:
m.setData(index, QVariant(editor.textbox.toPlainText()), Qt.EditRole)
return None
def setModelData(self, editor, model, index):
val = unicode(editor.text())
val = unicode(editor.textbox.toPlainText())
try:
validation_formatter.validate(val)
except Exception, err:
@ -364,7 +373,7 @@ class CcTemplateDelegate(QStyledItemDelegate): # {{{
def setEditorData(self, editor, index):
m = index.model()
val = m.custom_columns[m.column_map[index.column()]]['display']['composite_template']
editor.setText(val)
editor.textbox.setPlainText(val)
# }}}

View File

@ -223,21 +223,22 @@ class BooksModel(QAbstractTableModel): # {{{
def by_author(self):
return self.sorted_on[0] == 'authors'
def books_deleted(self):
self.count_changed()
self.clear_caches()
self.reset()
def delete_books(self, indices):
ids = map(self.id, indices)
for id in ids:
self.db.delete_book(id, notify=False)
self.count_changed()
self.clear_caches()
self.reset()
self.books_deleted()
return ids
def delete_books_by_id(self, ids):
for id in ids:
self.db.delete_book(id)
self.count_changed()
self.clear_caches()
self.reset()
self.books_deleted()
def books_added(self, num):
if num > 0:
@ -302,6 +303,20 @@ class BooksModel(QAbstractTableModel): # {{{
return self.rowCount(None)
def get_book_display_info(self, idx):
def custom_keys_to_display():
ans = getattr(self, '_custom_fields_in_book_info', None)
if ans is None:
cfkeys = set(self.db.custom_field_keys())
yes_fields = set(tweaks['book_details_will_display'])
no_fields = set(tweaks['book_details_wont_display'])
if '*' in yes_fields:
yes_fields = cfkeys
if '*' in no_fields:
no_fields = cfkeys
ans = frozenset(yes_fields - no_fields)
setattr(self, '_custom_fields_in_book_info', ans)
return ans
data = {}
cdata = self.cover(idx)
if cdata:
@ -333,7 +348,10 @@ class BooksModel(QAbstractTableModel): # {{{
_('Book %s of %s.')%\
(sidx, prepare_string_for_xml(series))
mi = self.db.get_metadata(idx)
cf_to_display = custom_keys_to_display()
for key in mi.custom_field_keys():
if key not in cf_to_display:
continue
name, val = mi.format_field(key)
if val:
data[name] = val

View File

@ -105,7 +105,8 @@ class BooksView(QTableView): # {{{
hv.setCursor(Qt.PointingHandCursor)
self.selected_ids = []
self._model.about_to_be_sorted.connect(self.about_to_be_sorted)
self._model.sorting_done.connect(self.sorting_done)
self._model.sorting_done.connect(self.sorting_done,
type=Qt.QueuedConnection)
# Column Header Context Menu {{{
def column_header_context_handler(self, action=None, column=None):
@ -227,6 +228,7 @@ class BooksView(QTableView): # {{{
sm = self.selectionModel()
for idx in indices:
sm.select(idx, sm.Select|sm.Rows)
self.scroll_to_row(indices[0].row())
self.selected_ids = []
# }}}

View File

@ -5,10 +5,11 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__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(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')

View File

@ -183,7 +183,35 @@
</layout>
</widget>
</item>
<item row="8" column="0" colspan="2">
<item row="8" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Interface font:</string>
</property>
<property name="buddy">
<cstring>font_display</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="font_display">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item row="8" column="1">
<widget class="QPushButton" name="change_font_button">
<property name="text">
<string>Change &amp;font (needs restart)</string>
</property>
</widget>
</item>
<item row="9" column="0" colspan="2">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>

View File

@ -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()

View File

@ -73,6 +73,7 @@ class TagsView(QTreeView): # {{{
def __init__(self, parent=None):
QTreeView.__init__(self, parent=None)
self.tag_match = None
self.disable_recounting = False
self.setUniformRowHeights(True)
self.setCursor(Qt.PointingHandCursor)
self.setIconSize(QSize(30, 30))
@ -86,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']
@ -93,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
@ -299,6 +308,8 @@ class TagsView(QTreeView): # {{{
return self.isExpanded(idx)
def recount(self, *args):
if self.disable_recounting or not self.pane_is_visible:
return
self.refresh_signal_processed = True
ci = self.currentIndex()
if not ci.isValid():
@ -966,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)
@ -995,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)
# }}}

View File

@ -236,6 +236,10 @@ def fetch_scheduled_recipe(arg):
recs.append(('header', True, OptionRecommendation.HIGH))
recs.append(('header_format', '%t', OptionRecommendation.HIGH))
epub = load_defaults('epub_output')
if epub.get('epub_flatten', False):
recs.append(('epub_flatten', True, OptionRecommendation.HIGH))
args = [arg['recipe'], pt.name, recs]
if arg['username'] is not None:
recs.append(('username', arg['username'], OptionRecommendation.HIGH))

View File

@ -103,6 +103,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{
acmap = OrderedDict()
for action in interface_actions():
ac = action.load_actual_plugin(self)
ac.plugin_path = action.plugin_path
if ac.name in acmap:
if ac.priority >= acmap[ac.name].priority:
acmap[ac.name] = ac
@ -234,7 +235,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 ################################

View File

@ -171,6 +171,13 @@
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<widget class="QCheckBox" name="opt_remember_current_page">
<property name="text">
<string>Remember the &amp;current page when quitting</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="max_view_width">
<property name="suffix">

View File

@ -50,6 +50,8 @@ def config(defaults=None):
c.add_opt('hyphenate', default=False, help=_('Hyphenate text'))
c.add_opt('hyphenate_default_lang', default='en',
help=_('Default language for hyphenation rules'))
c.add_opt('remember_current_page', default=True,
help=_('Save the current position in the document, when quitting'))
fonts = c.add_group('FONTS', _('Font options'))
fonts('serif_family', default='Times New Roman' if iswindows else 'Liberation Serif',
@ -72,6 +74,7 @@ class ConfigDialog(QDialog, Ui_Dialog):
opts = config().parse()
self.opt_remember_window_size.setChecked(opts.remember_window_size)
self.opt_remember_current_page.setChecked(opts.remember_current_page)
self.serif_family.setCurrentFont(QFont(opts.serif_family))
self.sans_family.setCurrentFont(QFont(opts.sans_family))
self.mono_family.setCurrentFont(QFont(opts.mono_family))
@ -118,6 +121,7 @@ class ConfigDialog(QDialog, Ui_Dialog):
c.set('fit_images', self.opt_fit_images.isChecked())
c.set('max_view_width', int(self.max_view_width.value()))
c.set('hyphenate', self.hyphenate.isChecked())
c.set('remember_current_page', self.opt_remember_current_page.isChecked())
idx = self.hyphenate_default_lang.currentIndex()
c.set('hyphenate_default_lang',
str(self.hyphenate_default_lang.itemData(idx).toString()))

View File

@ -328,6 +328,11 @@ class EbookViewer(MainWindow, Ui_EbookViewer):
c = config().parse()
self.frame.setMaximumWidth(c.max_view_width)
def get_remember_current_page_opt(self):
from calibre.gui2.viewer.documentview import config
c = config().parse()
return c.remember_current_page
def print_book(self, preview):
Printing(self.iterator.spine, preview)
@ -578,7 +583,8 @@ class EbookViewer(MainWindow, Ui_EbookViewer):
current_page = None
self.existing_bookmarks = []
for bm in bookmarks:
if bm[0] == 'calibre_current_page_bookmark':
if bm[0] == 'calibre_current_page_bookmark' and \
self.get_remember_current_page_opt():
current_page = bm
else:
self.existing_bookmarks.append(bm[0])
@ -598,6 +604,8 @@ class EbookViewer(MainWindow, Ui_EbookViewer):
self.set_bookmarks(bookmarks)
def save_current_position(self):
if not self.get_remember_current_page_opt():
return
try:
pos = self.view.bookmark()
bookmark = '%d#%s'%(self.current_index, pos)

View File

@ -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)]

View File

@ -129,6 +129,7 @@ class CoverCache(Thread): # {{{
self.keep_running = True
self.cache = {}
self.lock = RLock()
self.allowed_ids = frozenset([])
self.null_image = QImage()
def stop(self):
@ -175,6 +176,11 @@ class CoverCache(Thread): # {{{
break
for id_ in ids:
time.sleep(0.050) # Limit 20/second to not overwhelm the GUI
if not self.keep_running:
return
with self.lock:
if id_ not in self.allowed_ids:
continue
try:
img = self._image_for_id(id_)
except:
@ -193,6 +199,7 @@ class CoverCache(Thread): # {{{
def set_cache(self, ids):
with self.lock:
self.allowed_ids = frozenset(ids)
already_loaded = set([])
for id in self.cache.keys():
if id in ids:
@ -213,8 +220,9 @@ class CoverCache(Thread): # {{{
def refresh(self, ids):
with self.lock:
for id_ in ids:
self.cache.pop(id_, None)
self.load_queue.put(id_)
cover = self.cache.pop(id_, None)
if cover is not None:
self.load_queue.put(id_)
# }}}
### Global utility function for get_match here and in gui2/library.py

View File

@ -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
@ -953,23 +952,24 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
self.notify('metadata', [id])
return True
def delete_book(self, id, notify=True):
def delete_book(self, id, notify=True, commit=True):
'''
Removes book from the result cache and the underlying database.
If you set commit to False, you must call clean() manually afterwards
'''
try:
path = os.path.join(self.library_path, self.path(id, index_is_id=True))
except:
path = None
self.data.remove(id)
if path and os.path.exists(path):
self.rmtree(path)
parent = os.path.dirname(path)
if len(os.listdir(parent)) == 0:
self.rmtree(parent)
self.conn.execute('DELETE FROM books WHERE id=?', (id,))
self.conn.commit()
self.clean()
if commit:
self.conn.commit()
self.clean()
self.data.books_deleted([id])
if notify:
self.notify('delete', [id])
@ -1038,43 +1038,176 @@ 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:
if not val: continue
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):
@ -1086,23 +1219,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
@ -1188,8 +1344,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()
@ -1485,6 +1646,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']

View File

@ -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

View File

@ -425,3 +425,7 @@ class SchemaUpgrade(object):
ids = [(x[0],) for x in data if has_cover(x[1])]
self.conn.executemany('UPDATE books SET has_cover=1 WHERE id=?', ids)
def upgrade_version_15(self):
'Remove commas from tags'
self.conn.execute("UPDATE tags SET name=REPLACE(name, ',', ';')")

View File

@ -43,6 +43,9 @@ class DispatchController(object): # {{{
kwargs['action'] = 'f_%d'%len(self.funcs)
if route != '/':
route = self.prefix + route
elif self.prefix:
self.dispatcher.connect(name+'prefix_extra', self.prefix, self,
**kwargs)
self.dispatcher.connect(name, route, self, **kwargs)
self.funcs.append(expose(func))

View File

@ -359,7 +359,7 @@ class BrowseServer(object):
icon = 'blank.png'
cats.append((meta['name'], category, icon))
cats = [(u'<li><a title="{2} {0}" href="/browse/category/{1}">&nbsp;</a>'
cats = [(u'<li><a title="{2} {0}" href="{3}/browse/category/{1}">&nbsp;</a>'
u'<img src="{3}{src}" alt="{0}" />'
u'<span class="label">{0}</span>'
u'</li>')
@ -552,16 +552,18 @@ class BrowseServer(object):
ids = self.search_cache('search:"%s"'%which)
except:
raise cherrypy.HTTPError(404, 'Search: %r not understood'%which)
elif category == 'newest':
ids = self.search_cache('')
all_ids = self.search_cache('')
if category == 'newest':
ids = all_ids
hide_sort = 'true'
elif category == 'allbooks':
ids = self.search_cache('')
ids = all_ids
else:
q = category
if q == 'news':
q = 'tags'
ids = self.db.get_books_for_category(q, cid)
ids = [x for x in ids if x in all_ids]
items = [self.db.data._data[x] for x in ids]
if category == 'newest':

View File

@ -72,3 +72,5 @@ Precautions
--------------
Portable media can occasionally fail so you should make periodic backups of you Calibre library. This can be done by making a copy of the CalibreLibrary folder and all its contents. There are many freely available tools around that can optimise such back processes, well known ones being RoboCopy and RichCopy. However you can simply use a Windows copy facility if you cannot be bothered to use a specialised tools.
Using the environment variable CALIBRE_OVERRIDE_DATABASE_PATH disables multiple-library support in |app|. Avoid setting this variable in calibre-portable.bat unless you really need it.

View File

@ -101,8 +101,8 @@ Composite columns can use any template option, including formatting.
You cannot change the data contained in a composite column. If you edit a composite column by double-clicking on any item, you will open the template for editing, not the underlying data. Editing the template on the GUI is a quick way of testing and changing composite columns.
Using functions in templates
-----------------------------
Using functions in templates - single-function mode
---------------------------------------------------
Suppose you want to display the value of a field in upper case, when that field is normally in title case. You can do this (and many more things) using the functions available for templates. For example, to display the title in upper case, use ``{title:uppercase()}``. To display it in title case, use ``{title:titlecase()}``.
@ -137,6 +137,82 @@ Note that you can use the prefix and suffix as well. If you want the number to a
{#myint:0>3s:ifempty(0)|[|]}
Using functions in templates - program mode
-------------------------------------------
The template language program mode differs from single-function mode in that it permits you to write template expressions that refer to other metadata fields, modify values, and do arithmetic. It is a reasonably complete programming language.
Beginning with an example, assume that you want your template to show the series for a book if it has one, otherwise show the value of a custom field #genre. You cannot do this in the basic language because you cannot make reference to another metadata field within a template expression. In program mode, you can. The following expression works::
{#series:'ifempty($, field('#genre'))'}
The example shows several things:
* program mode is used if the expression begins with ``:'`` and ends with ``'``. Anything else is assumed to be single-function.
* the variable ``$`` stands for the field the expression is operating upon, ``#series`` in this case.
* functions must be given all their arguments. There is no default value. This is true for the standard builtin functions, and is a significant difference from single-function mode.
* white space is ignored and can be used anywhere within the expression.
* constant strings are enclosed in matching quotes, either ``'`` or ``"``.
The language is similar to ``functional`` languages in that it is built almost entirely from functions. A statement is a function. An expression is a function. Constants and identifiers can be thought of as functions returning the value indicated by the constant or stored in the identifier.
The syntax of the language is shown by the following grammar::
constant ::= " string " | ' string ' | number
identifier ::= sequence of letters or ``_`` characters
function ::= identifier ( statement [ , statement ]* )
expression ::= identifier | constant | function
statement ::= expression [ ; expression ]*
program ::= statement
An ``expression`` always has a value, either the value of the constant, the value contained in the identifier, or the value returned by a function. The value of a ``statement`` is the value of the last expression in the sequence of statements. As such, the value of the program (statement)::
1; 2; 'foobar'; 3
is 3.
Another example of a complex but rather silly program might help make things clearer::
{series_index:'
substr(
strcat($, '->',
cmp(divide($, 2), 1,
assign(c, 1); substr('lt123', c, 0),
'eq', 'gt')),
0, 6)
'| prefix | suffix}
This program does the following:
* specify that the field being looked at is series_index. This sets the value of the variable ``$``.
* calls the ``substr`` function, which takes 3 parameters ``(str, start, end)``. It returns a string formed by extracting the start through end characters from string, zero-based (the first character is character zero). In this case the string will be computed by the ``strcat`` function, the start is 0, and the end is 6. In this case it will return the first 6 characters of the string returned by ``strcat``, which must be evaluated before substr can return.
* calls the ``strcat`` (string concatenation) function. Strcat accepts 1 or more arguments, and returns a string formed by concatenating all the values. In this case there are three arguments. The first parameter is the value in ``$``, which here is the value of ``series_index``. The second paremeter is the constant string ``'->'``. The third parameter is the value returned by the ``cmp`` function, which must be fully evaluated before ``strcat`` can return.
* The ``cmp`` function takes 5 arguments ``(x, y, lt, eq, gt)``. It compares x and y and returns the third argument ``lt`` if x < y, the fourth argument ``eq`` if x == y, and the fifth argument ``gt`` if x > y. As with all functions, all of the parameters can be statements. In this case the first parameter (the value for ``x``) is the result of dividing the series_index by 2. The second parameter ``y`` is the constant ``1``. The third parameter ``lt`` is a statement (more later). The fourth parameter ``eq`` is the constant string ``'eq'``. The fifth parameter is the constant string ``'gt'``.
* The third parameter (the one for ``lt``) is a statement, or a sequence of expressions. Remember that a statement (a sequence of semicolon-separated expressions) is also an expression, returning the value of the last expression in the list. In this case, the program first assigns the value ``1`` to a local variable ``c``, then returns a substring made by extracting the c'th character to the end. Since c always contains the constant ``1``, the substring will return the second through end'th characters, or ``'t123'``.
* Once the statement providing the value to the third parameter is executed, ``cmp`` can return a value. At that point, ``strcat` can return a value, then ``substr`` can return a value. The program then terminates.
For various values of series_index, the program returns:
* series_index == undefined, result = ``prefix ->t123 suffix``
* series_index == 0.5, result = ``prefix 0.50-> suffix``
* series_index == 1, result = ``prefix 1->t12 suffix``
* series_index == 2, result = ``prefix 2->eq suffix``
* series_index == 3, result = ``prefix 3->gt suffix``
All the functions listed under single-function mode can be used in program mode, noting that unlike the functions described below you must supply a first parameter providing the value the function is to act upon.
The following functions are available in addition to those described in single-function mode. With the exception of the ``id`` parameter of assign, all parameters can be statements (sequences of expressions):
* ``add(x, y)`` -- returns x + y. Throws an exception if either x or y are not numbers.
* ``assign(id, val)`` -- assigns val to id, then returns val. id must be an identifier, not an expression
* ``cmp(x, y, lt, eq, gt)`` -- compares x and y after converting both to numbers. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``.
* ``divide(x, y)`` -- returns x / y. Throws an exception if either x or y are not numbers.
* ``field(name)`` -- returns the metadata field named by ``name``.
* ``multiply`` -- returns x * y. Throws an exception if either x or y are not numbers.
* ``strcat(a, b, ...)`` -- can take any number of arguments. Returns a string formed by concatenating all the arguments.
* ``strcmp(x, y, lt, eq, gt)`` -- does a case-insensitive comparison x and y as strings. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``.
* ``substr(str, start, end)`` -- returns the ``start``'th through the ``end``'th characters of ``str``. The first character in ``str`` is the zero'th character. If end is negative, then it indicates that many characters counting from the right. If end is zero, then it indicates the last character. For example, ``substr('12345', 1, 0)`` returns ``'2345'``, and ``substr('12345', 1, -1)`` returns ``'234'``.
* ``subtract`` -- returns x - y. Throws an exception if either x or y are not numbers.
Special notes for save/send templates
-------------------------------------

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More