(Skomentuj|Komentarz(e)?\([0-9]*\)) \|', lambda match: '')]
+ ]
+
+ remove_attributes = [ 'width', 'height' ]
diff --git a/resources/recipes/globe_and_mail.recipe b/resources/recipes/globe_and_mail.recipe
index b2a9915250..b6e6b5c25b 100644
--- a/resources/recipes/globe_and_mail.recipe
+++ b/resources/recipes/globe_and_mail.recipe
@@ -26,31 +26,12 @@ class GlobeAndMail(BasicNewsRecipe):
#credit {margin-top:0px;}
.tag {font-size: 22pt;}'''
description = 'Canada\'s national newspaper'
- remove_tags_before = dict(id="article-top")
- remove_tags = [
- {'id':['util', 'article-tabs', 'comments', 'article-relations',
- 'gallery-controls', 'video', 'galleryLoading','deck','header',
- 'toolsBottom'] },
- {'class':['credit','inline-img-caption','tab-pointer'] },
- dict(name='div', attrs={'id':['lead-photo', 'most-popular-story']}),
- dict(name='div', attrs={'class':'right'}),
- dict(name='div', attrs={'id':'footer'}),
- dict(name='div', attrs={'id':'beta-msg'}),
- dict(name='img', attrs={'class':'headshot'}),
- dict(name='div', attrs={'class':'brand'}),
- dict(name='div', attrs={'id':'nav-wrap'}),
- dict(name='div', attrs={'id':'featureTopics'}),
- dict(name='div', attrs={'id':'videoNav'}),
- dict(name='div', attrs={'id':'blog-header'}),
- dict(name='div', attrs={'id':'right-rail'}),
- dict(name='div', attrs={'id':'group-footer-container'}),
- dict(name=['iframe', 'style'])
- ]
- remove_attributes = ['style']
- remove_tags_after = [{'id':['article-content']},
- {'class':['pull','inline-img'] },
- dict(name='img', attrs={'class':'inline-media-embed'}),
- ]
+ keep_only_tags = [dict(name='article')]
+ remove_tags = [dict(name='aside'),
+ dict(name='footer'),
+ dict(name='div', attrs={'class':(lambda x: isinstance(x, (str,unicode)) and 'articlecommentcountholder' in x.split(' '))}),
+ dict(name='ul', attrs={'class':(lambda x: isinstance(x, (str,unicode)) and 'articletoolbar' in x.split(' '))}),
+ ]
feeds = [
(u'Latest headlines', u'http://www.theglobeandmail.com/?service=rss'),
(u'Top stories', u'http://www.theglobeandmail.com/?service=rss&feed=topstories'),
diff --git a/resources/recipes/malaysian_mirror.recipe b/resources/recipes/malaysian_mirror.recipe
new file mode 100644
index 0000000000..e61538431a
--- /dev/null
+++ b/resources/recipes/malaysian_mirror.recipe
@@ -0,0 +1,89 @@
+#!/usr/bin/env python
+__license__ = 'GPL v3'
+__author__ = 'Tony Stegall'
+__copyright__ = '2010, Tony Stegall or Tonythebookworm on mobiread.com'
+__version__ = '1'
+__date__ = '16, October 2010'
+__docformat__ = 'English'
+
+
+
+from calibre.web.feeds.news import BasicNewsRecipe
+
+class MalaysianMirror(BasicNewsRecipe):
+ title = 'MalaysianMirror'
+ __author__ = 'Tonythebookworm'
+ description = 'The Pulse of the Nation'
+ language = 'en'
+ no_stylesheets = True
+ publisher = 'Tonythebookworm'
+ category = 'news'
+ use_embedded_content= False
+ no_stylesheets = True
+ oldest_article = 24
+
+ remove_javascript = True
+ remove_empty_feeds = True
+ conversion_options = {'linearize_tables' : True}
+ extra_css = '''
+ #content_heading{font-family:Arial,Helvetica,sans-serif; font-weight:bold;font-size:large;}
+
+ td{text-align:right; font-size:small;margin-top:0px;margin-bottom: 0px;}
+
+ #content_body{font-family:Helvetica,Arial,sans-serif;font-size:small;}
+ '''
+
+ keep_only_tags = [dict(name='table', attrs={'class':['contentpaneopen']})
+ ]
+ remove_tags = [dict(name='table', attrs={'class':['buttonheading']})]
+ #######################################################################################################################
+
+
+ max_articles_per_feed = 10
+
+ '''
+ Make a variable that will hold the url for the main site because our links do not include the index
+ '''
+
+ INDEX = 'http://www.malaysianmirror.com'
+
+
+
+
+ def parse_index(self):
+ feeds = []
+ for title, url in [
+ (u"Media Buzz", u"http://www.malaysianmirror.com/media-buzz-front"),
+ (u"Life Style", u"http://www.malaysianmirror.com/lifestylefront"),
+ (u"Features", u"http://www.malaysianmirror.com/featurefront"),
+
+
+ ]:
+ articles = self.make_links(url)
+ if articles:
+ feeds.append((title, articles))
+ return feeds
+
+ def make_links(self, url):
+ title = 'Temp'
+ current_articles = []
+ soup = self.index_to_soup(url)
+ # print 'The soup is: ', soup
+ for item in soup.findAll('div', attrs={'class':'contentheading'}):
+ #print 'item is: ', item
+ link = item.find('a')
+ #print 'the link is: ', link
+ if link:
+ url = self.INDEX + link['href']
+ title = self.tag_to_string(link)
+ #print 'the title is: ', title
+ #print 'the url is: ', url
+ #print 'the title is: ', title
+ current_articles.append({'title': title, 'url': url, 'description':'', 'date':''}) # append all this
+ return current_articles
+
+ def preprocess_html(self, soup):
+ for item in soup.findAll(attrs={'style':True}):
+ del item['style']
+ return soup
+
diff --git a/resources/recipes/miami_herald.recipe b/resources/recipes/miami_herald.recipe
index 4500b02097..bdff88b375 100644
--- a/resources/recipes/miami_herald.recipe
+++ b/resources/recipes/miami_herald.recipe
@@ -1,6 +1,6 @@
__license__ = 'GPL v3'
-__copyright__ = '2009, Darko Miletic
'
+__copyright__ = '2009-2010, Darko Miletic '
'''
miamiherald.com
'''
@@ -15,13 +15,11 @@ class TheMiamiHerald(BasicNewsRecipe):
max_articles_per_feed = 100
publisher = u'The Miami Herald'
category = u'miami herald, weather, dolphins, news, miami news, local news, miamiherald, miami newspaper, miamiherald.com, miami, the miami herald, broward, miami-dade'
- language = 'en'
-
+ language = 'en'
no_stylesheets = True
use_embedded_content = False
encoding = 'cp1252'
remove_javascript = True
-
extra_css = '''
h1{font-family:Arial,Helvetica,sans-serif; font-size:large; color:#1A272F; }
.subheadline{font-family:Arial,Helvetica,sans-serif; font-size:30%; color: #666666;}
@@ -33,50 +31,35 @@ class TheMiamiHerald(BasicNewsRecipe):
.imageCaption{font-family:Arial,Helvetica,sans-serif; font-size:30%; color:#666666; }
'''
- keep_only_tags = [dict(name='div', attrs={'id':['storyBody','storyPhotoContentArea']}),
- ]
+ conversion_options = {
+ 'comment' : description
+ , 'tags' : category
+ , 'publisher' : publisher
+ , 'language' : language
+ }
+
+ keep_only_tags = [dict(name='div', attrs={'id':'wide'}),]
- remove_tags = [dict(name=['object','link','embed']),
- dict(name='div', attrs={'class':["imageBuyButton","shareLinksArea","storyTools","spill_navigation pagination","circPromoArea","storyTools_footer","storyYahooContentMatch"]}) ,
- dict(name='div', attrs={'id':["pluck","mlt","storyAssets"]}) ]
+ remove_tags = [dict(name=['object','link','embed','iframe','meta'])]
feeds = [
- (u'Breaking News' , u'http://www.miamiherald.com/416/index.xml' )
- ,(u'Miami-Dade' , u'http://www.miamiherald.com/460/index.xml' )
- ,(u'Broward' , u'http://www.miamiherald.com/467/index.xml' )
- ,(u'Florida Keys' , u'http://www.miamiherald.com/505/index.xml' )
- ,(u'Florida' , u'http://www.miamiherald.com/569/index.xml' )
- ,(u'Nation' , u'http://www.miamiherald.com/509/index.xml' )
- ,(u'World' , u'http://www.miamiherald.com/578/index.xml' )
- ,(u'Americas' , u'http://www.miamiherald.com/579/index.xml' )
- ,(u'Cuba' , u'http://www.miamiherald.com/581/index.xml' )
- ,(u'Haiti' , u'http://www.miamiherald.com/582/index.xml' )
- ,(u'Politics' , u'http://www.miamiherald.com/515/index.xml' )
- ,(u'Education' , u'http://www.miamiherald.com/295/index.xml' )
- ,(u'Environment' , u'http://www.miamiherald.com/573/index.xml' )
+ (u'Breaking News' , u'http://www.miamiherald.com/news/breaking-news/index.xml' )
+ ,(u'Miami-Dade' , u'http://www.miamiherald.com/news/miami-dade/index.xml' )
+ ,(u'Broward' , u'http://www.miamiherald.com/news/broward/index.xml' )
+ ,(u'Florida Keys' , u'http://www.miamiherald.com/news/florida-keys/index.xml' )
+ ,(u'Florida' , u'http://www.miamiherald.com/news/florida/index.xml' )
+ ,(u'Nation' , u'http://www.miamiherald.com/news/nation/index.xml' )
+ ,(u'World' , u'http://www.miamiherald.com/news/world/index.xml' )
+ ,(u'Americas' , u'http://www.miamiherald.com/news/americas/index.xml' )
+ ,(u'Cuba' , u'http://www.miamiherald.com/news/americas/cuba/index.xml' )
+ ,(u'Haiti' , u'http://www.miamiherald.com/news/americas/haiti/index.xml' )
+ ,(u'Politics' , u'http://www.miamiherald.com/news/politics/index.xml' )
+ ,(u'Education' , u'http://www.miamiherald.com/news/education/index.xml' )
+ ,(u'Environment' , u'http://www.miamiherald.com/news/environment/index.xml' )
]
-
-
-
-
- def get_article_url(self, article):
- ans = article.get('guid', None)
- print ans
- try:
- self.log('Looking for full story link in', ans)
- soup = self.index_to_soup(ans)
- x = soup.find(text="Full Story")
-
- if x is not None:
- a = x.parent
- if a and a.has_key('href'):
- ans = 'http://www.miamiherald.com'+a['href']
- self.log('Found full story link', ans)
- except:
- pass
- return ans
-
-
-
+ def print_version(self, url):
+ art, sep, rest = url.rpartition('/')
+ art2, sep2, rest2 = art.rpartition('/')
+ return art2 + '/v-print/' + rest2 + '/' + rest
diff --git a/resources/recipes/novaya_gazeta.recipe b/resources/recipes/novaya_gazeta.recipe
new file mode 100644
index 0000000000..50ce83b130
--- /dev/null
+++ b/resources/recipes/novaya_gazeta.recipe
@@ -0,0 +1,18 @@
+from calibre.web.feeds.news import BasicNewsRecipe
+
+class AdvancedUserRecipe1286819935(BasicNewsRecipe):
+ title = u'Novaya Gazeta'
+ __author__ = 'muwa'
+ oldest_article = 7
+ max_articles_per_feed = 100
+ no_stylesheets = True
+ conversion_options = {'linearize_tables' : True}
+ remove_attributes = ['style']
+ language = 'ru'
+
+ feeds = [(u'Articles', u'http://www.novayagazeta.ru/rss_number.xml')]
+
+
+ def print_version(self, url):
+ return url + '?print=true'
+
diff --git a/resources/recipes/orsai.recipe b/resources/recipes/orsai.recipe
new file mode 100644
index 0000000000..2d9659b89b
--- /dev/null
+++ b/resources/recipes/orsai.recipe
@@ -0,0 +1,37 @@
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Darko Miletic '
+'''
+orsai.bitacoras.com
+'''
+
+from calibre.web.feeds.news import BasicNewsRecipe
+
+class Orsai(BasicNewsRecipe):
+ title = 'Orsai'
+ __author__ = 'Darko Miletic'
+ language = 'es'
+ oldest_article = 35
+ max_articles_per_feed = 100
+ encoding = 'utf-8'
+ no_stylesheets = True
+ use_embedded_content = False
+ publication_type = 'blog'
+ masthead_url = 'http://orsai.bitacoras.com/wp-content/themes/orsai/images/logo_orsai.png'
+
+ conversion_options = {
+ 'comment' : 'Blog literario de Hernán Casciari'
+ , 'tags' : 'blog, Argentina, España, literatura, Casciari'
+ , 'publisher': 'Editorial Orsai S.L.'
+ , 'language' : 'es'
+ }
+
+ keep_only_tags=[dict(attrs={'class':['entry-title','entry-meta','entry-content','commentlist']})]
+ remove_tags=[dict(name='img',attrs={'class':'avatar avatar-40 photo'})]
+ feeds = [(u'Articulos', u'http://orsai.bitacoras.com/feed')]
+
+ def preprocess_html(self, soup):
+ for item in soup.findAll(style=True):
+ del item['style']
+ return self.adeify_images(soup)
+
diff --git a/resources/recipes/rstones.recipe b/resources/recipes/rstones.recipe
new file mode 100644
index 0000000000..fa09701e15
--- /dev/null
+++ b/resources/recipes/rstones.recipe
@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+__license__ = 'GPL v3'
+__author__ = 'Tony Stegall'
+__copyright__ = '2010, Tony Stegall or Tonythebookworm on mobileread.com'
+__version__ = 'v1.01'
+__date__ = '07, October 2010'
+__description__ = 'Rolling Stones Mag'
+
+'''
+http://www.rollingstone.com
+'''
+
+from calibre.web.feeds.news import BasicNewsRecipe
+
+class RollingStones(BasicNewsRecipe):
+ __author__ = 'Tony Stegall'
+ description = 'Rolling Stones Mag'
+ cover_url = 'http://gallery.celebritypro.com/data/media/648/kid-rock-rolling-stone-cover.jpg'
+ masthead_url = 'http://origin.myfonts.com/s/ec/cc-200804/Rolling_Stone-logo.gif'
+
+
+ title = 'Rolling Stones Mag'
+ category = 'Music Reviews, Movie Reviews, entertainment news'
+
+ language = 'en'
+ timefmt = '[%a, %d %b, %Y]'
+
+ oldest_article = 15
+ max_articles_per_feed = 25
+ use_embedded_content = False
+ no_stylesheets = True
+
+ remove_javascript = True
+ #####################################################################################
+ # cleanup section #
+ #####################################################################################
+ keep_only_tags = [
+ dict(name='div', attrs={'class':['c65l']}),
+ dict(name='div', attrs={'id':['col1']}),
+
+
+ ]
+ remove_tags = [
+ dict(name='div', attrs={'class': ['storyActions upper','storyActions lowerArticleNav']}),
+ dict(name='div', attrs={'id': ['comments','related']}),
+ ]
+
+
+ feeds = [
+ (u'News', u'http://www.rollingstone.com/siteServices/rss/allNews'),
+ (u'Blogs', u'http://www.rollingstone.com/siteServices/rss/allBlogs'),
+ (u'Movie Reviews', u'http://www.rollingstone.com/siteServices/rss/movieReviews'),
+ (u'Album Reviews', u'http://www.rollingstone.com/siteServices/rss/albumReviews'),
+ (u'Song Reviews', u'http://www.rollingstone.com/siteServices/rss/songReviews'),
+
+
+ ]
+
+
+
+ def get_article_url(self, article):
+ return article.get('guid', None)
+
+
+ def append_page(self, soup, appendtag, position):
+ '''
+ Some are the articles are multipage so the below function
+ will get the articles that have
+ '''
+ pager = soup.find('li',attrs={'class':'next'})
+ if pager:
+ nexturl = pager.a['href']
+ soup2 = self.index_to_soup(nexturl)
+ texttag = soup2.find('div', attrs={'id':'storyTextContainer'})
+ for it in texttag.findAll(style=True):
+ del it['style']
+ newpos = len(texttag.contents)
+ self.append_page(soup2,texttag,newpos)
+ texttag.extract()
+ appendtag.insert(position,texttag)
+
+
diff --git a/resources/recipes/volksrant.recipe b/resources/recipes/volksrant.recipe
index 0229bb0376..6f3ec4ce0d 100644
--- a/resources/recipes/volksrant.recipe
+++ b/resources/recipes/volksrant.recipe
@@ -6,7 +6,19 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal '
__docformat__ = 'restructuredtext en'
+'''
+ Modified by Tony Stegall
+ on 10/10/10 to include function to grab print version of articles
+'''
+
+from datetime import date
from calibre.web.feeds.news import BasicNewsRecipe
+'''
+added by Tony Stegall
+'''
+#######################################################
+from calibre.ptempfile import PersistentTemporaryFile
+#######################################################
class AdvancedUserRecipe1249039563(BasicNewsRecipe):
title = u'De Volkskrant'
@@ -16,20 +28,58 @@ class AdvancedUserRecipe1249039563(BasicNewsRecipe):
no_stylesheets = True
language = 'nl'
- keep_only_tags = [dict(name='div', attrs={'id':'leftColumnArticle'}) ]
- remove_tags = [
- dict(name='div',attrs={'class':'article_tools'}),
- dict(name='div',attrs={'id':'article_tools'}),
- dict(name='div',attrs={'class':'articletools'}),
- dict(name='div',attrs={'id':'articletools'}),
- dict(name='div',attrs={'id':'myOverlay'}),
- dict(name='div',attrs={'id':'trackback'}),
- dict(name='div',attrs={'id':'googleBanner'}),
- dict(name='div',attrs={'id':'article_headlines'}),
- ]
extra_css = '''
body{font-family:Arial,Helvetica,sans-serif; font-size:small;}
h1{font-size:large;}
'''
+ '''
+ Change Log:
+ Date: 10/10/10 - Modified code to include obfuscated to get the print version
+ Author: Tony Stegall
+ '''
+ #######################################################################################################
+ temp_files = []
+ articles_are_obfuscated = True
- feeds = [(u'Laatste Nieuws', u'http://volkskrant.nl/rss/laatstenieuws.rss'), (u'Binnenlands nieuws', u'http://volkskrant.nl/rss/nederland.rss'), (u'Buitenlands nieuws', u'http://volkskrant.nl/rss/internationaal.rss'), (u'Economisch nieuws', u'http://volkskrant.nl/rss/economie.rss'), (u'Sportnieuws', u'http://volkskrant.nl/rss/sport.rss'), (u'Kunstnieuws', u'http://volkskrant.nl/rss/kunst.rss'), (u'Wetenschapsnieuws', u'http://feeds.feedburner.com/DeVolkskrantWetenschap'), (u'Technologienieuws', u'http://feeds.feedburner.com/vkmedia')]
+ def get_obfuscated_article(self, url):
+ br = self.get_browser()
+ print 'THE CURRENT URL IS: ', url
+ br.open(url)
+ year = date.today().year
+
+ try:
+ response = br.follow_link(url_regex='.*?(%d)(\\/)(article)(\\/)(print)(\\/)'%year, nr = 0)
+ html = response.read()
+ except:
+ response = br.open(url)
+ html = response.read()
+
+ self.temp_files.append(PersistentTemporaryFile('_fa.html'))
+ self.temp_files[-1].write(html)
+ self.temp_files[-1].close()
+ return self.temp_files[-1].name
+
+ ###############################################################################################################
+
+ '''
+ Change Log:
+ Date: 10/15/2010
+ Feeds updated by Martin Tarenskeen
+ '''
+
+ feeds = [
+ (u'Laatste Nieuws', u'http://www.volkskrant.nl/rss/laatstenieuws.rss'),
+ (u'Binnenland', u'http://www.volkskrant.nl/rss/nederland.rss'),
+ (u'Buitenland', u'http://www.volkskrant.nl/rss/internationaal.rss'),
+ (u'Economie', u'http://www.volkskrant.nl/rss/economie.rss'),
+ (u'Sport', u'http://www.volkskrant.nl/rss/sport.rss'),
+ (u'Cultuur', u'http://www.volkskrant.nl/rss/kunst.rss'),
+ (u'Gezondheid & Wetenschap', u'http://www.volkskrant.nl/rss/wetenschap.rss'),
+ (u'Internet & Media', u'http://www.volkskrant.nl/rss/media.rss') ]
+
+
+'''
+example for formating
+'''
+# original url: http://www.volkskrant.nl/vk/nl/2668/Buitenland/article/detail/1031493/2010/10/10/Noord-Korea-ziet-nieuwe-leider.dhtml
+# print url : http://www.volkskrant.nl/vk/nl/2668/2010/article/print/detail/1031493/Noord-Korea-ziet-nieuwe-leider.dhtml
diff --git a/setup/installer/__init__.py b/setup/installer/__init__.py
index f38d175b4c..b976c4d448 100644
--- a/setup/installer/__init__.py
+++ b/setup/installer/__init__.py
@@ -38,13 +38,19 @@ class Push(Command):
description = 'Push code to another host'
def run(self, opts):
+ from threading import Thread
+ threads = []
for host in (
r'Owner@winxp:/cygdrive/c/Documents\ and\ Settings/Owner/calibre',
'kovid@ox:calibre'
):
rcmd = BASE_RSYNC + EXCLUDES + ['.', host]
print '\n\nPushing to:', host, '\n'
+ threads.append(Thread(target=subprocess.check_call, args=(rcmd,)))
+ threads[-1].start()
subprocess.check_call(rcmd)
+ for thread in threads:
+ thread.join()
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index 6fda73f785..fe187a1400 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -292,6 +292,17 @@ class RTFMetadataReader(MetadataReaderPlugin):
def get_metadata(self, stream, ftype):
from calibre.ebooks.metadata.rtf import get_metadata
return get_metadata(stream)
+
+class SNBMetadataReader(MetadataReaderPlugin):
+
+ name = 'Read SNB metadata'
+ file_types = set(['snb'])
+ description = _('Read metadata from %s files') % 'SNB'
+ author = 'Li Fanxi'
+
+ def get_metadata(self, stream, ftype):
+ from calibre.ebooks.metadata.snb import get_metadata
+ return get_metadata(stream)
class TOPAZMetadataReader(MetadataReaderPlugin):
@@ -420,6 +431,7 @@ from calibre.ebooks.tcr.input import TCRInput
from calibre.ebooks.txt.input import TXTInput
from calibre.ebooks.lrf.input import LRFInput
from calibre.ebooks.chm.input import CHMInput
+from calibre.ebooks.snb.input import SNBInput
from calibre.ebooks.epub.output import EPUBOutput
from calibre.ebooks.fb2.output import FB2Output
@@ -434,6 +446,7 @@ from calibre.ebooks.rb.output import RBOutput
from calibre.ebooks.rtf.output import RTFOutput
from calibre.ebooks.tcr.output import TCROutput
from calibre.ebooks.txt.output import TXTOutput
+from calibre.ebooks.snb.output import SNBOutput
from calibre.customize.profiles import input_profiles, output_profiles
@@ -495,6 +508,7 @@ plugins += [
TXTInput,
LRFInput,
CHMInput,
+ SNBInput,
]
plugins += [
EPUBOutput,
@@ -510,6 +524,7 @@ plugins += [
RTFOutput,
TCROutput,
TXTOutput,
+ SNBOutput,
]
# Order here matters. The first matched device is the one used.
plugins += [
diff --git a/src/calibre/customize/profiles.py b/src/calibre/customize/profiles.py
index 1d879f0c5d..4fa53b1cdb 100644
--- a/src/calibre/customize/profiles.py
+++ b/src/calibre/customize/profiles.py
@@ -647,11 +647,25 @@ class NookOutput(OutputProfile):
fbase = 16
fsizes = [12, 12, 14, 16, 18, 20, 22, 24]
+class BambookOutput(OutputProfile):
+
+ name = 'Sanda Bambook'
+ short_name = 'bambook'
+ description = _('This profile is intended for the Sanda Bambook.')
+
+ # Screen size is a best guess
+ screen_size = (800, 600)
+ comic_screen_size = (700, 540)
+ dpi = 168.451
+ fbase = 12
+ fsizes = [10, 12, 14, 16]
+
output_profiles = [OutputProfile, SonyReaderOutput, SonyReader300Output,
SonyReader900Output, MSReaderOutput, MobipocketOutput, HanlinV3Output,
HanlinV5Output, CybookG3Output, CybookOpusOutput, KindleOutput,
iPadOutput, KoboReaderOutput,
SonyReaderLandscapeOutput, KindleDXOutput, IlliadOutput,
- IRexDR1000Output, IRexDR800Output, JetBook5Output, NookOutput,]
+ IRexDR1000Output, IRexDR800Output, JetBook5Output, NookOutput,
+ BambookOutput, ]
output_profiles.sort(cmp=lambda x,y:cmp(x.name.lower(), y.name.lower()))
diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py
index 9465b789ae..9ad3cf3e08 100644
--- a/src/calibre/devices/apple/driver.py
+++ b/src/calibre/devices/apple/driver.py
@@ -13,7 +13,8 @@ from calibre.devices.errors import UserFeedback
from calibre.devices.usbms.deviceconfig import DeviceConfig
from calibre.devices.interface import DevicePlugin
from calibre.ebooks.BeautifulSoup import BeautifulSoup
-from calibre.ebooks.metadata import authors_to_string, MetaInformation
+from calibre.ebooks.metadata import authors_to_string, MetaInformation, \
+ title_sort
from calibre.ebooks.metadata.book.base import Metadata
from calibre.ebooks.metadata.epub import set_metadata
from calibre.library.server.utils import strftime
@@ -96,6 +97,9 @@ class ITUNES(DriverBase):
OPEN_FEEDBACK_MESSAGE = _(
'Apple device detected, launching iTunes, please wait ...')
+ BACKLOADING_ERROR_MESSAGE = _(
+ "Cannot copy books directly from iDevice. "
+ "Drag from iTunes Library to desktop, then add to calibre's Library window.")
# Product IDs:
# 0x1291 iPod Touch
@@ -3128,6 +3132,9 @@ class Book(Metadata):
See ebooks.metadata.book.base
'''
def __init__(self,title,author):
-
Metadata.__init__(self, title, authors=[author])
+ @property
+ def title_sorter(self):
+ return title_sort(self.title)
+
diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py
index aee35649d2..75453c74b9 100644
--- a/src/calibre/devices/interface.py
+++ b/src/calibre/devices/interface.py
@@ -39,6 +39,10 @@ class DevicePlugin(Plugin):
#: Whether the metadata on books can be set via the GUI.
CAN_SET_METADATA = ['title', 'authors', 'collections']
+ # Set this to None if the books on the device are files that the GUI can
+ # access in order to add the books from the device to the library
+ BACKLOADING_ERROR_MESSAGE = _('Cannot get files from this device')
+
#: Path separator for paths to books on device
path_sep = os.sep
diff --git a/src/calibre/devices/udisks.py b/src/calibre/devices/udisks.py
new file mode 100644
index 0000000000..ba26c2b56c
--- /dev/null
+++ b/src/calibre/devices/udisks.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Kovid Goyal '
+__docformat__ = 'restructuredtext en'
+
+import dbus
+import os
+
+def node_mountpoint(node):
+
+ def de_mangle(raw):
+ return raw.replace('\\040', ' ').replace('\\011', '\t').replace('\\012',
+ '\n').replace('\\0134', '\\')
+
+ for line in open('/proc/mounts').readlines():
+ line = line.split()
+ if line[0] == node:
+ return de_mangle(line[1])
+ return None
+
+
+class UDisks(object):
+
+ def __init__(self):
+ if os.environ.get('CALIBRE_DISABLE_UDISKS', False):
+ raise Exception('User has aborted use of UDISKS')
+ self.bus = dbus.SystemBus()
+ self.main = dbus.Interface(self.bus.get_object('org.freedesktop.UDisks',
+ '/org/freedesktop/UDisks'), 'org.freedesktop.UDisks')
+
+ def device(self, device_node_path):
+ devpath = self.main.FindDeviceByDeviceFile(device_node_path)
+ return dbus.Interface(self.bus.get_object('org.freedesktop.UDisks',
+ devpath), 'org.freedesktop.UDisks.Device')
+
+ def mount(self, device_node_path):
+ d = self.device(device_node_path)
+ try:
+ return unicode(d.FilesystemMount('',
+ ['auth_no_user_interaction', 'rw', 'noexec', 'nosuid',
+ 'sync', 'nodev', 'uid=1000', 'gid=1000']))
+ except:
+ # May be already mounted, check
+ mp = node_mountpoint(str(device_node_path))
+ if mp is None:
+ raise
+ return mp
+
+ def unmount(self, device_node_path):
+ d = self.device(device_node_path)
+ d.FilesystemUnmount(['force'])
+
+ def eject(self, device_node_path):
+ parent = device_node_path
+ while parent[-1] in '0123456789':
+ parent = parent[:-1]
+ devices = [str(x) for x in self.main.EnumerateDeviceFiles()]
+ for d in devices:
+ if d.startswith(parent) and d != parent:
+ try:
+ self.unmount(d)
+ except:
+ import traceback
+ print 'Failed to unmount:', d
+ traceback.print_exc()
+ d = self.device(parent)
+ d.DriveEject([])
+
+def mount(node_path):
+ u = UDisks()
+ u.mount(node_path)
+
+def eject(node_path):
+ u = UDisks()
+ u.eject(node_path)
+
+if __name__ == '__main__':
+ import sys
+ dev = sys.argv[1]
+ print 'Testing with node', dev
+ u = UDisks()
+ print 'Mounted at:', u.mount(dev)
+ print 'Ejecting'
+ u.eject(dev)
+
+
diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py
index a267d18584..462d78b233 100644
--- a/src/calibre/devices/usbms/books.py
+++ b/src/calibre/devices/usbms/books.py
@@ -6,6 +6,7 @@ __docformat__ = 'restructuredtext en'
import os, re, time, sys
+from calibre.ebooks.metadata import title_sort
from calibre.ebooks.metadata.book.base import Metadata
from calibre.devices.mime import mime_type_ext
from calibre.devices.interface import BookList as _BookList
@@ -54,7 +55,7 @@ class Book(Metadata):
def title_sorter(self):
doc = '''String to sort the title. If absent, title is returned'''
def fget(self):
- return re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip()
+ return title_sort(self.title)
return property(doc=doc, fget=fget)
@dynamic_property
@@ -124,7 +125,6 @@ class CollectionsBookList(BookList):
collections = {}
# This map of sets is used to avoid linear searches when testing for
# book equality
- collections_lpaths = {}
for book in self:
# Make sure we can identify this book via the lpath
lpath = getattr(book, 'lpath', None)
@@ -198,20 +198,22 @@ class CollectionsBookList(BookList):
cat_name = category
if cat_name not in collections:
- collections[cat_name] = []
- collections_lpaths[cat_name] = set()
- if lpath in collections_lpaths[cat_name]:
- continue
- collections_lpaths[cat_name].add(lpath)
+ collections[cat_name] = {}
if is_series:
- collections[cat_name].append(
- (book, book.get(attr+'_index', sys.maxint)))
+ if doing_dc:
+ collections[cat_name][lpath] = \
+ (book, book.get('series_index', sys.maxint))
+ else:
+ collections[cat_name][lpath] = \
+ (book, book.get(attr+'_index', sys.maxint))
else:
- collections[cat_name].append(
- (book, book.get('title_sort', 'zzzz')))
+ if lpath not in collections[cat_name]:
+ collections[cat_name][lpath] = \
+ (book, book.get('title_sort', 'zzzz'))
# Sort collections
result = {}
- for category, books in collections.items():
+ for category, lpaths in collections.items():
+ books = lpaths.values()
books.sort(cmp=lambda x,y:cmp(x[1], y[1]))
result[category] = [x[0] for x in books]
return result
diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py
index 78d4606e85..6f938cbcbd 100644
--- a/src/calibre/devices/usbms/device.py
+++ b/src/calibre/devices/usbms/device.py
@@ -94,6 +94,9 @@ class Device(DeviceConfig, DevicePlugin):
EBOOK_DIR_CARD_B = ''
DELETE_EXTS = []
+ # USB disk-based devices can see the book files on the device, so can
+ # copy these back to the library
+ BACKLOADING_ERROR_MESSAGE = None
def reset(self, key='-1', log_packets=False, report_progress=None,
detected_device=None):
@@ -527,16 +530,8 @@ class Device(DeviceConfig, DevicePlugin):
return drives
def node_mountpoint(self, node):
-
- def de_mangle(raw):
- return raw.replace('\\040', ' ').replace('\\011', '\t').replace('\\012',
- '\n').replace('\\0134', '\\')
-
- for line in open('/proc/mounts').readlines():
- line = line.split()
- if line[0] == node:
- return de_mangle(line[1])
- return None
+ from calibre.devices.udisks import node_mountpoint
+ return node_mountpoint(node)
def find_largest_partition(self, path):
node = path.split('/')[-1]
@@ -582,6 +577,13 @@ class Device(DeviceConfig, DevicePlugin):
label += ' (%d)'%extra
def do_mount(node, label):
+ try:
+ from calibre.devices.udisks import mount
+ mount(node)
+ return 0
+ except:
+ pass
+
cmd = 'calibre-mount-helper'
if getattr(sys, 'frozen_path', False):
cmd = os.path.join(sys.frozen_path, cmd)
@@ -614,6 +616,7 @@ class Device(DeviceConfig, DevicePlugin):
if not mp.endswith('/'): mp += '/'
self._linux_mount_map[main] = mp
self._main_prefix = mp
+ self._linux_main_device_node = main
cards = [(carda, '_card_a_prefix', 'carda'),
(cardb, '_card_b_prefix', 'cardb')]
for card, prefix, typ in cards:
@@ -729,6 +732,11 @@ class Device(DeviceConfig, DevicePlugin):
pass
def eject_linux(self):
+ try:
+ from calibre.devices.udisks import eject
+ return eject(self._linux_main_device_node)
+ except:
+ pass
drives = self.find_device_nodes()
for drive in drives:
if drive:
diff --git a/src/calibre/ebooks/__init__.py b/src/calibre/ebooks/__init__.py
index 624b277e61..9bdf937dd1 100644
--- a/src/calibre/ebooks/__init__.py
+++ b/src/calibre/ebooks/__init__.py
@@ -25,7 +25,7 @@ class DRMError(ValueError):
BOOK_EXTENSIONS = ['lrf', 'rar', 'zip', 'rtf', 'lit', 'txt', 'htm', 'xhtm',
'html', 'xhtml', 'pdf', 'pdb', 'pdr', 'prc', 'mobi', 'azw', 'doc',
'epub', 'fb2', 'djvu', 'lrx', 'cbr', 'cbz', 'cbc', 'oebzip',
- 'rb', 'imp', 'odt', 'chm', 'tpz', 'azw1', 'pml', 'mbp', 'tan']
+ 'rb', 'imp', 'odt', 'chm', 'tpz', 'azw1', 'pml', 'mbp', 'tan', 'snb']
class HTMLRenderer(object):
diff --git a/src/calibre/ebooks/chm/reader.py b/src/calibre/ebooks/chm/reader.py
index 73587edfa4..025e252005 100644
--- a/src/calibre/ebooks/chm/reader.py
+++ b/src/calibre/ebooks/chm/reader.py
@@ -93,6 +93,7 @@ class CHMReader(CHMFile):
return data
def ExtractFiles(self, output_dir=os.getcwdu()):
+ html_files = set([])
for path in self.Contents():
lpath = os.path.join(output_dir, path)
self._ensure_dir(lpath)
@@ -106,14 +107,27 @@ class CHMReader(CHMFile):
lpath = lpath.split(';')[0]
try:
with open(lpath, 'wb') as f:
- if guess_mimetype(path)[0] == ('text/html'):
- data = self._reformat(data)
f.write(data)
+ try:
+ if 'html' in guess_mimetype(path)[0]:
+ html_files.add(lpath)
+ except:
+ pass
except:
if iswindows and len(lpath) > 250:
self.log.warn('%r filename too long, skipping'%path)
continue
raise
+ for lpath in html_files:
+ with open(lpath, 'r+b') as f:
+ data = f.read()
+ data = self._reformat(data, lpath)
+ if isinstance(data, unicode):
+ data = data.encode('utf-8')
+ f.seek(0)
+ f.truncate()
+ f.write(data)
+
self._extracted = True
files = [x for x in os.listdir(output_dir) if
os.path.isfile(os.path.join(output_dir, x))]
@@ -125,7 +139,7 @@ class CHMReader(CHMFile):
if self.hhc_path not in files and files:
self.hhc_path = files[0]
- def _reformat(self, data):
+ def _reformat(self, data, htmlpath):
try:
data = xml_to_unicode(data, strip_encoding_pats=True)[0]
soup = BeautifulSoup(data)
@@ -169,15 +183,19 @@ class CHMReader(CHMFile):
br[0].extract()
# some images seem to be broken in some chm's :/
- for img in soup('img'):
- try:
- # some are supposedly "relative"... lies.
- while img['src'].startswith('../'): img['src'] = img['src'][3:]
- # some have ";" at the end.
- img['src'] = img['src'].split(';')[0]
- except KeyError:
- # and some don't even have a src= ?!
- pass
+ base = os.path.dirname(htmlpath)
+ for img in soup('img', src=True):
+ src = img['src']
+ ipath = os.path.join(base, *src.split('/'))
+ if os.path.exists(ipath):
+ continue
+ src = src.split(';')[0]
+ if not src: continue
+ ipath = os.path.join(base, *src.split('/'))
+ if not os.path.exists(ipath):
+ while src.startswith('../'):
+ src = src[3:]
+ img['src'] = src
try:
# if there is only a single table with a single element
# in the body, replace it by the contents of this single element
diff --git a/src/calibre/ebooks/comic/input.py b/src/calibre/ebooks/comic/input.py
index 23f5906a53..04d097ac67 100755
--- a/src/calibre/ebooks/comic/input.py
+++ b/src/calibre/ebooks/comic/input.py
@@ -94,7 +94,7 @@ class PageProcessor(list):
from calibre.utils.magick import PixelWand
for i, wand in enumerate(self.pages):
pw = PixelWand()
- pw.color = 'white'
+ pw.color = '#ffffff'
wand.set_border_color(pw)
if self.rotate:
diff --git a/src/calibre/ebooks/metadata/isbndb.py b/src/calibre/ebooks/metadata/isbndb.py
index 07a054eeaa..83cf6ee0ed 100644
--- a/src/calibre/ebooks/metadata/isbndb.py
+++ b/src/calibre/ebooks/metadata/isbndb.py
@@ -45,7 +45,7 @@ def fetch_metadata(url, max=100, timeout=5.):
class ISBNDBMetadata(Metadata):
def __init__(self, book):
- Metadata.__init__(self, None, [])
+ Metadata.__init__(self, None)
def tostring(e):
if not hasattr(e, 'string'):
@@ -58,21 +58,21 @@ class ISBNDBMetadata(Metadata):
return ans
self.isbn = unicode(book.get('isbn13', book.get('isbn')))
- self.title = tostring(book.find('titlelong'))
- if not self.title:
- self.title = tostring(book.find('title'))
- if not self.title:
- self.title = _('Unknown')
+ title = tostring(book.find('titlelong'))
+ if not title:
+ title = tostring(book.find('title'))
+ self.title = title
self.title = unicode(self.title).strip()
- self.authors = []
+ authors = []
au = tostring(book.find('authorstext'))
if au:
au = au.strip()
temp = au.split(',')
for au in temp:
if not au: continue
- self.authors.extend([a.strip() for a in au.split('&')])
-
+ authors.extend([a.strip() for a in au.split('&')])
+ if authors:
+ self.authors = authors
try:
self.author_sort = tostring(book.find('authors').find('person'))
if self.authors and self.author_sort == self.authors[0]:
diff --git a/src/calibre/ebooks/metadata/meta.py b/src/calibre/ebooks/metadata/meta.py
index 87b8d3b535..cbd9db3f04 100644
--- a/src/calibre/ebooks/metadata/meta.py
+++ b/src/calibre/ebooks/metadata/meta.py
@@ -15,7 +15,7 @@ _METADATA_PRIORITIES = [
'html', 'htm', 'xhtml', 'xhtm',
'rtf', 'fb2', 'pdf', 'prc', 'odt',
'epub', 'lit', 'lrx', 'lrf', 'mobi',
- 'rb', 'imp', 'azw'
+ 'rb', 'imp', 'azw', 'snb'
]
# The priorities for loading metadata from different file types
diff --git a/src/calibre/ebooks/metadata/snb.py b/src/calibre/ebooks/metadata/snb.py
new file mode 100755
index 0000000000..2a330b19e6
--- /dev/null
+++ b/src/calibre/ebooks/metadata/snb.py
@@ -0,0 +1,47 @@
+'''Read meta information from SNB files'''
+
+from __future__ import with_statement
+
+__license__ = 'GPL v3'
+__copyright__ = '2010, Li Fanxi '
+
+import os
+from StringIO import StringIO
+from calibre.ebooks.metadata import MetaInformation
+from calibre.ebooks.snb.snbfile import SNBFile
+from lxml import etree
+
+def get_metadata(stream, extract_cover=True):
+ """ Return metadata as a L{MetaInfo} object """
+ mi = MetaInformation(_('Unknown'), [_('Unknown')])
+ snbFile = SNBFile()
+
+ try:
+ if not hasattr(stream, 'write'):
+ snbFile.Parse(StringIO(stream), True)
+ else:
+ stream.seek(0)
+ snbFile.Parse(stream, True)
+
+ meta = snbFile.GetFileStream('snbf/book.snbf')
+
+ if meta != None:
+ meta = etree.fromstring(meta)
+ mi.title = meta.find('.//head/name').text
+ mi.authors = [meta.find('.//head/author').text]
+ mi.language = meta.find('.//head/language').text.lower().replace('_', '-')
+ mi.publisher = meta.find('.//head/publisher').text
+
+ if extract_cover:
+ cover = meta.find('.//head/cover')
+ if cover != None and cover.text != None:
+ root, ext = os.path.splitext(cover.text)
+ if ext == '.jpeg':
+ ext = '.jpg'
+ mi.cover_data = (ext[-3:], snbFile.GetFileStream('snbc/images/' + cover.text))
+
+ except Exception:
+ import traceback
+ traceback.print_exc()
+
+ return mi
diff --git a/src/calibre/ebooks/metadata/toc.py b/src/calibre/ebooks/metadata/toc.py
index 8c6f3f6baf..0ed527d26a 100644
--- a/src/calibre/ebooks/metadata/toc.py
+++ b/src/calibre/ebooks/metadata/toc.py
@@ -2,7 +2,7 @@
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
-import os, glob, re
+import os, glob, re, functools
from urlparse import urlparse
from urllib import unquote
from uuid import uuid4
@@ -11,7 +11,7 @@ from lxml import etree
from lxml.builder import ElementMaker
from calibre.constants import __appname__, __version__
-from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup, BeautifulSoup
+from calibre.ebooks.BeautifulSoup import BeautifulSoup
from calibre.ebooks.chardet import xml_to_unicode
NCX_NS = "http://www.daisy.org/z3986/2005/ncx/"
@@ -26,14 +26,6 @@ E = ElementMaker(namespace=NCX_NS, nsmap=NSMAP)
C = ElementMaker(namespace=CALIBRE_NS, nsmap=NSMAP)
-class NCXSoup(BeautifulStoneSoup):
-
- NESTABLE_TAGS = {'navpoint':[]}
-
- def __init__(self, raw):
- BeautifulStoneSoup.__init__(self, raw,
- convertEntities=BeautifulSoup.HTML_ENTITIES,
- selfClosingTags=['meta', 'content'])
class TOC(list):
@@ -166,40 +158,60 @@ class TOC(list):
def read_ncx_toc(self, toc):
self.base_path = os.path.dirname(toc)
- raw = xml_to_unicode(open(toc, 'rb').read(), assume_utf8=True)[0]
- soup = NCXSoup(raw)
+ raw = xml_to_unicode(open(toc, 'rb').read(), assume_utf8=True,
+ strip_encoding_pats=True)[0]
+ root = etree.fromstring(raw, parser=etree.XMLParser(recover=True,
+ no_network=True))
+ xpn = {'re': 'http://exslt.org/regular-expressions'}
+ XPath = functools.partial(etree.XPath, namespaces=xpn)
+
+ def get_attr(node, default=None, attr='playorder'):
+ for name, val in node.attrib.items():
+ if name and val and name.lower().endswith(attr):
+ return val
+ return default
+
+ nl_path = XPath('./*[re:match(local-name(), "navlabel$", "i")]')
+ txt_path = XPath('./*[re:match(local-name(), "text$", "i")]')
+ content_path = XPath('./*[re:match(local-name(), "content$", "i")]')
+ np_path = XPath('./*[re:match(local-name(), "navpoint$", "i")]')
def process_navpoint(np, dest):
- play_order = np.get('playOrder', None)
- if play_order is None:
- play_order = int(np.get('playorder', 1))
+ try:
+ play_order = int(get_attr(np, 1))
+ except:
+ play_order = 1
href = fragment = text = None
- nl = np.find(re.compile('navlabel'))
- if nl is not None:
+ nl = nl_path(np)
+ if nl:
+ nl = nl[0]
text = u''
- for txt in nl.findAll(re.compile('text')):
- text += u''.join([unicode(s) for s in txt.findAll(text=True)])
- content = np.find(re.compile('content'))
- if content is None or not content.has_key('src') or not txt:
+ for txt in txt_path(nl):
+ text += etree.tostring(txt, method='text',
+ encoding=unicode, with_tail=False)
+ content = content_path(np)
+ if not content or not text:
+ return
+ content = content[0]
+ src = get_attr(content, attr='src')
+ if src is None:
return
- purl = urlparse(unquote(content['src']))
+ purl = urlparse(unquote(content.get('src')))
href, fragment = purl[2], purl[5]
nd = dest.add_item(href, fragment, text)
nd.play_order = play_order
- for c in np:
- if 'navpoint' in getattr(c, 'name', ''):
- process_navpoint(c, nd)
+ for c in np_path(np):
+ process_navpoint(c, nd)
- nm = soup.find(re.compile('navmap'))
- if nm is None:
+ nm = XPath('//*[re:match(local-name(), "navmap$", "i")]')(root)
+ if not nm:
raise ValueError('NCX files must have a element.')
+ nm = nm[0]
- for elem in nm:
- if 'navpoint' in getattr(elem, 'name', ''):
- process_navpoint(elem, self)
-
+ for child in np_path(nm):
+ process_navpoint(child, self)
def read_html_toc(self, toc):
self.base_path = os.path.dirname(toc)
diff --git a/src/calibre/ebooks/oeb/base.py b/src/calibre/ebooks/oeb/base.py
index e85098e293..cf80e4abe2 100644
--- a/src/calibre/ebooks/oeb/base.py
+++ b/src/calibre/ebooks/oeb/base.py
@@ -282,9 +282,9 @@ def XPath(expr):
def xpath(elem, expr):
return elem.xpath(expr, namespaces=XPNSMAP)
-def xml2str(root, pretty_print=False, strip_comments=False):
+def xml2str(root, pretty_print=False, strip_comments=False, with_tail=True):
ans = etree.tostring(root, encoding='utf-8', xml_declaration=True,
- pretty_print=pretty_print)
+ pretty_print=pretty_print, with_tail=with_tail)
if strip_comments:
ans = re.compile(r'', re.DOTALL).sub('', ans)
@@ -1908,6 +1908,7 @@ class OEBBook(object):
def _to_ncx(self):
lang = unicode(self.metadata.language[0])
+ lang = lang.replace('_', '-')
ncx = etree.Element(NCX('ncx'),
attrib={'version': '2005-1', XML('lang'): lang},
nsmap={None: NCX_NS})
diff --git a/src/calibre/ebooks/oeb/transforms/rasterize.py b/src/calibre/ebooks/oeb/transforms/rasterize.py
index 1026b625bf..b09037498a 100644
--- a/src/calibre/ebooks/oeb/transforms/rasterize.py
+++ b/src/calibre/ebooks/oeb/transforms/rasterize.py
@@ -55,7 +55,7 @@ class SVGRasterizer(object):
self.rasterize_cover()
def rasterize_svg(self, elem, width=0, height=0, format='PNG'):
- data = QByteArray(xml2str(elem))
+ data = QByteArray(xml2str(elem, with_tail=False))
svg = QSvgRenderer(data)
size = svg.defaultSize()
view_box = elem.get('viewBox', elem.get('viewbox', None))
diff --git a/src/calibre/ebooks/snb/__init__.py b/src/calibre/ebooks/snb/__init__.py
new file mode 100644
index 0000000000..d83022b362
--- /dev/null
+++ b/src/calibre/ebooks/snb/__init__.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+__license__ = 'GPL v3'
+__copyright__ = '2010, Li Fanxi '
+__docformat__ = 'restructuredtext en'
+
+'''
+Used for snb output
+'''
+
diff --git a/src/calibre/ebooks/snb/input.py b/src/calibre/ebooks/snb/input.py
new file mode 100755
index 0000000000..052db6d059
--- /dev/null
+++ b/src/calibre/ebooks/snb/input.py
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL 3'
+__copyright__ = '2010, Li Fanxi '
+__docformat__ = 'restructuredtext en'
+
+import os, uuid
+
+from calibre.customize.conversion import InputFormatPlugin
+from calibre.ebooks.oeb.base import DirContainer
+from calibre.ebooks.snb.snbfile import SNBFile
+from calibre.ptempfile import TemporaryDirectory
+from calibre.utils.filenames import ascii_filename
+from lxml import etree
+
+HTML_TEMPLATE = u'%s \n%s\n'
+
+def html_encode(s):
+ return s.replace(u'&', u'&').replace(u'<', u'<').replace(u'>', u'>').replace(u'"', u'"').replace(u"'", u''').replace(u'\n', u' ').replace(u' ', u' ')
+
+class SNBInput(InputFormatPlugin):
+
+ name = 'SNB Input'
+ author = 'Li Fanxi'
+ description = 'Convert SNB files to OEB'
+ file_types = set(['snb'])
+
+ options = set([
+ ])
+
+ def convert(self, stream, options, file_ext, log,
+ accelerators):
+ log.debug("Parsing SNB file...")
+ snbFile = SNBFile()
+ try:
+ snbFile.Parse(stream)
+ except:
+ raise ValueError("Invalid SNB file")
+ if not snbFile.IsValid():
+ log.debug("Invaild SNB file")
+ raise ValueError("Invalid SNB file")
+ log.debug("Handle meta data ...")
+ from calibre.ebooks.conversion.plumber import create_oebbook
+ oeb = create_oebbook(log, None, options, self,
+ encoding=options.input_encoding, populate=False)
+ meta = snbFile.GetFileStream('snbf/book.snbf')
+ if meta != None:
+ meta = etree.fromstring(meta)
+ oeb.metadata.add('title', meta.find('.//head/name').text)
+ oeb.metadata.add('creator', meta.find('.//head/author').text, attrib={'role':'aut'})
+ oeb.metadata.add('language', meta.find('.//head/language').text.lower().replace('_', '-'))
+ oeb.metadata.add('creator', meta.find('.//head/generator').text)
+ oeb.metadata.add('publisher', meta.find('.//head/publisher').text)
+ cover = meta.find('.//head/cover')
+ if cover != None and cover.text != None:
+ oeb.guide.add('cover', 'Cover', cover.text)
+
+ bookid = str(uuid.uuid4())
+ oeb.metadata.add('identifier', bookid, id='uuid_id', scheme='uuid')
+ for ident in oeb.metadata.identifier:
+ if 'id' in ident.attrib:
+ oeb.uid = oeb.metadata.identifier[0]
+ break
+
+ with TemporaryDirectory('_chm2oeb', keep=True) as tdir:
+ log.debug('Process TOC ...')
+ toc = snbFile.GetFileStream('snbf/toc.snbf')
+ oeb.container = DirContainer(tdir, log)
+ if toc != None:
+ toc = etree.fromstring(toc)
+ i = 1
+ for ch in toc.find('.//body'):
+ chapterName = ch.text
+ chapterSrc = ch.get('src')
+ fname = 'ch_%d.htm' % i
+ data = snbFile.GetFileStream('snbc/' + chapterSrc)
+ if data != None:
+ snbc = etree.fromstring(data)
+ outputFile = open(os.path.join(tdir, fname), 'wb')
+ lines = []
+ for line in snbc.find('.//body'):
+ if line.tag == 'text':
+ lines.append(u'%s
' % html_encode(line.text))
+ elif line.tag == 'img':
+ lines.append(u'
' % html_encode(line.text))
+ outputFile.write((HTML_TEMPLATE % (chapterName, u'\n'.join(lines))).encode('utf-8', 'replace'))
+ outputFile.close()
+ oeb.toc.add(ch.text, fname)
+ id, href = oeb.manifest.generate(id='html',
+ href=ascii_filename(fname))
+ item = oeb.manifest.add(id, href, 'text/html')
+ item.html_input_href = fname
+ oeb.spine.add(item, True)
+ i = i + 1
+ imageFiles = snbFile.OutputImageFiles(tdir)
+ for f, m in imageFiles:
+ id, href = oeb.manifest.generate(id='image',
+ href=ascii_filename(f))
+ item = oeb.manifest.add(id, href, m)
+ item.html_input_href = f
+
+ return oeb
+
diff --git a/src/calibre/ebooks/snb/output.py b/src/calibre/ebooks/snb/output.py
new file mode 100644
index 0000000000..549ee51446
--- /dev/null
+++ b/src/calibre/ebooks/snb/output.py
@@ -0,0 +1,264 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL 3'
+__copyright__ = '2010, Li Fanxi '
+__docformat__ = 'restructuredtext en'
+
+import os, string
+
+from lxml import etree
+from calibre.customize.conversion import OutputFormatPlugin, OptionRecommendation
+from calibre.ptempfile import TemporaryDirectory
+from calibre.constants import __appname__, __version__
+from calibre.ebooks.snb.snbfile import SNBFile
+from calibre.ebooks.snb.snbml import SNBMLizer, ProcessFileName
+
+class SNBOutput(OutputFormatPlugin):
+
+ name = 'SNB Output'
+ author = 'Li Fanxi'
+ file_type = 'snb'
+
+ options = set([
+ # OptionRecommendation(name='newline', recommended_value='system',
+ # level=OptionRecommendation.LOW,
+ # short_switch='n', choices=TxtNewlines.NEWLINE_TYPES.keys(),
+ # help=_('Type of newline to use. Options are %s. Default is \'system\'. '
+ # 'Use \'old_mac\' for compatibility with Mac OS 9 and earlier. '
+ # 'For Mac OS X use \'unix\'. \'system\' will default to the newline '
+ # 'type used by this OS.') % sorted(TxtNewlines.NEWLINE_TYPES.keys())),
+ OptionRecommendation(name='snb_output_encoding', recommended_value='utf-8',
+ level=OptionRecommendation.LOW,
+ help=_('Specify the character encoding of the output document. ' \
+ 'The default is utf-8.')),
+ # OptionRecommendation(name='inline_toc',
+ # recommended_value=False, level=OptionRecommendation.LOW,
+ # help=_('Add Table of Contents to beginning of the book.')),
+ OptionRecommendation(name='snb_max_line_length',
+ recommended_value=0, level=OptionRecommendation.LOW,
+ help=_('The maximum number of characters per line. This splits on '
+ 'the first space before the specified value. If no space is found '
+ 'the line will be broken at the space after and will exceed the '
+ 'specified value. Also, there is a minimum of 25 characters. '
+ 'Use 0 to disable line splitting.')),
+ # OptionRecommendation(name='force_max_line_length',
+ # recommended_value=False, level=OptionRecommendation.LOW,
+ # help=_('Force splitting on the max-line-length value when no space '
+ # 'is present. Also allows max-line-length to be below the minimum')),
+ ])
+
+ def convert(self, oeb_book, output_path, input_plugin, opts, log):
+ self.opts = opts
+ from calibre.ebooks.oeb.transforms.rasterize import SVGRasterizer, Unavailable
+ try:
+ rasterizer = SVGRasterizer()
+ rasterizer(oeb_book, opts)
+ except Unavailable:
+ log.warn('SVG rasterizer unavailable, SVG will not be converted')
+
+ # Create temp dir
+ with TemporaryDirectory('_snb_output') as tdir:
+ # Create stub directories
+ snbfDir = os.path.join(tdir, 'snbf')
+ snbcDir = os.path.join(tdir, 'snbc')
+ snbiDir = os.path.join(tdir, 'snbc/images')
+ os.mkdir(snbfDir)
+ os.mkdir(snbcDir)
+ os.mkdir(snbiDir)
+
+ # Process Meta data
+ meta = oeb_book.metadata
+ if meta.title:
+ title = unicode(meta.title[0])
+ else:
+ title = ''
+ authors = [unicode(x) for x in meta.creator if x.role == 'aut']
+ if meta.publisher:
+ publishers = unicode(meta.publisher[0])
+ else:
+ publishers = ''
+ if meta.language:
+ lang = unicode(meta.language[0]).upper()
+ else:
+ lang = ''
+ if meta.description:
+ abstract = unicode(meta.description[0])
+ else:
+ abstract = ''
+
+ # Process Cover
+ g, m, s = oeb_book.guide, oeb_book.manifest, oeb_book.spine
+ href = None
+ if 'titlepage' not in g:
+ if 'cover' in g:
+ href = g['cover'].href
+
+ # Output book info file
+ bookInfoTree = etree.Element("book-snbf", version="1.0")
+ headTree = etree.SubElement(bookInfoTree, "head")
+ etree.SubElement(headTree, "name").text = title
+ etree.SubElement(headTree, "author").text = ' '.join(authors)
+ etree.SubElement(headTree, "language").text = lang
+ etree.SubElement(headTree, "rights")
+ etree.SubElement(headTree, "publisher").text = publishers
+ etree.SubElement(headTree, "generator").text = __appname__ + ' ' + __version__
+ etree.SubElement(headTree, "created")
+ etree.SubElement(headTree, "abstract").text = abstract
+ if href != None:
+ etree.SubElement(headTree, "cover").text = ProcessFileName(href)
+ else:
+ etree.SubElement(headTree, "cover")
+ bookInfoFile = open(os.path.join(snbfDir, 'book.snbf'), 'wb')
+ bookInfoFile.write(etree.tostring(bookInfoTree, pretty_print=True, encoding='utf-8'))
+ bookInfoFile.close()
+
+ # Output TOC
+ tocInfoTree = etree.Element("toc-snbf")
+ tocHead = etree.SubElement(tocInfoTree, "head")
+ tocBody = etree.SubElement(tocInfoTree, "body")
+ outputFiles = { }
+ if oeb_book.toc.count() == 0:
+ log.warn('This SNB file has no Table of Contents. '
+ 'Creating a default TOC')
+ first = iter(oeb_book.spine).next()
+ oeb_book.toc.add(_('Start Page'), first.href)
+ else:
+ first = iter(oeb_book.spine).next()
+ if oeb_book.toc[0].href != first.href:
+ # The pages before the fist item in toc will be stored as
+ # "Cover Pages".
+ # oeb_book.toc does not support "insert", so we generate
+ # the tocInfoTree directly instead of modifying the toc
+ ch = etree.SubElement(tocBody, "chapter")
+ ch.set("src", ProcessFileName(first.href) + ".snbc")
+ ch.text = _('Cover Pages')
+ outputFiles[first.href] = []
+ outputFiles[first.href].append(("", _("Cover Pages")))
+
+ for tocitem in oeb_book.toc:
+ if tocitem.href.find('#') != -1:
+ item = string.split(tocitem.href, '#')
+ if len(item) != 2:
+ log.error('Error in TOC item: %s' % tocitem)
+ else:
+ if item[0] in outputFiles:
+ outputFiles[item[0]].append((item[1], tocitem.title))
+ else:
+ outputFiles[item[0]] = []
+ if not "" in outputFiles[item[0]]:
+ outputFiles[item[0]].append(("", tocitem.title + _(" (Preface)")))
+ ch = etree.SubElement(tocBody, "chapter")
+ ch.set("src", ProcessFileName(item[0]) + ".snbc")
+ ch.text = tocitem.title + _(" (Preface)")
+ outputFiles[item[0]].append((item[1], tocitem.title))
+ else:
+ if tocitem.href in outputFiles:
+ outputFiles[tocitem.href].append(("", tocitem.title))
+ else:
+ outputFiles[tocitem.href] = []
+ outputFiles[tocitem.href].append(("", tocitem.title))
+ ch = etree.SubElement(tocBody, "chapter")
+ ch.set("src", ProcessFileName(tocitem.href) + ".snbc")
+ ch.text = tocitem.title
+
+
+ etree.SubElement(tocHead, "chapters").text = '%d' % len(tocBody)
+
+ tocInfoFile = open(os.path.join(snbfDir, 'toc.snbf'), 'wb')
+ tocInfoFile.write(etree.tostring(tocInfoTree, pretty_print=True, encoding='utf-8'))
+ tocInfoFile.close()
+
+ # Output Files
+ oldTree = None
+ mergeLast = False
+ lastName = None
+ for item in s:
+ from calibre.ebooks.oeb.base import OEB_DOCS, OEB_IMAGES
+ if m.hrefs[item.href].media_type in OEB_DOCS:
+ if not item.href in outputFiles:
+ log.debug('File %s is unused in TOC. Continue in last chapter' % item.href)
+ mergeLast = True
+ else:
+ if oldTree != None and mergeLast:
+ log.debug('Output the modified chapter again: %s' % lastName)
+ outputFile = open(os.path.join(snbcDir, lastName), 'wb')
+ outputFile.write(etree.tostring(oldTree, pretty_print=True, encoding='utf-8'))
+ outputFile.close()
+ mergeLast = False
+
+ log.debug('Converting %s to snbc...' % item.href)
+ snbwriter = SNBMLizer(log)
+ snbcTrees = None
+ if not mergeLast:
+ snbcTrees = snbwriter.extract_content(oeb_book, item, outputFiles[item.href], opts)
+ for subName in snbcTrees:
+ postfix = ''
+ if subName != '':
+ postfix = '_' + subName
+ lastName = ProcessFileName(item.href + postfix + ".snbc")
+ oldTree = snbcTrees[subName]
+ outputFile = open(os.path.join(snbcDir, lastName), 'wb')
+ outputFile.write(etree.tostring(oldTree, pretty_print=True, encoding='utf-8'))
+ outputFile.close()
+ else:
+ log.debug('Merge %s with last TOC item...' % item.href)
+ snbwriter.merge_content(oldTree, oeb_book, item, [('', _("Start"))], opts)
+
+ # Output the last one if needed
+ log.debug('Output the last modified chapter again: %s' % lastName)
+ if oldTree != None and mergeLast:
+ outputFile = open(os.path.join(snbcDir, lastName), 'wb')
+ outputFile.write(etree.tostring(oldTree, pretty_print=True, encoding='utf-8'))
+ outputFile.close()
+ mergeLast = False
+
+ for item in m:
+ if m.hrefs[item.href].media_type in OEB_IMAGES:
+ log.debug('Converting image: %s ...' % item.href)
+ content = m.hrefs[item.href].data
+ # Convert & Resize image
+ self.HandleImage(content, os.path.join(snbiDir, ProcessFileName(item.href)))
+
+ # Package as SNB File
+ snbFile = SNBFile()
+ snbFile.FromDir(tdir)
+ snbFile.Output(output_path)
+
+ def HandleImage(self, imageData, imagePath):
+ from calibre.utils.magick import Image
+ img = Image()
+ img.load(imageData)
+ (x,y) = img.size
+ if self.opts:
+ SCREEN_Y, SCREEN_X = self.opts.output_profile.comic_screen_size
+ else:
+ SCREEN_X = 540
+ SCREEN_Y = 700
+ # Handle big image only
+ if x > SCREEN_X or y > SCREEN_Y:
+ xScale = float(x) / SCREEN_X
+ yScale = float(y) / SCREEN_Y
+ scale = max(xScale, yScale)
+ # TODO : intelligent image rotation
+ # img = img.rotate(90)
+ # x,y = y,x
+ img.size = (x / scale, y / scale)
+ img.save(imagePath)
+
+if __name__ == '__main__':
+ from calibre.ebooks.oeb.reader import OEBReader
+ from calibre.ebooks.oeb.base import OEBBook
+ from calibre.ebooks.conversion.preprocess import HTMLPreProcessor
+ from calibre.customize.profiles import HanlinV3Output
+ class OptionValues(object):
+ pass
+
+ opts = OptionValues()
+ opts.output_profile = HanlinV3Output(None)
+
+ html_preprocessor = HTMLPreProcessor(None, None, opts)
+ from calibre.utils.logging import default_log
+ oeb = OEBBook(default_log, html_preprocessor)
+ reader = OEBReader
+ reader()(oeb, '/tmp/bbb/processed/')
+ SNBOutput(None).convert(oeb, '/tmp/test.snb', None, None, default_log);
diff --git a/src/calibre/ebooks/snb/snbfile.py b/src/calibre/ebooks/snb/snbfile.py
new file mode 100644
index 0000000000..ed5aa45c08
--- /dev/null
+++ b/src/calibre/ebooks/snb/snbfile.py
@@ -0,0 +1,319 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL 3'
+__copyright__ = '2010, Li Fanxi '
+__docformat__ = 'restructuredtext en'
+
+import sys, struct, zlib, bz2, os
+from mimetypes import types_map
+
+class FileStream:
+ def IsBinary(self):
+ return self.attr & 0x41000000 != 0x41000000
+
+def compareFileStream(file1, file2):
+ return cmp(file1.fileName, file2.fileName)
+
+class BlockData:
+ pass
+
+class SNBFile:
+
+ MAGIC = 'SNBP000B'
+ REV80 = 0x00008000
+ REVA3 = 0x00A3A3A3
+ REVZ1 = 0x00000000
+ REVZ2 = 0x00000000
+
+ def __init__(self, inputFile = None):
+ self.files = []
+ self.blocks = []
+
+ if inputFile != None:
+ self.Open(inputFile)
+
+ def Open(self, inputFile):
+ self.fileName = inputFile
+
+ snbFile = open(self.fileName, "rb")
+ snbFile.seek(0)
+ self.Parse(snbFile)
+ snbFile.close()
+
+ def Parse(self, snbFile, metaOnly = False):
+ # Read header
+ vmbr = snbFile.read(44)
+ (self.magic, self.rev80, self.revA3, self.revZ1,
+ self.fileCount, self.vfatSize, self.vfatCompressed,
+ self.binStreamSize, self.plainStreamSizeUncompressed,
+ self.revZ2) = struct.unpack('>8siiiiiiiii', vmbr)
+
+ # Read FAT
+ self.vfat = zlib.decompress(snbFile.read(self.vfatCompressed))
+ self.ParseFile(self.vfat, self.fileCount)
+
+ # Read tail
+ snbFile.seek(-16, os.SEEK_END)
+ #plainStreamEnd = snbFile.tell()
+ tailblock = snbFile.read(16)
+ (self.tailSize, self.tailOffset, self.tailMagic) = struct.unpack('>ii8s', tailblock)
+ snbFile.seek(self.tailOffset)
+ self.vTailUncompressed = zlib.decompress(snbFile.read(self.tailSize))
+ self.tailSizeUncompressed = len(self.vTailUncompressed)
+ self.ParseTail(self.vTailUncompressed, self.fileCount)
+
+ # Uncompress file data
+ # Read files
+ binPos = 0
+ plainPos = 0
+ uncompressedData = None
+ for f in self.files:
+ if f.attr & 0x41000000 == 0x41000000:
+ # Compressed Files
+ if uncompressedData == None:
+ uncompressedData = ""
+ for i in range(self.plainBlock):
+ bzdc = bz2.BZ2Decompressor()
+ if (i < self.plainBlock - 1):
+ bSize = self.blocks[self.binBlock + i + 1].Offset - self.blocks[self.binBlock + i].Offset;
+ else:
+ bSize = self.tailOffset - self.blocks[self.binBlock + i].Offset;
+ snbFile.seek(self.blocks[self.binBlock + i].Offset);
+ try:
+ data = snbFile.read(bSize)
+ uncompressedData += bzdc.decompress(data)
+ except Exception, e:
+ print e
+ f.fileBody = uncompressedData[plainPos:plainPos+f.fileSize]
+ plainPos += f.fileSize
+ elif f.attr & 0x01000000 == 0x01000000:
+ # Binary Files
+ snbFile.seek(44 + self.vfatCompressed + binPos)
+ f.fileBody = snbFile.read(f.fileSize)
+ binPos += f.fileSize
+ else:
+ print f.attr, f.fileName
+ raise Exception("Invalid file")
+
+ def ParseFile(self, vfat, fileCount):
+ fileNames = vfat[fileCount*12:].split('\0');
+ for i in range(fileCount):
+ f = FileStream()
+ (f.attr, f.fileNameOffset, f.fileSize) = struct.unpack('>iii', vfat[i * 12 : (i+1)*12])
+ f.fileName = fileNames[i]
+ self.files.append(f)
+
+ def ParseTail(self, vtail, fileCount):
+ self.binBlock = (self.binStreamSize + 0x8000 - 1) / 0x8000;
+ self.plainBlock = (self.plainStreamSizeUncompressed + 0x8000 - 1) / 0x8000;
+ for i in range(self.binBlock + self.plainBlock):
+ block = BlockData()
+ (block.Offset,) = struct.unpack('>i', vtail[i * 4 : (i+1) * 4])
+ self.blocks.append(block)
+ for i in range(fileCount):
+ (self.files[i].blockIndex, self.files[i].contentOffset) = struct.unpack('>ii', vtail[(self.binBlock + self.plainBlock) * 4 + i * 8 : (self.binBlock + self.plainBlock) * 4 + (i+1) * 8])
+
+ def IsValid(self):
+ if self.magic != SNBFile.MAGIC:
+ return False
+ if self.rev80 != SNBFile.REV80:
+ return False
+ if self.revA3 != SNBFile.REVA3:
+ return False
+ if self.revZ1 != SNBFile.REVZ1:
+ return False
+ if self.revZ2 != SNBFile.REVZ2:
+ return False
+ if self.vfatSize != len(self.vfat):
+ return False
+ if self.fileCount != len(self.files):
+ return False
+ if (self.binBlock + self.plainBlock) * 4 + self.fileCount * 8 != self.tailSizeUncompressed:
+ return False
+ if self.tailMagic != SNBFile.MAGIC:
+ print self.tailMagic
+ return False
+ return True
+
+ def FromDir(self, tdir):
+ for root, dirs, files in os.walk(tdir):
+ for name in files:
+ p, ext = os.path.splitext(name)
+ if ext in [ ".snbf", ".snbc" ]:
+ self.AppendPlain(os.path.relpath(os.path.join(root, name), tdir), tdir)
+ else:
+ self.AppendBinary(os.path.relpath(os.path.join(root, name), tdir), tdir)
+
+ def AppendPlain(self, fileName, tdir):
+ f = FileStream()
+ f.attr = 0x41000000
+ f.fileSize = os.path.getsize(os.path.join(tdir,fileName))
+ f.fileBody = open(os.path.join(tdir,fileName), 'rb').read()
+ f.fileName = fileName.replace(os.sep, '/')
+ self.files.append(f)
+
+ def AppendBinary(self, fileName, tdir):
+ f = FileStream()
+ f.attr = 0x01000000
+ f.fileSize = os.path.getsize(os.path.join(tdir,fileName))
+ f.fileBody = open(os.path.join(tdir,fileName), 'rb').read()
+ f.fileName = fileName.replace(os.sep, '/')
+ self.files.append(f)
+
+ def GetFileStream(self, fileName):
+ for file in self.files:
+ if file.fileName == fileName:
+ return file.fileBody
+ return None
+
+ def OutputImageFiles(self, path):
+ fileNames = []
+ for f in self.files:
+ fname = os.path.basename(f.fileName)
+ root, ext = os.path.splitext(fname)
+ if ext in [ '.jpeg', '.jpg', '.gif', '.svg', '.png' ]:
+ file = open(os.path.join(path, fname), 'wb')
+ file.write(f.fileBody)
+ file.close()
+ fileNames.append((fname, types_map[ext]))
+ return fileNames
+
+ def Output(self, outputFile):
+
+ # Sort the files in file buffer,
+ # requried by the SNB file format
+ self.files.sort(compareFileStream)
+
+ outputFile = open(outputFile, 'wb')
+ # File header part 1
+ vmbrp1 = struct.pack('>8siiii', SNBFile.MAGIC, SNBFile.REV80, SNBFile.REVA3, SNBFile.REVZ1, len(self.files))
+
+ # Create VFAT & file stream
+ vfat = ''
+ fileNameTable = ''
+ plainStream = ''
+ binStream = ''
+ for f in self.files:
+ vfat += struct.pack('>iii', f.attr, len(fileNameTable), f.fileSize);
+ fileNameTable += (f.fileName + '\0')
+
+ if f.attr & 0x41000000 == 0x41000000:
+ # Plain Files
+ f.contentOffset = len(plainStream)
+ plainStream += f.fileBody
+ elif f.attr & 0x01000000 == 0x01000000:
+ # Binary Files
+ f.contentOffset = len(binStream)
+ binStream += f.fileBody
+ else:
+ print f.attr, f.fileName
+ raise Exception("Unknown file type")
+ vfatCompressed = zlib.compress(vfat+fileNameTable)
+
+ # File header part 2
+ vmbrp2 = struct.pack('>iiiii', len(vfat+fileNameTable), len(vfatCompressed), len(binStream), len(plainStream), SNBFile.REVZ2)
+ # Write header
+ outputFile.write(vmbrp1 + vmbrp2)
+ # Write vfat
+ outputFile.write(vfatCompressed)
+
+ # Generate block information
+ binBlockOffset = 0x2C + len(vfatCompressed)
+ plainBlockOffset = binBlockOffset + len(binStream)
+
+ binBlock = (len(binStream) + 0x8000 - 1) / 0x8000
+ #plainBlock = (len(plainStream) + 0x8000 - 1) / 0x8000
+
+ offset = 0
+ tailBlock = ''
+ for i in range(binBlock):
+ tailBlock += struct.pack('>i', binBlockOffset + offset)
+ offset += 0x8000;
+ tailRec = ''
+ for f in self.files:
+ t = 0
+ if f.IsBinary():
+ t = 0
+ else:
+ t = binBlock
+ tailRec += struct.pack('>ii', f.contentOffset / 0x8000 + t, f.contentOffset % 0x8000);
+
+ # Write binary stream
+ outputFile.write(binStream)
+
+ # Write plain stream
+ pos = 0
+ offset = 0
+ while pos < len(plainStream):
+ tailBlock += struct.pack('>i', plainBlockOffset + offset);
+ block = plainStream[pos:pos+0x8000];
+ compressed = bz2.compress(block)
+ outputFile.write(compressed)
+ offset += len(compressed)
+ pos += 0x8000
+
+ # Write tail block
+ compressedTail = zlib.compress(tailBlock + tailRec)
+ outputFile.write(compressedTail)
+
+ # Write tail pointer
+ veom = struct.pack('>ii', len(compressedTail), plainBlockOffset + offset)
+ outputFile.write(veom)
+
+ # Write file end mark
+ outputFile.write(SNBFile.MAGIC);
+
+ # Close
+ outputFile.close()
+ return
+
+ def Dump(self):
+ if self.fileName:
+ print "File Name:\t", self.fileName
+ print "File Count:\t", self.fileCount
+ print "VFAT Size(Compressed):\t%d(%d)" % (self.vfatSize, self.vfatCompressed)
+ print "Binary Stream Size:\t", self.binStreamSize
+ print "Plain Stream Uncompressed Size:\t", self.plainStreamSizeUncompressed
+ print "Binary Block Count:\t", self.binBlock
+ print "Plain Block Count:\t", self.plainBlock
+ for i in range(self.fileCount):
+ print "File ", i
+ f = self.files[i]
+ print "File Name: ", f.fileName
+ print "File Attr: ", f.attr
+ print "File Size: ", f.fileSize
+ print "Block Index: ", f.blockIndex
+ print "Content Offset: ", f.contentOffset
+ tempFile = open("/tmp/" + f.fileName, 'wb')
+ tempFile.write(f.fileBody)
+ tempFile.close()
+
+def usage():
+ print "This unit test is for INTERNAL usage only!"
+ print "This unit test accept two parameters."
+ print "python snbfile.py "
+ print "The input file will be extracted and write to dest file. "
+ print "Meta data of the file will be shown during this process."
+
+def main():
+ if len(sys.argv) != 3:
+ usage()
+ sys.exit(0)
+ inputFile = sys.argv[1]
+ outputFile = sys.argv[2]
+
+ print "Input file: ", inputFile
+ print "Output file: ", outputFile
+
+ snbFile = SNBFile(inputFile)
+ if snbFile.IsValid():
+ snbFile.Dump()
+ snbFile.Output(outputFile)
+ else:
+ print "The input file is invalid."
+ return 1
+ return 0
+
+if __name__ == "__main__":
+ """SNB file unit test"""
+ sys.exit(main())
diff --git a/src/calibre/ebooks/snb/snbml.py b/src/calibre/ebooks/snb/snbml.py
new file mode 100644
index 0000000000..e3eed5a476
--- /dev/null
+++ b/src/calibre/ebooks/snb/snbml.py
@@ -0,0 +1,263 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL 3'
+__copyright__ = '2010, Li Fanxi '
+__docformat__ = 'restructuredtext en'
+
+'''
+Transform OEB content into SNB format
+'''
+
+import os
+import re
+
+from lxml import etree
+
+from calibre.ebooks.oeb.base import XHTML, XHTML_NS, barename, namespace
+from calibre.ebooks.oeb.stylizer import Stylizer
+
+def ProcessFileName(fileName):
+ # Flat the path
+ fileName = fileName.replace("/", "_").replace(os.sep, "_")
+ # Handle bookmark for HTML file
+ fileName = fileName.replace("#", "_")
+ # Make it lower case
+ fileName = fileName.lower()
+ # Change all images to jpg
+ root, ext = os.path.splitext(fileName)
+ if ext in [ '.jpeg', '.jpg', '.gif', '.svg', '.png' ]:
+ fileName = root + '.jpg'
+ return fileName
+
+
+BLOCK_TAGS = [
+ 'div',
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'li',
+ 'tr',
+]
+
+BLOCK_STYLES = [
+ 'block',
+]
+
+SPACE_TAGS = [
+ 'td',
+]
+
+CALIBRE_SNB_IMG_TAG = "<$$calibre_snb_temp_img$$>"
+CALIBRE_SNB_BM_TAG = "<$$calibre_snb_bm_tag$$>"
+CALIBRE_SNB_PRE_TAG = "<$$calibre_snb_pre_tag$$>"
+
+class SNBMLizer(object):
+
+ curSubItem = ""
+# curText = [ ]
+
+ def __init__(self, log):
+ self.log = log
+
+ def extract_content(self, oeb_book, item, subitems, opts):
+ self.log.info('Converting XHTML to SNBC...')
+ self.oeb_book = oeb_book
+ self.opts = opts
+ self.item = item
+ self.subitems = subitems
+ return self.mlize();
+
+ def merge_content(self, old_tree, oeb_book, item, subitems, opts):
+ newTrees = self.extract_content(oeb_book, item, subitems, opts)
+ body = old_tree.find(".//body")
+ if body != None:
+ for subName in newTrees:
+ newbody = newTrees[subName].find(".//body")
+ for entity in newbody:
+ body.append(entity)
+
+ def mlize(self):
+ output = [ u'' ]
+ stylizer = Stylizer(self.item.data, self.item.href, self.oeb_book, self.opts, self.opts.output_profile)
+ content = unicode(etree.tostring(self.item.data.find(XHTML('body')), encoding=unicode))
+# content = self.remove_newlines(content)
+ trees = { }
+ for subitem, subtitle in self.subitems:
+ snbcTree = etree.Element("snbc")
+ etree.SubElement(etree.SubElement(snbcTree, "head"), "title").text = subtitle
+ etree.SubElement(snbcTree, "body")
+ trees[subitem] = snbcTree
+ output.append(u'%s%s\n\n' % (CALIBRE_SNB_BM_TAG, ""))
+ output += self.dump_text(self.subitems, etree.fromstring(content), stylizer)[0]
+ output = self.cleanup_text(u''.join(output))
+
+ subitem = ''
+ for line in output.splitlines():
+ if not line.find(CALIBRE_SNB_PRE_TAG) == 0:
+ line = line.strip(u' \t\n\r\u3000')
+ else:
+ etree.SubElement(trees[subitem].find(".//body"), "text").text = \
+ etree.CDATA(line[len(CALIBRE_SNB_PRE_TAG):])
+ continue
+ if len(line) != 0:
+ if line.find(CALIBRE_SNB_IMG_TAG) == 0:
+ prefix = ProcessFileName(os.path.dirname(self.item.href))
+ if prefix != '':
+ etree.SubElement(trees[subitem].find(".//body"), "img").text = \
+ prefix + '_' + line[len(CALIBRE_SNB_IMG_TAG):]
+ else:
+ etree.SubElement(trees[subitem].find(".//body"), "img").text = \
+ line[len(CALIBRE_SNB_IMG_TAG):]
+ elif line.find(CALIBRE_SNB_BM_TAG) == 0:
+ subitem = line[len(CALIBRE_SNB_BM_TAG):]
+ else:
+ etree.SubElement(trees[subitem].find(".//body"), "text").text = \
+ etree.CDATA(unicode(u'\u3000\u3000' + line))
+ return trees
+
+ def remove_newlines(self, text):
+ self.log.debug('\tRemove newlines for processing...')
+ text = text.replace('\r\n', ' ')
+ text = text.replace('\n', ' ')
+ text = text.replace('\r', ' ')
+
+ return text
+
+ def cleanup_text(self, text):
+ self.log.debug('\tClean up text...')
+ # Replace bad characters.
+ text = text.replace(u'\xc2', '')
+ text = text.replace(u'\xa0', ' ')
+ text = text.replace(u'\xa9', '(C)')
+
+ # Replace tabs, vertical tags and form feeds with single space.
+ text = text.replace('\t+', ' ')
+ text = text.replace('\v+', ' ')
+ text = text.replace('\f+', ' ')
+
+ # Single line paragraph.
+ text = re.sub('(?<=.)%s(?=.)' % os.linesep, ' ', text)
+
+ # Remove multiple spaces.
+ #text = re.sub('[ ]{2,}', ' ', text)
+
+ # Remove excessive newlines.
+ text = re.sub('\n[ ]+\n', '\n\n', text)
+ if self.opts.remove_paragraph_spacing:
+ text = re.sub('\n{2,}', '\n', text)
+ text = re.sub('(?imu)^(?=.)', '\t', text)
+ else:
+ text = re.sub('\n{3,}', '\n\n', text)
+
+ # Replace spaces at the beginning and end of lines
+ text = re.sub('(?imu)^[ ]+', '', text)
+ text = re.sub('(?imu)[ ]+$', '', text)
+
+ if self.opts.snb_max_line_length:
+ max_length = self.opts.snb_max_line_length
+ if self.opts.max_line_length < 25:# and not self.opts.force_max_line_length:
+ max_length = 25
+ short_lines = []
+ lines = text.splitlines()
+ for line in lines:
+ while len(line) > max_length:
+ space = line.rfind(' ', 0, max_length)
+ if space != -1:
+ # Space was found.
+ short_lines.append(line[:space])
+ line = line[space + 1:]
+ else:
+ # Space was not found.
+ if False and self.opts.force_max_line_length:
+ # Force breaking at max_lenght.
+ short_lines.append(line[:max_length])
+ line = line[max_length:]
+ else:
+ # Look for the first space after max_length.
+ space = line.find(' ', max_length, len(line))
+ if space != -1:
+ # Space was found.
+ short_lines.append(line[:space])
+ line = line[space + 1:]
+ else:
+ # No space was found cannot break line.
+ short_lines.append(line)
+ line = ''
+ # Add the text that was less than max_lengh to the list
+ short_lines.append(line)
+ text = '\n'.join(short_lines)
+
+ return text
+
+ def dump_text(self, subitems, elem, stylizer, end='', pre=False, li = ''):
+
+ if not isinstance(elem.tag, basestring) \
+ or namespace(elem.tag) != XHTML_NS:
+ return ['']
+
+
+ text = ['']
+ style = stylizer.style(elem)
+
+ if elem.attrib.get('id') != None and elem.attrib['id'] in [ href for href, title in subitems ]:
+ if self.curSubItem != None and self.curSubItem != elem.attrib['id']:
+ self.curSubItem = elem.attrib['id']
+ text.append(u'\n\n%s%s\n\n' % (CALIBRE_SNB_BM_TAG, self.curSubItem))
+
+ if style['display'] in ('none', 'oeb-page-head', 'oeb-page-foot') \
+ or style['visibility'] == 'hidden':
+ return ['']
+
+ tag = barename(elem.tag)
+ in_block = False
+
+ # Are we in a paragraph block?
+ if tag in BLOCK_TAGS or style['display'] in BLOCK_STYLES:
+ in_block = True
+ if not end.endswith(u'\n\n') and hasattr(elem, 'text') and elem.text:
+ text.append(u'\n\n')
+
+ if tag in SPACE_TAGS:
+ if not end.endswith('u ') and hasattr(elem, 'text') and elem.text:
+ text.append(u' ')
+
+ if tag == 'img':
+ text.append(u'\n\n%s%s\n\n' % (CALIBRE_SNB_IMG_TAG, ProcessFileName(elem.attrib['src'])))
+
+ if tag == 'br':
+ text.append(u'\n\n')
+
+ if tag == 'li':
+ li = '- '
+
+ pre = (tag == 'pre' or pre)
+ # Process tags that contain text.
+ if hasattr(elem, 'text') and elem.text:
+ if pre:
+ text.append((u'\n\n%s' % CALIBRE_SNB_PRE_TAG ).join((li + elem.text).splitlines()))
+ else:
+ text.append(li + elem.text)
+ li = ''
+
+ for item in elem:
+ en = u''
+ if len(text) >= 2:
+ en = text[-1][-2:]
+ t = self.dump_text(subitems, item, stylizer, en, pre, li)[0]
+ text += t
+
+ if in_block:
+ text.append(u'\n\n')
+
+ if hasattr(elem, 'tail') and elem.tail:
+ if pre:
+ text.append((u'\n\n%s' % CALIBRE_SNB_PRE_TAG ).join(elem.tail.splitlines()))
+ else:
+ text.append(li + elem.tail)
+ li = ''
+
+ return text, li
diff --git a/src/calibre/gui2/actions/add.py b/src/calibre/gui2/actions/add.py
index be1f8f4eaf..d2e7016e6f 100644
--- a/src/calibre/gui2/actions/add.py
+++ b/src/calibre/gui2/actions/add.py
@@ -166,6 +166,7 @@ class AddAction(InterfaceAction):
(_('Topaz books'), ['tpz','azw1']),
(_('Text books'), ['txt', 'rtf']),
(_('PDF Books'), ['pdf']),
+ (_('SNB Books'), ['snb']),
(_('Comics'), ['cbz', 'cbr', 'cbc']),
(_('Archives'), ['zip', 'rar']),
]
@@ -235,6 +236,10 @@ class AddAction(InterfaceAction):
self.gui.refresh_ondevice()
def add_books_from_device(self, view, paths=None):
+ backloading_err = self.gui.device_manager.device.BACKLOADING_ERROR_MESSAGE
+ if backloading_err is not None:
+ return error_dialog(self.gui, _('Add to library'), backloading_err,
+ show=True)
if paths is None:
rows = view.selectionModel().selectedRows()
if not rows or len(rows) == 0:
diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py
index 2a81a1500d..c46d77cd06 100644
--- a/src/calibre/gui2/actions/edit_metadata.py
+++ b/src/calibre/gui2/actions/edit_metadata.py
@@ -169,7 +169,7 @@ class EditMetadataAction(InterfaceAction):
self.gui.tags_view.blockSignals(False)
if changed:
m = self.gui.library_view.model()
- m.resort(reset=False)
+ m.refresh(reset=False)
m.research()
self.gui.tags_view.recount()
if self.gui.cover_flow:
diff --git a/src/calibre/gui2/convert/metadata.py b/src/calibre/gui2/convert/metadata.py
index b206bf68a6..7a02cf4429 100644
--- a/src/calibre/gui2/convert/metadata.py
+++ b/src/calibre/gui2/convert/metadata.py
@@ -172,7 +172,7 @@ class MetadataWidget(Widget, Ui_Form):
if _file:
_file = os.path.abspath(_file)
if not os.access(_file, os.R_OK):
- d = error_dialog(self.window, _('Cannot read'),
+ d = error_dialog(self.parent(), _('Cannot read'),
_('You do not have permission to read the file: ') + _file)
d.exec_()
return
@@ -181,14 +181,14 @@ class MetadataWidget(Widget, Ui_Form):
cf = open(_file, "rb")
cover = cf.read()
except IOError, e:
- d = error_dialog(self.window, _('Error reading file'),
+ d = error_dialog(self.parent(), _('Error reading file'),
_("There was an error reading from file: ") + _file + "
"+str(e))
d.exec_()
if cover:
pix = QPixmap()
pix.loadFromData(cover)
if pix.isNull():
- d = error_dialog(self.window, _('Error reading file'),
+ d = error_dialog(self.parent(), _('Error reading file'),
_file + _(" is not a valid picture"))
d.exec_()
else:
diff --git a/src/calibre/gui2/convert/snb_output.py b/src/calibre/gui2/convert/snb_output.py
new file mode 100644
index 0000000000..b3ebfc747f
--- /dev/null
+++ b/src/calibre/gui2/convert/snb_output.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+__license__ = 'GPL 3'
+__copyright__ = '2009, John Schember '
+__docformat__ = 'restructuredtext en'
+
+from calibre.gui2.convert.snb_output_ui import Ui_Form
+from calibre.gui2.convert import Widget
+
+newline_model = None
+
+class PluginWidget(Widget, Ui_Form):
+
+ TITLE = _('SNB Output')
+ HELP = _('Options specific to')+' SNB '+_('output')
+ COMMIT_NAME = 'snb_output'
+ ICON = I('mimetypes/snb.png')
+
+ def __init__(self, parent, get_option, get_help, db=None, book_id=None):
+ Widget.__init__(self, parent,
+ [])
+ self.db, self.book_id = db, book_id
+ self.initialize_options(get_option, get_help, db, book_id)
+
+ # default = self.opt_newline.currentText()
+
+ # global newline_model
+ # if newline_model is None:
+ # newline_model = BasicComboModel(TxtNewlines.NEWLINE_TYPES.keys())
+ # self.newline_model = newline_model
+ # self.opt_newline.setModel(self.newline_model)
+
+ # default_index = self.opt_newline.findText(default)
+ # system_index = self.opt_newline.findText('system')
+ # self.opt_newline.setCurrentIndex(default_index if default_index != -1 else system_index if system_index != -1 else 0)
diff --git a/src/calibre/gui2/convert/snb_output.ui b/src/calibre/gui2/convert/snb_output.ui
new file mode 100644
index 0000000000..a5ff8ce7ef
--- /dev/null
+++ b/src/calibre/gui2/convert/snb_output.ui
@@ -0,0 +1,74 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ Form
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py
index bc9f5cf671..e662c6a5cc 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -793,11 +793,17 @@ class DeviceMixin(object): # {{{
self.set_books_in_library(job.result, reset=True)
mainlist, cardalist, cardblist = job.result
self.memory_view.set_database(mainlist)
- self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
+ self.memory_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
+ self.device_manager.device.BACKLOADING_ERROR_MESSAGE
+ is None)
self.card_a_view.set_database(cardalist)
- self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
+ self.card_a_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
+ self.device_manager.device.BACKLOADING_ERROR_MESSAGE
+ is None)
self.card_b_view.set_database(cardblist)
- self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA)
+ self.card_b_view.set_editable(self.device_manager.device.CAN_SET_METADATA,
+ self.device_manager.device.BACKLOADING_ERROR_MESSAGE
+ is None)
self.sync_news()
self.sync_catalogs()
self.refresh_ondevice()
diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui
index 60e24dbceb..0fe537b598 100644
--- a/src/calibre/gui2/dialogs/metadata_bulk.ui
+++ b/src/calibre/gui2/dialogs/metadata_bulk.ui
@@ -7,7 +7,7 @@
0
0
752
- 715
+ 633
@@ -263,7 +263,7 @@
20
- 00
+ 0
@@ -357,13 +357,13 @@ from the value in the box
-
-
- Change title to title case
-
Force the title to be in title case. If both this and swap authors are checked,
title and author are swapped before the title case is set
+
+ Change title to title case
+
-
@@ -486,15 +486,15 @@ Future conversion of these books will use the default settings.
-
-
- Enter the what you are looking for, either plain text or a regular expression, depending on the mode
-
100
0
+
+ Enter the what you are looking for, either plain text or a regular expression, depending on the mode
+
-
@@ -656,6 +656,14 @@ nothing should be put between the original text and the inserted text
true
+
+
+ 0
+ 0
+ 726
+ 334
+
+
-
@@ -674,19 +682,6 @@ nothing should be put between the original text and the inserted text
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 0
-
-
-
-
@@ -733,14 +728,33 @@ nothing should be put between the original text and the inserted text
author_sort
rating
publisher
- tag_editor_button
tags
+ tag_editor_button
remove_tags
+ remove_all_tags
series
+ clear_series
autonumber_series
+ series_numbering_restarts
+ series_start_number
remove_format
+ remove_conversion_settings
swap_title_and_author
+ change_title_to_title_case
button_box
+ central_widget
+ search_field
+ search_mode
+ search_for
+ case_sensitive
+ replace_with
+ replace_func
+ destination_field
+ replace_mode
+ comma_separated
+ scrollArea11
+ test_text
+ test_result
diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py
index b39b752ac6..ef1bddca0c 100644
--- a/src/calibre/gui2/dialogs/metadata_single.py
+++ b/src/calibre/gui2/dialogs/metadata_single.py
@@ -434,9 +434,9 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.pubdate.setDate(QDate(pubdate.year, pubdate.month,
pubdate.day))
timestamp = db.timestamp(self.id, index_is_id=True)
- self.orig_timestamp = timestamp
self.date.setDate(QDate(timestamp.year, timestamp.month,
timestamp.day))
+ self.orig_date = qt_to_dt(self.date.date())
exts = self.db.formats(row)
if exts:
@@ -729,10 +729,13 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.series.setText(book.series)
if book.series_index is not None:
self.series_index.setValue(book.series_index)
+ # Needed because of Qt focus bug on OS X
+ self.fetch_cover_button.setFocus(Qt.OtherFocusReason)
else:
error_dialog(self, _('Cannot fetch metadata'),
_('You must specify at least one of ISBN, Title, '
'Authors or Publisher'), show=True)
+ self.title.setFocus(Qt.OtherFocusReason)
def enable_series_index(self, *args):
self.series_index.setEnabled(True)
@@ -802,7 +805,7 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog):
self.db.set_pubdate(self.id, d, notify=False, commit=False)
d = self.date.date()
d = qt_to_dt(d)
- if d.date() != self.orig_timestamp.date():
+ if d != self.orig_date:
self.db.set_timestamp(self.id, d, notify=False, commit=False)
self.db.commit()
diff --git a/src/calibre/gui2/dialogs/scheduler.py b/src/calibre/gui2/dialogs/scheduler.py
index 30f4a2d8a2..071c5778a8 100644
--- a/src/calibre/gui2/dialogs/scheduler.py
+++ b/src/calibre/gui2/dialogs/scheduler.py
@@ -120,12 +120,15 @@ class SchedulerDialog(QDialog, Ui_Dialog):
if self.account.isVisible():
un, pw = map(unicode, (self.username.text(), self.password.text()))
+ un, pw = un.strip(), pw.strip()
if not un and not pw and self.schedule.isChecked():
- error_dialog(self, _('Need username and password'),
- _('You must provide a username and/or password to '
- 'use this news source.'), show=True)
- return False
- self.recipe_model.set_account_info(urn, un.strip(), pw.strip())
+ if not getattr(self, 'subscription_optional', False):
+ error_dialog(self, _('Need username and password'),
+ _('You must provide a username and/or password to '
+ 'use this news source.'), show=True)
+ return False
+ if un or pw:
+ self.recipe_model.set_account_info(urn, un, pw)
if self.schedule.isChecked():
schedule_type = 'interval' if self.interval_button.isChecked() else 'day/time'
@@ -157,7 +160,13 @@ class SchedulerDialog(QDialog, Ui_Dialog):
account_info = self.recipe_model.account_info_from_urn(urn)
customize_info = self.recipe_model.get_customize_info(urn)
- self.account.setVisible(recipe.get('needs_subscription', '') == 'yes')
+ ns = recipe.get('needs_subscription', '')
+ self.account.setVisible(ns in ('yes', 'optional'))
+ self.subscription_optional = ns == 'optional'
+ act = _('Account')
+ act2 = _('(optional)') if self.subscription_optional else \
+ _('(required)')
+ self.account.setTitle(act+' '+act2)
un = pw = ''
if account_info is not None:
un, pw = account_info[:2]
diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py
index 57ea04fb75..8f86bf43b8 100644
--- a/src/calibre/gui2/library/views.py
+++ b/src/calibre/gui2/library/views.py
@@ -30,6 +30,7 @@ class BooksView(QTableView): # {{{
def __init__(self, parent, modelcls=BooksModel):
QTableView.__init__(self, parent)
+ self.drag_allowed = True
self.setDragEnabled(True)
self.setDragDropOverwriteMode(False)
self.setDragDropMode(self.DragDrop)
@@ -505,6 +506,8 @@ class BooksView(QTableView): # {{{
return QTableView.mousePressEvent(self, event)
def mouseMoveEvent(self, event):
+ if not self.drag_allowed:
+ return
if self.drag_start_pos is None:
return QTableView.mouseMoveEvent(self, event)
@@ -613,7 +616,7 @@ class BooksView(QTableView): # {{{
def close(self):
self._model.close()
- def set_editable(self, editable):
+ def set_editable(self, editable, supports_backloading):
self._model.set_editable(editable)
def connect_to_search_box(self, sb, search_done):
@@ -700,5 +703,9 @@ class DeviceBooksView(BooksView): # {{{
error_dialog(self, _('Not allowed'),
_('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_()
+ def set_editable(self, editable, supports_backloading):
+ self._model.set_editable(editable)
+ self.drag_allowed = supports_backloading
+
# }}}
diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py
index d8f59b8db7..6e510cbfa5 100644
--- a/src/calibre/gui2/search_box.py
+++ b/src/calibre/gui2/search_box.py
@@ -73,6 +73,8 @@ class SearchBox2(QComboBox):
self.normal_background = 'rgb(255, 255, 255, 0%)'
self.line_edit = SearchLineEdit(self)
self.setLineEdit(self.line_edit)
+ c = self.line_edit.completer()
+ c.setCompletionMode(c.PopupCompletion)
self.line_edit.key_pressed.connect(self.key_pressed,
type=Qt.DirectConnection)
self.line_edit.mouse_released.connect(self.mouse_released,
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 88a9220024..6e0aef1b99 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -17,7 +17,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \
from calibre.ebooks.metadata import title_sort
from calibre.gui2 import config, NONE
-from calibre.library.field_metadata import TagsIcons
+from calibre.library.field_metadata import TagsIcons, category_icon_map
from calibre.utils.search_query_parser import saved_searches
from calibre.gui2 import error_dialog
from calibre.gui2.dialogs.confirm_delete import confirm
@@ -84,12 +84,14 @@ class TagsView(QTreeView): # {{{
self.setAcceptDrops(True)
self.setDragDropMode(self.DropOnly)
self.setDropIndicatorShown(True)
+ self.setAutoExpandDelay(500)
def set_database(self, db, tag_match, sort_by):
self.hidden_categories = config['tag_browser_hidden_categories']
self._model = TagsModel(db, parent=self,
hidden_categories=self.hidden_categories,
- search_restriction=None)
+ search_restriction=None,
+ drag_drop_finished=self.drag_drop_finished)
self.sort_by = sort_by
self.tag_match = tag_match
self.db = db
@@ -109,103 +111,6 @@ class TagsView(QTreeView): # {{{
def database_changed(self, event, ids):
self.refresh_required.emit()
- def dragEnterEvent(self, event):
- md = event.mimeData()
- if md.hasFormat("application/calibre+from_library"):
- event.setDropAction(Qt.CopyAction)
- event.accept()
- else:
- event.ignore()
-
- def dragMoveEvent(self, event):
- allowed = False
- idx = self.indexAt(event.pos())
- m = self.model()
- p = m.parent(idx)
- if idx.isValid() and p.isValid():
- item = m.data(p, Qt.UserRole)
- fm = self.db.metadata_for_field(item.category_key)
- if item.category_key in \
- ('tags', 'series', 'authors', 'rating', 'publisher') or\
- (fm['is_custom'] and \
- fm['datatype'] in ['text', 'rating', 'series']):
- allowed = True
- if allowed:
- event.acceptProposedAction()
- else:
- event.ignore()
-
- def dropEvent(self, event):
- idx = self.indexAt(event.pos())
- m = self.model()
- p = m.parent(idx)
- if idx.isValid() and p.isValid():
- item = m.data(p, Qt.UserRole)
- if item.type == TagTreeItem.CATEGORY:
- fm = self.db.metadata_for_field(item.category_key)
- if item.category_key in \
- ('tags', 'series', 'authors', 'rating', 'publisher') or\
- (fm['is_custom'] and \
- fm['datatype'] in ['text', 'rating', 'series']):
- child = m.data(idx, Qt.UserRole)
- md = event.mimeData()
- mime = 'application/calibre+from_library'
- ids = list(map(int, str(md.data(mime)).split()))
- self.handle_drop(item, child, ids)
- event.accept()
- return
- event.ignore()
-
- def handle_drop(self, parent, child, ids):
- # print 'Dropped ids:', ids, parent.category_key, child.tag.name
- key = parent.category_key
- if (key == 'authors' and len(ids) >= 5):
- if not confirm(''+_('Changing the authors for several books can '
- 'take a while. Are you sure?')
- +'
', 'tag_browser_drop_authors', self):
- return
- elif len(ids) > 15:
- if not confirm(''+_('Changing the metadata for that many books '
- 'can take a while. Are you sure?')
- +'
', 'tag_browser_many_changes', self):
- return
-
- fm = self.db.metadata_for_field(key)
- is_multiple = fm['is_multiple']
- val = child.tag.name
- for id in ids:
- mi = self.db.get_metadata(id, index_is_id=True)
-
- # Prepare to ignore the author, unless it is changed. Title is
- # always ignored -- see the call to set_metadata
- set_authors = False
-
- # Author_sort cannot change explicitly. Changing the author might
- # change it.
- mi.author_sort = None # Never will change by itself.
-
- if key == 'authors':
- mi.authors = [val]
- set_authors=True
- elif fm['datatype'] == 'rating':
- mi.set(key, len(val) * 2)
- elif fm['is_custom'] and fm['datatype'] == 'series':
- mi.set(key, val, extra=1.0)
- elif is_multiple:
- new_val = mi.get(key, [])
- if val in new_val:
- # Fortunately, only one field can change, so the continue
- # won't break anything
- continue
- new_val.append(val)
- mi.set(key, new_val)
- else:
- mi.set(key, val)
- self.db.set_metadata(id, mi, set_title=False,
- set_authors=set_authors, commit=False)
- self.db.commit()
- self.drag_drop_finished.emit(ids)
-
@property
def match_all(self):
return self.tag_match and self.tag_match.currentIndex() > 0
@@ -374,7 +279,8 @@ class TagsView(QTreeView): # {{{
try:
self._model = TagsModel(self.db, parent=self,
hidden_categories=self.hidden_categories,
- search_restriction=self.search_restriction)
+ search_restriction=self.search_restriction,
+ drag_drop_finished=self.drag_drop_finished)
self.setModel(self._model)
except:
# The DB must be gone. Set the model to None and hope that someone
@@ -469,24 +375,20 @@ class TagTreeItem(object): # {{{
class TagsModel(QAbstractItemModel): # {{{
- def __init__(self, db, parent, hidden_categories=None, search_restriction=None):
+ def __init__(self, db, parent, hidden_categories=None,
+ search_restriction=None, drag_drop_finished=None):
QAbstractItemModel.__init__(self, parent)
# must do this here because 'QPixmap: Must construct a QApplication
# before a QPaintDevice'. The ':' in front avoids polluting either the
# user-defined categories (':' at end) or columns namespaces (no ':').
- self.category_icon_map = TagsIcons({
- 'authors' : QIcon(I('user_profile.png')),
- 'series' : QIcon(I('series.png')),
- 'formats' : QIcon(I('book.png')),
- 'publisher' : QIcon(I('publisher.png')),
- 'rating' : QIcon(I('rating.png')),
- 'news' : QIcon(I('news.png')),
- 'tags' : QIcon(I('tags.png')),
- ':custom' : QIcon(I('column.png')),
- ':user' : QIcon(I('drawer.png')),
- 'search' : QIcon(I('search.png'))})
+ iconmap = {}
+ for key in category_icon_map:
+ iconmap[key] = QIcon(I(category_icon_map[key]))
+ self.category_icon_map = TagsIcons(iconmap)
+
self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags']
+ self.drag_drop_finished = drag_drop_finished
self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('minus.png'))]
self.db = db
@@ -519,6 +421,79 @@ class TagsModel(QAbstractItemModel): # {{{
tag.avg_rating = None
TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map)
+ def mimeTypes(self):
+ return ["application/calibre+from_library"]
+
+ def dropMimeData(self, md, action, row, column, parent):
+ if not md.hasFormat("application/calibre+from_library") or \
+ action != Qt.CopyAction:
+ return False
+ idx = parent
+ if idx.isValid():
+ node = self.data(idx, Qt.UserRole)
+ if node.type == TagTreeItem.TAG:
+ fm = self.db.metadata_for_field(node.tag.category)
+ if node.tag.category in \
+ ('tags', 'series', 'authors', 'rating', 'publisher') or \
+ (fm['is_custom'] and \
+ fm['datatype'] in ['text', 'rating', 'series']):
+ mime = 'application/calibre+from_library'
+ ids = list(map(int, str(md.data(mime)).split()))
+ self.handle_drop(node, ids)
+ return True
+ return False
+
+
+ def handle_drop(self, on_node, ids):
+ #print 'Dropped ids:', ids, on_node.tag
+ key = on_node.tag.category
+ if (key == 'authors' and len(ids) >= 5):
+ if not confirm(''+_('Changing the authors for several books can '
+ 'take a while. Are you sure?')
+ +'
', 'tag_browser_drop_authors', self.parent()):
+ return
+ elif len(ids) > 15:
+ if not confirm(''+_('Changing the metadata for that many books '
+ 'can take a while. Are you sure?')
+ +'
', 'tag_browser_many_changes', self.parent()):
+ return
+
+ fm = self.db.metadata_for_field(key)
+ is_multiple = fm['is_multiple']
+ val = on_node.tag.name
+ for id in ids:
+ mi = self.db.get_metadata(id, index_is_id=True)
+
+ # Prepare to ignore the author, unless it is changed. Title is
+ # always ignored -- see the call to set_metadata
+ set_authors = False
+
+ # Author_sort cannot change explicitly. Changing the author might
+ # change it.
+ mi.author_sort = None # Never will change by itself.
+
+ if key == 'authors':
+ mi.authors = [val]
+ set_authors=True
+ elif fm['datatype'] == 'rating':
+ mi.set(key, len(val) * 2)
+ elif fm['is_custom'] and fm['datatype'] == 'series':
+ mi.set(key, val, extra=1.0)
+ elif is_multiple:
+ new_val = mi.get(key, [])
+ if val in new_val:
+ # Fortunately, only one field can change, so the continue
+ # won't break anything
+ continue
+ new_val.append(val)
+ mi.set(key, new_val)
+ else:
+ mi.set(key, val)
+ self.db.set_metadata(id, mi, set_title=False,
+ set_authors=set_authors, commit=False)
+ self.db.commit()
+ self.drag_drop_finished.emit(ids)
+
def set_search_restriction(self, s):
self.search_restriction = s
@@ -650,12 +625,19 @@ class TagsModel(QAbstractItemModel): # {{{
def flags(self, index, *args):
ans = Qt.ItemIsEnabled|Qt.ItemIsSelectable|Qt.ItemIsEditable
- if index.isValid() and self.parent(index).isValid():
- ans |= Qt.ItemIsDropEnabled
+ if index.isValid():
+ node = self.data(index, Qt.UserRole)
+ if node.type == TagTreeItem.TAG:
+ fm = self.db.metadata_for_field(node.tag.category)
+ if node.tag.category in \
+ ('tags', 'series', 'authors', 'rating', 'publisher') or \
+ (fm['is_custom'] and \
+ fm['datatype'] in ['text', 'rating', 'series']):
+ ans |= Qt.ItemIsDropEnabled
return ans
def supportedDropActions(self):
- return Qt.CopyAction|Qt.MoveAction
+ return Qt.CopyAction
def path_for_index(self, index):
ans = []
diff --git a/src/calibre/gui2/viewer/main.py b/src/calibre/gui2/viewer/main.py
index 26fd2cadc9..f0f29a67e6 100644
--- a/src/calibre/gui2/viewer/main.py
+++ b/src/calibre/gui2/viewer/main.py
@@ -695,6 +695,9 @@ def config(defaults=None):
c.add_opt('raise_window', ['--raise-window'], default=False,
help=_('If specified, viewer window will try to come to the '
'front when started.'))
+ c.add_opt('full_screen', ['--full-screen', '--fullscreen', '-f'], default=False,
+ help=_('If specified, viewer window will try to open '
+ 'full screen when started.'))
c.add_opt('remember_window_size', default=False,
help=_('Remember last used window size'))
c.add_opt('debug_javascript', ['--debug-javascript'], default=False,
@@ -726,8 +729,10 @@ def main(args=sys.argv):
main.show()
if opts.raise_window:
main.raise_()
- with main:
- return app.exec_()
+ if opts.full_screen:
+ main.action_full_screen.trigger()
+ with main:
+ return app.exec_()
return 0
if __name__ == '__main__':
diff --git a/src/calibre/library/comments.py b/src/calibre/library/comments.py
index 32ae65b31e..670d9f2564 100644
--- a/src/calibre/library/comments.py
+++ b/src/calibre/library/comments.py
@@ -42,6 +42,8 @@ def comments_to_html(comments):
Deprecated HTML returns as HTML via BeautifulSoup()
'''
+ if not comments:
+ return u'
'
if not isinstance(comments, unicode):
comments = comments.decode(preferred_encoding, 'replace')
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index 5bec43ab28..bbfef47977 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -10,6 +10,7 @@ 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
@@ -68,7 +69,7 @@ copyfile = os.link if hasattr(os, 'link') else shutil.copyfile
class Tag(object):
def __init__(self, name, id=None, count=0, state=0, avg=0, sort=None,
- tooltip=None, icon=None):
+ tooltip=None, icon=None, category=None):
self.name = name
self.id = id
self.count = count
@@ -81,9 +82,11 @@ class Tag(object):
tooltip = _('%sAverage rating is %3.1f')%(tooltip, self.avg_rating)
self.tooltip = tooltip
self.icon = icon
+ self.category = category
def __unicode__(self):
- return u'%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state, self.tooltip)
+ return u'%s:%s:%s:%s:%s:%s'%(self.name, self.count, self.id, self.state,
+ self.category, self.tooltip)
def __str__(self):
return unicode(self).encode('utf-8')
@@ -1102,21 +1105,22 @@ 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
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)
elif category == 'authors':
- item_not_zero_func = (lambda x: x[2] > 0)
# Clean up the authors strings to human-readable form
formatter = (lambda x: x.replace('|', ','))
else:
- item_not_zero_func = (lambda x: x[2] > 0)
formatter = (lambda x:unicode(x))
categories[category] = [Tag(formatter(r[1]), count=r[2], id=r[0],
- avg=r[3], sort=r[4],
- icon=icon, tooltip=tooltip)
+ avg=avgr(r), sort=r[4], icon=icon,
+ tooltip=tooltip, category=category)
for r in data if item_not_zero_func(r)]
# Needed for legacy databases that have multiple ratings that
@@ -1148,7 +1152,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
WHERE format="%s"'''%fmt,
all=False)
if count > 0:
- categories['formats'].append(Tag(fmt, count=count, icon=icon))
+ categories['formats'].append(Tag(fmt, count=count, icon=icon,
+ category='formats'))
if sort == 'popularity':
categories['formats'].sort(key=lambda x: x.count, reverse=True)
@@ -1194,7 +1199,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
if icon_map and 'search' in icon_map:
icon = icon_map['search']
for srch in saved_searches().names():
- items.append(Tag(srch, tooltip=saved_searches().lookup(srch), icon=icon))
+ items.append(Tag(srch, tooltip=saved_searches().lookup(srch),
+ icon=icon, category='search'))
if len(items):
if icon_map is not None:
icon_map['search'] = icon_map['search']
diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py
index b43a6620d0..dbc871026e 100644
--- a/src/calibre/library/field_metadata.py
+++ b/src/calibre/library/field_metadata.py
@@ -22,6 +22,20 @@ class TagsIcons(dict):
raise ValueError('Missing category icon [%s]'%a)
self[a] = icon_dict[a]
+category_icon_map = {
+ 'authors' : 'user_profile.png',
+ 'series' : 'series.png',
+ 'formats' : 'book.png',
+ 'publisher' : 'publisher.png',
+ 'rating' : 'rating.png',
+ 'news' : 'news.png',
+ 'tags' : 'tags.png',
+ ':custom' : 'column.png',
+ ':user' : 'drawer.png',
+ 'search' : 'search.png'
+ }
+
+
class FieldMetadata(dict):
'''
key: the key to the dictionary is:
@@ -161,7 +175,7 @@ class FieldMetadata(dict):
'datatype':'text',
'is_multiple':None,
'kind':'field',
- 'name':None,
+ 'name':_('Comments'),
'search_terms':['comments', 'comment'],
'is_custom':False, 'is_category':False}),
('cover', {'table':None,
diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py
index 42d3d76dfb..247e6945e6 100644
--- a/src/calibre/library/server/browse.py
+++ b/src/calibre/library/server/browse.py
@@ -5,12 +5,177 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import operator, os
+import operator, os, json
+from binascii import hexlify, unhexlify
import cherrypy
from calibre.constants import filesystem_encoding
-from calibre import isbytestring, force_unicode, prepare_string_for_xml as xml
+from calibre import isbytestring, force_unicode, fit_image, \
+ prepare_string_for_xml as xml
+from calibre.utils.ordered_dict import OrderedDict
+from calibre.utils.filenames import ascii_filename
+from calibre.utils.config import prefs
+from calibre.utils.magick import Image
+from calibre.library.comments import comments_to_html
+from calibre.library.server import custom_fields_to_display
+from calibre.library.field_metadata import category_icon_map
+
+def render_book_list(ids, suffix=''): # {{{
+ pages = []
+ num = len(ids)
+ pos = 0
+ delta = 25
+ while ids:
+ page = list(ids[:delta])
+ pages.append((page, pos))
+ ids = ids[delta:]
+ pos += len(page)
+ page_template = u'''\
+
+
+
+
+
+
+
{2}
+
+
+ '''
+ rpages = []
+ for i, x in enumerate(pages):
+ pg, pos = x
+ ld = xml(json.dumps(pg), True)
+ rpages.append(page_template.format(i, ld,
+ xml(_('Loading, please wait')) + '…',
+ start=pos+1, end=pos+len(pg)))
+ rpages = u'\n\n'.join(rpages)
+
+ templ = u'''\
+ {0} {suffix}
+
+
+ {navbar}
+
+ {pages}
+
+ {navbar}
+
+
+ '''
+
+ navbar = u'''\
+
+
+ 0 to 0 of {num}
+
+
+ '''.format(first=_('First'), last=_('Last'), previous=_('Previous'),
+ next=_('Next'), num=num)
+
+ return templ.format(_('Browsing %d books')%num, suffix=suffix,
+ pages=rpages, navbar=navbar)
+
+# }}}
+
+def utf8(x): # {{{
+ if isinstance(x, unicode):
+ x = x.encode('utf-8')
+ return x
+# }}}
+
+def render_rating(rating, container='span', prefix=None): # {{{
+ if rating < 0.1:
+ return '', ''
+ added = 0
+ if prefix is None:
+ prefix = _('Average rating')
+ rstring = xml(_('%s: %.1f stars')% (prefix, rating if rating else 0.0),
+ True)
+ ans = ['<%s class="rating">' % (container)]
+ for i in range(5):
+ n = rating - added
+ x = 'half'
+ if n <= 0.1:
+ x = 'off'
+ elif n >= 0.9:
+ x = 'on'
+ ans.append(
+ u' '.format(
+ rstring, x))
+ added += 1
+ ans.append('%s>'%container)
+ return u''.join(ans), rstring
+
+# }}}
+
+def get_category_items(category, items, db, datatype): # {{{
+
+ def item(i):
+ templ = (u''
+ '
{0}
{1}
'
+ '
{2}'
+ '{3}
')
+ rating, rstring = render_rating(i.avg_rating)
+ name = xml(i.name)
+ if datatype == 'rating':
+ name = xml(_('%d stars')%int(i.avg_rating))
+ id_ = i.id
+ if id_ is None:
+ id_ = hexlify(force_unicode(name).encode('utf-8'))
+ id_ = xml(str(id_))
+ desc = ''
+ if i.count > 0:
+ desc += '[' + _('%d books')%i.count + ']'
+ q = i.category
+ if not q:
+ q = category
+ href = '/browse/matches/%s/%s'%(q, id_)
+ return templ.format(xml(name), rating,
+ xml(desc), xml(href), rstring)
+
+ items = list(map(item, items))
+ return '\n'.join([''] + items + ['
'])
+
+# }}}
+
+class Endpoint(object): # {{{
+ 'Manage encoding, mime-type, last modified, cookies, etc.'
+
+ def __init__(self, mimetype='text/html; charset=utf-8', sort_type='category'):
+ self.mimetype = mimetype
+ self.sort_type = sort_type
+ self.sort_kwarg = sort_type + '_sort'
+ self.sort_cookie_name = 'calibre_browse_server_sort_'+self.sort_type
+
+ def __call__(eself, func):
+
+ def do(self, *args, **kwargs):
+ if 'json' not in eself.mimetype:
+ sort_val = None
+ cookie = cherrypy.request.cookie
+ if cookie.has_key(eself.sort_cookie_name):
+ sort_val = cookie[eself.sort_cookie_name].value
+ kwargs[eself.sort_kwarg] = sort_val
+
+ ans = func(self, *args, **kwargs)
+ cherrypy.response.headers['Content-Type'] = eself.mimetype
+ updated = self.db.last_modified()
+ cherrypy.response.headers['Last-Modified'] = \
+ self.last_modified(max(updated, self.build_time))
+ ans = utf8(ans)
+ return ans
+
+ do.__name__ = func.__name__
+
+ return do
+# }}}
class BrowseServer(object):
@@ -19,76 +184,511 @@ class BrowseServer(object):
connect('browse', base_href, self.browse_catalog)
connect('browse_catalog', base_href+'/category/{category}',
self.browse_catalog)
- connect('browse_list', base_href+'/list/{query}', self.browse_list)
- connect('browse_search', base_href+'/search/{query}',
+ connect('browse_category_group',
+ base_href+'/category_group/{category}/{group}',
+ self.browse_category_group)
+ connect('browse_matches',
+ base_href+'/matches/{category}/{cid}',
+ self.browse_matches)
+ connect('browse_booklist_page',
+ base_href+'/booklist_page',
+ self.browse_booklist_page)
+ connect('browse_search', base_href+'/search',
self.browse_search)
- connect('browse_book', base_href+'/book/{uuid}', self.browse_book)
- connect('browse_json', base_href+'/json/{query}', self.browse_json)
+ connect('browse_details', base_href+'/details/{id}',
+ self.browse_details)
+ connect('browse_book', base_href+'/book/{id}',
+ self.browse_book)
+ connect('browse_category_icon', base_href+'/icon/{name}',
+ self.browse_icon)
- def browse_template(self, category=True):
- def generate():
- if category:
- sort_opts = [('rating', _('Average rating')), ('name',
- _('Name')), ('popularity', _('Popularity'))]
- else:
- fm = self.db.field_metadata
- sort_opts = [(x, fm[x]['name']) for x in fm.sortable_field_keys()
- if fm[x]['name']]
- prefix = 'category' if category else 'book'
- ans = P('content_server/browse.html', data=True)
- ans = ans.replace('{sort_select_label}', xml(_('Sort by')+':'))
- opts = ['%s ' % (prefix, xml(k),
- xml(n)) for k, n in
- sorted(sort_opts, key=operator.itemgetter(1))]
- opts = [''+xml(_('Select one')) +
- '… '] + opts
- ans = ans.replace('{sort_select_options}', '\n\t\t\t'.join(opts))
- lp = self.db.library_path
- if isbytestring(lp):
- lp = force_unicode(lp, filesystem_encoding)
- if isinstance(ans, unicode):
- ans = ans.encode('utf-8')
- ans = ans.replace('{library_name}', xml(os.path.basename(lp)))
- ans = ans.replace('{library_path}', xml(lp, True))
- return ans
+ # Templates {{{
+ def browse_template(self, sort, category=True, initial_search=''):
+
+ if not hasattr(self, '__browse_template__') or \
+ self.opts.develop:
+ self.__browse_template__ = \
+ P('content_server/browse/browse.html', data=True).decode('utf-8')
+
+ ans = self.__browse_template__
+ scn = 'calibre_browse_server_sort_'
+
+ if category:
+ sort_opts = [('rating', _('Average rating')), ('name',
+ _('Name')), ('popularity', _('Popularity'))]
+ scn += 'category'
+ else:
+ scn += 'list'
+ fm = self.db.field_metadata
+ sort_opts, added = [], set([])
+ displayed_custom_fields = custom_fields_to_display(self.db)
+ for x in fm.sortable_field_keys():
+ if x in ('ondevice', 'formats', 'sort'):
+ continue
+ if fm[x]['is_custom'] and x not in displayed_custom_fields:
+ continue
+ if x == 'comments' or fm[x]['datatype'] == 'comments':
+ continue
+ n = fm[x]['name']
+ if n not in added:
+ added.add(n)
+ sort_opts.append((x, n))
+
+ ans = ans.replace('{sort_select_label}', xml(_('Sort by')+':'))
+ ans = ans.replace('{sort_cookie_name}', scn)
+ opts = ['%s ' % (
+ 'selected="selected" ' if k==sort else '',
+ xml(k), xml(n), ) for k, n in
+ sorted(sort_opts, key=operator.itemgetter(1)) if k and n]
+ ans = ans.replace('{sort_select_options}', ('\n'+' '*20).join(opts))
+ lp = self.db.library_path
+ if isbytestring(lp):
+ lp = force_unicode(lp, filesystem_encoding)
+ if isinstance(ans, unicode):
+ ans = ans.encode('utf-8')
+ ans = ans.replace('{library_name}', xml(os.path.basename(lp)))
+ ans = ans.replace('{library_path}', xml(lp, True))
+ ans = ans.replace('{initial_search}', initial_search)
+ return ans
- if self.opts.develop:
- return generate()
- if not hasattr(self, '__browse_template__'):
- self.__browse_template__ = generate()
return self.__browse_template__
+ @property
+ def browse_summary_template(self):
+ if not hasattr(self, '__browse_summary_template__') or \
+ self.opts.develop:
+ self.__browse_summary_template__ = \
+ P('content_server/browse/summary.html', data=True).decode('utf-8')
+ return self.__browse_summary_template__
+
+ @property
+ def browse_details_template(self):
+ if not hasattr(self, '__browse_details_template__') or \
+ self.opts.develop:
+ self.__browse_details_template__ = \
+ P('content_server/browse/details.html', data=True).decode('utf-8')
+ return self.__browse_details_template__
+
+ # }}}
# Catalogs {{{
- def browse_catalog(self, category=None):
- if category == None:
- ans = self.browse_template().format(title='',
- script='toplevel();')
- else:
- raise cherrypy.HTTPError(404, 'Not found')
- cherrypy.response.headers['Content-Type'] = 'text/html'
+ def browse_icon(self, name='blank.png'):
+ cherrypy.response.headers['Content-Type'] = 'image/png'
cherrypy.response.headers['Last-Modified'] = self.last_modified(self.build_time)
+
+ if not hasattr(self, '__browse_icon_cache__'):
+ self.__browse_icon_cache__ = {}
+ if name not in self.__browse_icon_cache__:
+ try:
+ data = I(name, data=True)
+ except:
+ raise cherrypy.HTTPError(404, 'no icon named: %r'%name)
+ img = Image()
+ img.load(data)
+ width, height = img.size
+ scaled, width, height = fit_image(width, height, 48, 48)
+ if scaled:
+ img.size = (width, height)
+
+ self.__browse_icon_cache__[name] = img.export('png')
+ return self.__browse_icon_cache__[name]
+
+ def browse_toplevel(self):
+ categories = self.categories_cache()
+ category_meta = self.db.field_metadata
+ cats = [
+ (_('Newest'), 'newest', 'forward.png'),
+ ]
+
+ def getter(x):
+ return category_meta[x]['name'].lower()
+
+ displayed_custom_fields = custom_fields_to_display(self.db)
+ for category in sorted(categories,
+ cmp=lambda x,y: cmp(getter(x), getter(y))):
+ if len(categories[category]) == 0:
+ continue
+ if category == 'formats':
+ continue
+ meta = category_meta.get(category, None)
+ if meta is None:
+ continue
+ if meta['is_custom'] and category not in displayed_custom_fields:
+ continue
+ # get the icon files
+ if category in category_icon_map:
+ icon = category_icon_map[category]
+ elif meta['is_custom']:
+ icon = category_icon_map[':custom']
+ elif meta['kind'] == 'user':
+ icon = category_icon_map[':user']
+ else:
+ icon = 'blank.png'
+ cats.append((meta['name'], category, icon))
+
+ cats = [(' '
+ '{0} '
+ '/browse/category/{1} ')
+ .format(xml(x, True), xml(y), xml(_('Browse books by')),
+ src='/browse/icon/'+z)
+ for x, y, z in cats]
+
+ main = ''\
+ .format(_('Choose a category to browse by:'), '\n\n'.join(cats))
+ return self.browse_template('name').format(title='',
+ script='toplevel();', main=main)
+
+ def browse_sort_categories(self, items, sort):
+ if sort not in ('rating', 'name', 'popularity'):
+ sort = 'name'
+ def sorter(x):
+ ans = getattr(x, 'sort', x.name)
+ if hasattr(ans, 'upper'):
+ ans = ans.upper()
+ return ans
+ items.sort(key=sorter)
+ if sort == 'popularity':
+ items.sort(key=operator.attrgetter('count'), reverse=True)
+ elif sort == 'rating':
+ items.sort(key=operator.attrgetter('avg_rating'), reverse=True)
+ return sort
+
+ def browse_category(self, category, sort):
+ categories = self.categories_cache()
+ if category not in categories:
+ raise cherrypy.HTTPError(404, 'category not found')
+ category_meta = self.db.field_metadata
+ category_name = category_meta[category]['name']
+ datatype = category_meta[category]['datatype']
+
+
+ items = categories[category]
+ sort = self.browse_sort_categories(items, sort)
+
+ script = 'true'
+
+ if len(items) <= self.opts.max_opds_ungrouped_items:
+ script = 'false'
+ items = get_category_items(category, items, self.db, datatype)
+ else:
+ getter = lambda x: unicode(getattr(x, 'sort', x.name))
+ starts = set([])
+ for x in items:
+ val = getter(x)
+ if not val:
+ val = u'A'
+ starts.add(val[0].upper())
+ category_groups = OrderedDict()
+ for x in sorted(starts):
+ category_groups[x] = len([y for y in items if
+ getter(y).upper().startswith(x)])
+ items = [(u'{0} [{2}] '
+ u'
'
+ u'
{1} '
+ u'
{3} ').format(
+ xml(s, True),
+ xml(_('Loading, please wait'))+'…',
+ unicode(c),
+ xml(u'/browse/category_group/%s/%s'%(category, s)))
+ for s, c in category_groups.items()]
+ items = '\n\n'.join(items)
+ items = u'\n{0}
'.format(items)
+
+
+
+ script = 'category(%s);'%script
+
+ main = u'''
+
+ '''.format(
+ xml(_('Browsing by')+': ' + category_name), items,
+ xml(_('Up'), True))
+
+ return self.browse_template(sort).format(title=category_name,
+ script=script, main=main)
+
+ @Endpoint(mimetype='application/json; charset=utf-8')
+ def browse_category_group(self, category=None, group=None, sort=None):
+ if sort == 'null':
+ sort = None
+ if sort not in ('rating', 'name', 'popularity'):
+ sort = 'name'
+ categories = self.categories_cache()
+ if category not in categories:
+ raise cherrypy.HTTPError(404, 'category not found')
+
+ category_meta = self.db.field_metadata
+ datatype = category_meta[category]['datatype']
+
+ if not group:
+ raise cherrypy.HTTPError(404, 'invalid group')
+
+ items = categories[category]
+ entries = []
+ getter = lambda x: unicode(getattr(x, 'sort', x.name))
+ for x in items:
+ val = getter(x)
+ if not val:
+ val = u'A'
+ if val.upper().startswith(group):
+ entries.append(x)
+
+ sort = self.browse_sort_categories(entries, sort)
+ entries = get_category_items(category, entries, self.db, datatype)
+ return json.dumps(entries, ensure_ascii=False)
+
+
+ @Endpoint()
+ def browse_catalog(self, category=None, category_sort=None):
+ 'Entry point for top-level, categories and sub-categories'
+ if category == None:
+ ans = self.browse_toplevel()
+ elif category == 'newest':
+ raise cherrypy.InternalRedirect('/browse/matches/newest/dummy')
+ else:
+ ans = self.browse_category(category, category_sort)
+
return ans
# }}}
# Book Lists {{{
- def browse_list(self, query=None):
- raise NotImplementedError()
+
+ def browse_sort_book_list(self, items, sort):
+ fm = self.db.field_metadata
+ keys = frozenset(fm.sortable_field_keys())
+ if sort not in keys:
+ sort = 'title'
+ self.sort(items, 'title', True)
+ if sort != 'title':
+ ascending = fm[sort]['datatype'] not in ('rating', 'datetime',
+ 'series')
+ self.sort(items, sort, ascending)
+ return sort
+
+ @Endpoint(sort_type='list')
+ def browse_matches(self, category=None, cid=None, list_sort=None):
+ if not cid:
+ raise cherrypy.HTTPError(404, 'invalid category id: %r'%cid)
+ categories = self.categories_cache()
+
+ if category not in categories and category != 'newest':
+ raise cherrypy.HTTPError(404, 'category not found')
+ fm = self.db.field_metadata
+ try:
+ category_name = fm[category]['name']
+ dt = fm[category]['datatype']
+ except:
+ if category != 'newest':
+ raise
+ category_name = _('Newest')
+ dt = None
+
+ hide_sort = 'true' if dt == 'series' else 'false'
+ if category == 'search':
+ which = unhexlify(cid)
+ try:
+ ids = self.search_cache('search:"%s"'%which)
+ except:
+ raise cherrypy.HTTPError(404, 'Search: %r not understood'%which)
+ elif category == 'newest':
+ ids = list(self.db.data.iterallids())
+ hide_sort = 'true'
+ else:
+ q = category
+ if q == 'news':
+ q = 'tags'
+ ids = self.db.get_books_for_category(q, cid)
+
+ items = [self.db.data._data[x] for x in ids]
+ if category == 'newest':
+ list_sort = 'timestamp'
+ if dt == 'series':
+ list_sort = category
+ sort = self.browse_sort_book_list(items, list_sort)
+ ids = [x[0] for x in items]
+ html = render_book_list(ids, suffix=_('in') + ' ' + category_name)
+
+ return self.browse_template(sort, category=False).format(
+ title=_('Books in') + " " +category_name,
+ script='booklist(%s);'%hide_sort, main=html)
+
+ def browse_get_book_args(self, mi, id_):
+ fmts = self.db.formats(id_, index_is_id=True)
+ if not fmts:
+ fmts = ''
+ fmts = [x.lower() for x in fmts.split(',') if x]
+ pf = prefs['output_format'].lower()
+ try:
+ fmt = pf if pf in fmts else fmts[0]
+ except:
+ fmt = None
+ args = {'id':id_, 'mi':mi,
+ }
+ for key in mi.all_field_keys():
+ val = mi.format_field(key)[1]
+ if not val:
+ val = ''
+ args[key] = xml(val, True)
+ fname = ascii_filename(args['title']) + ' - ' + ascii_filename(args['authors'])
+ return args, fmt, fmts, fname
+
+ @Endpoint(mimetype='application/json; charset=utf-8')
+ def browse_booklist_page(self, ids=None, sort=None):
+ if sort == 'null':
+ sort = None
+ if ids is None:
+ ids = json.dumps('[]')
+ try:
+ ids = json.loads(ids)
+ except:
+ raise cherrypy.HTTPError(404, 'invalid ids')
+ summs = []
+ for id_ in ids:
+ try:
+ id_ = int(id_)
+ mi = self.db.get_metadata(id_, index_is_id=True)
+ except:
+ continue
+ args, fmt, fmts, fname = self.browse_get_book_args(mi, id_)
+ args['other_formats'] = ''
+ if fmts and fmt:
+ other_fmts = [x for x in fmts if x.lower() != fmt.lower()]
+ if other_fmts:
+ ofmts = [u'{3} '\
+ .format(f, fname, id_, f.upper()) for f in
+ other_fmts]
+ ofmts = ', '.join(ofmts)
+ args['other_formats'] = u'%s: ' % \
+ _('Other formats') + ofmts
+
+ args['details_href'] = '/browse/details/'+str(id_)
+
+ if fmt:
+ href = '/get/%s/%s_%d.%s'%(
+ fmt, fname, id_, fmt)
+ rt = xml(_('Read %s in the %s format')%(args['title'],
+ fmt.upper()), True)
+
+ args['get_button'] = \
+ '%s ' % \
+ (xml(href, True), rt, xml(_('Get')))
+ else:
+ args['get_button'] = ''
+ args['comments'] = comments_to_html(mi.comments)
+ args['stars'] = ''
+ if mi.rating:
+ args['stars'] = render_rating(mi.rating/2.0, prefix=_('Rating'))[0]
+ if args['tags']:
+ args['tags'] = u'%s: '%xml(_('Tags')) + \
+ args['tags']
+ if args['series']:
+ args['series'] = args['series']
+ args['details'] = xml(_('Details'), True)
+ args['details_tt'] = xml(_('Show book details'), True)
+ args['permalink'] = xml(_('Permalink'), True)
+ args['permalink_tt'] = xml(_('A permanent link to this book'), True)
+
+ summs.append(self.browse_summary_template.format(**args))
+
+
+ return json.dumps('\n'.join(summs), ensure_ascii=False)
+
+ def browse_render_details(self, id_):
+ try:
+ mi = self.db.get_metadata(id_, index_is_id=True)
+ except:
+ return _('This book has been deleted')
+ else:
+ args, fmt, fmts, fname = self.browse_get_book_args(mi, id_)
+ args['formats'] = ''
+ if fmts:
+ ofmts = [u'{3} '\
+ .format(fmt, fname, id_, fmt.upper()) for fmt in
+ fmts]
+ ofmts = ', '.join(ofmts)
+ args['formats'] = ofmts
+ fields, comments = [], []
+ displayed_custom_fields = custom_fields_to_display(self.db)
+ for field, m in list(mi.get_all_standard_metadata(False).items()) + \
+ list(mi.get_all_user_metadata(False).items()):
+ if m['is_custom'] and field not in displayed_custom_fields:
+ continue
+ if m['datatype'] == 'comments' or field == 'comments':
+ comments.append((m['name'], comments_to_html(mi.get(field,
+ ''))))
+ continue
+ if field in ('title', 'formats') or not args.get(field, False) \
+ or not m['name']:
+ continue
+ if m['datatype'] == 'rating':
+ r = u'%s: '%xml(m['name']) + \
+ render_rating(mi.rating/2.0, prefix=m['name'])[0]
+ else:
+ r = u'%s: '%xml(m['name']) + \
+ args[field]
+ fields.append((m['name'], r))
+
+ fields.sort(key=lambda x: x[0].lower())
+ fields = [u'{0}
'.format(f[1]) for f in
+ fields]
+ fields = u'%s
'%('\n\n'.join(fields))
+
+ comments.sort(key=lambda x: x[0].lower())
+ comments = [(u'%s: '
+ u'
') % (xml(c[0]),
+ c[1]) for c in comments]
+ comments = u''%('\n\n'.join(comments))
+
+ return self.browse_details_template.format(id=id_,
+ title=xml(mi.title, True), fields=fields,
+ formats=args['formats'], comments=comments)
+
+ @Endpoint(mimetype='application/json; charset=utf-8')
+ def browse_details(self, id=None):
+ try:
+ id_ = int(id)
+ except:
+ raise cherrypy.HTTPError(404, 'invalid id: %r'%id)
+
+ ans = self.browse_render_details(id_)
+
+ return json.dumps(ans, ensure_ascii=False)
+
+
+ @Endpoint()
+ def browse_book(self, id=None, category_sort=None):
+ try:
+ id_ = int(id)
+ except:
+ raise cherrypy.HTTPError(404, 'invalid id: %r'%id)
+
+ ans = self.browse_render_details(id_)
+ return self.browse_template('').format(
+ title='', script='book();', main=ans)
+
+
# }}}
# Search {{{
- def browse_search(self, query=None):
- raise NotImplementedError()
+ @Endpoint(sort_type='list')
+ def browse_search(self, query='', list_sort=None):
+ if isbytestring(query):
+ query = query.decode('UTF-8')
+ ids = self.db.search_getting_ids(query.strip(), self.search_restriction)
+ items = [self.db.data._data[x] for x in ids]
+ sort = self.browse_sort_book_list(items, list_sort)
+ ids = [x[0] for x in items]
+ html = render_book_list(ids, suffix=_('in search')+': '+query)
+ return self.browse_template(sort, category=False, initial_search=query).format(
+ title=_('Matching books'),
+ script='booklist();', main=html)
+
# }}}
- # Book {{{
- def browse_book(self, uuid=None):
- raise NotImplementedError()
- # }}}
-
- # JSON {{{
- def browse_json(self, query=None):
- raise NotImplementedError()
- # }}}
diff --git a/src/calibre/library/server/cache.py b/src/calibre/library/server/cache.py
index 94e4a1c041..29602a114c 100644
--- a/src/calibre/library/server/cache.py
+++ b/src/calibre/library/server/cache.py
@@ -29,6 +29,11 @@ class Cache(object):
def categories_cache(self, restrict_to=frozenset([])):
+ base_restriction = self.search_cache('')
+ if restrict_to:
+ restrict_to = frozenset(restrict_to).intersection(base_restriction)
+ else:
+ restrict_to = base_restriction
old = self._category_cache.pop(frozenset(restrict_to), None)
if old is None or old[0] <= self.db.last_modified():
categories = self.db.get_categories(ids=restrict_to)
diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py
index 7139b12d08..d95cd1818c 100644
--- a/src/calibre/library/server/content.py
+++ b/src/calibre/library/server/content.py
@@ -5,18 +5,15 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal '
__docformat__ = 'restructuredtext en'
-import re, os, cStringIO
+import re, os
import cherrypy
-try:
- from PIL import Image as PILImage
- PILImage
-except ImportError:
- import Image as PILImage
from calibre import fit_image, guess_type
from calibre.utils.date import fromtimestamp
from calibre.library.caches import SortKeyGenerator
+from calibre.utils.magick.draw import save_cover_data_to, Image, \
+ thumbnail as generate_thumbnail
class CSSortKeyGenerator(SortKeyGenerator):
@@ -35,6 +32,7 @@ class ContentServer(object):
def add_routes(self, connect):
connect('root', '/', self.index)
+ connect('old', '/old', self.old)
connect('get', '/get/{what}/{id}', self.get,
conditions=dict(method=["GET", "HEAD"]))
connect('static', '/static/{name:.*?}', self.static,
@@ -76,8 +74,13 @@ class ContentServer(object):
id = int(match.group())
if not self.db.has_id(id):
raise cherrypy.HTTPError(400, 'id:%d does not exist in database'%id)
- if what == 'thumb':
- return self.get_cover(id, thumbnail=True)
+ if what == 'thumb' or what.startswith('thumb_'):
+ try:
+ width, height = map(int, what.split('_')[1:])
+ except:
+ width, height = 60, 80
+ return self.get_cover(id, thumbnail=True, thumb_width=width,
+ thumb_height=height)
if what == 'cover':
return self.get_cover(id)
return self.get_format(id, what)
@@ -123,38 +126,43 @@ class ContentServer(object):
return self.static('index.html')
+ def old(self, **kwargs):
+ return self.static('index.html')
+
# Actually get content from the database {{{
- def get_cover(self, id, thumbnail=False):
- cover = self.db.cover(id, index_is_id=True, as_file=False)
- if cover is None:
- cover = self.default_cover
- cherrypy.response.headers['Content-Type'] = 'image/jpeg'
- cherrypy.response.timeout = 3600
- path = getattr(cover, 'name', False)
- updated = fromtimestamp(os.stat(path).st_mtime) if path and \
- os.access(path, os.R_OK) else self.build_time
- cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
+ def get_cover(self, id, thumbnail=False, thumb_width=60, thumb_height=80):
try:
- f = cStringIO.StringIO(cover)
- try:
- im = PILImage.open(f)
- except IOError:
- raise cherrypy.HTTPError(404, 'No valid cover found')
- width, height = im.size
+ cherrypy.response.headers['Content-Type'] = 'image/jpeg'
+ cherrypy.response.timeout = 3600
+ cover = self.db.cover(id, index_is_id=True, as_file=True)
+ if cover is None:
+ cover = self.default_cover
+ updated = self.build_time
+ else:
+ with cover as f:
+ updated = fromtimestamp(os.fstat(f.fileno()).st_mtime)
+ cover = f.read()
+ cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
+
+ if thumbnail:
+ return generate_thumbnail(cover,
+ width=thumb_width, height=thumb_height)[-1]
+
+ img = Image()
+ img.load(cover)
+ width, height = img.size
scaled, width, height = fit_image(width, height,
- 60 if thumbnail else self.max_cover_width,
- 80 if thumbnail else self.max_cover_height)
+ thumb_width if thumbnail else self.max_cover_width,
+ thumb_height if thumbnail else self.max_cover_height)
if not scaled:
return cover
- im = im.resize((int(width), int(height)), PILImage.ANTIALIAS)
- of = cStringIO.StringIO()
- im.convert('RGB').save(of, 'JPEG')
- return of.getvalue()
+ return save_cover_data_to(img, 'img.jpg', return_data=True,
+ resize_to=(width, height))
except Exception, err:
import traceback
cherrypy.log.error('Failed to generate cover:')
cherrypy.log.error(traceback.print_exc())
- raise cherrypy.HTTPError(404, 'Failed to generate cover: %s'%err)
+ raise cherrypy.HTTPError(404, 'Failed to generate cover: %r'%err)
def get_format(self, id, format):
format = format.upper()
diff --git a/src/calibre/library/server/mobile.py b/src/calibre/library/server/mobile.py
index 856363d7db..b9ca24a823 100644
--- a/src/calibre/library/server/mobile.py
+++ b/src/calibre/library/server/mobile.py
@@ -17,7 +17,7 @@ from calibre.library.server import custom_fields_to_display
from calibre.library.server.utils import strftime, format_tag_string
from calibre.ebooks.metadata import fmt_sidx
from calibre.constants import __appname__
-from calibre import human_readable
+from calibre import human_readable, isbytestring
from calibre.utils.date import utcfromtimestamp
from calibre.utils.filenames import ascii_filename
@@ -29,6 +29,8 @@ def CLASS(*args, **kwargs): # class is a reserved word in Python
def build_search_box(num, search, sort, order): # {{{
div = DIV(id='search_box')
form = FORM('Show ', method='get', action='mobile')
+ form.set('accept-charset', 'UTF-8')
+
div.append(form)
num_select = SELECT(name='num')
@@ -193,6 +195,8 @@ class MobileServer(object):
raise cherrypy.HTTPError(400, 'num: %s is not an integer'%num)
if not search:
search = ''
+ if isbytestring(search):
+ search = search.decode('UTF-8')
ids = self.db.search_getting_ids(search.strip(), self.search_restriction)
FM = self.db.FIELD_MAP
items = [r for r in iter(self.db) if r[FM['id']] in ids]
diff --git a/src/calibre/library/server/opds.py b/src/calibre/library/server/opds.py
index f1aeb583db..16e7d34cbf 100644
--- a/src/calibre/library/server/opds.py
+++ b/src/calibre/library/server/opds.py
@@ -18,7 +18,7 @@ from calibre.constants import __appname__
from calibre.ebooks.metadata import fmt_sidx
from calibre.library.comments import comments_to_html
from calibre.library.server import custom_fields_to_display
-from calibre.library.server.utils import format_tag_string
+from calibre.library.server.utils import format_tag_string, Offsets
from calibre import guess_type
from calibre.utils.ordered_dict import OrderedDict
@@ -321,26 +321,6 @@ class CategoryGroupFeed(NavFeed):
self.root.append(CATALOG_GROUP_ENTRY(item, which, base_href, version, updated))
-class OPDSOffsets(object):
-
- def __init__(self, offset, delta, total):
- if offset < 0:
- offset = 0
- if offset >= total:
- raise cherrypy.HTTPError(404, 'Invalid offset: %r'%offset)
- last_allowed_index = total - 1
- last_current_index = offset + delta - 1
- self.offset = offset
- self.next_offset = last_current_index + 1
- if self.next_offset > last_allowed_index:
- self.next_offset = -1
- self.previous_offset = self.offset - delta
- if self.previous_offset < 0:
- self.previous_offset = 0
- self.last_offset = last_allowed_index - delta
- if self.last_offset < 0:
- self.last_offset = 0
-
class OPDSServer(object):
@@ -374,7 +354,7 @@ class OPDSServer(object):
items = [x for x in self.db.data.iterall() if x[idx] in ids]
self.sort(items, sort_by, ascending)
max_items = self.opts.max_opds_items
- offsets = OPDSOffsets(offset, max_items, len(items))
+ offsets = Offsets(offset, max_items, len(items))
items = items[offsets.offset:offsets.offset+max_items]
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
@@ -448,7 +428,7 @@ class OPDSServer(object):
id_ = 'calibre-category-group-feed:'+category+':'+which
max_items = self.opts.max_opds_items
- offsets = OPDSOffsets(offset, max_items, len(items))
+ offsets = Offsets(offset, max_items, len(items))
items = list(items)[offsets.offset:offsets.offset+max_items]
cherrypy.response.headers['Last-Modified'] = self.last_modified(updated)
@@ -495,7 +475,7 @@ class OPDSServer(object):
if len(items) <= MAX_ITEMS:
max_items = self.opts.max_opds_items
- offsets = OPDSOffsets(offset, max_items, len(items))
+ offsets = Offsets(offset, max_items, len(items))
items = list(items)[offsets.offset:offsets.offset+max_items]
ans = CategoryFeed(items, which, id_, updated, version, offsets,
page_url, up_url, self.db)
@@ -516,7 +496,7 @@ class OPDSServer(object):
getattr(y, 'sort', y.name).startswith(x)])
items = [Group(x, y) for x, y in category_groups.items()]
max_items = self.opts.max_opds_items
- offsets = OPDSOffsets(offset, max_items, len(items))
+ offsets = Offsets(offset, max_items, len(items))
items = items[offsets.offset:offsets.offset+max_items]
ans = CategoryGroupFeed(items, which, id_, updated, version, offsets,
page_url, up_url)
diff --git a/src/calibre/library/server/utils.py b/src/calibre/library/server/utils.py
index 9a64948a3d..35c92f7ae2 100644
--- a/src/calibre/library/server/utils.py
+++ b/src/calibre/library/server/utils.py
@@ -13,6 +13,28 @@ from calibre import strftime as _strftime, prints
from calibre.utils.date import now as nowf
from calibre.utils.config import tweaks
+class Offsets(object):
+ 'Calculate offsets for a paginated view'
+
+ def __init__(self, offset, delta, total):
+ if offset < 0:
+ offset = 0
+ if offset >= total:
+ raise cherrypy.HTTPError(404, 'Invalid offset: %r'%offset)
+ last_allowed_index = total - 1
+ last_current_index = offset + delta - 1
+ self.slice_upper_bound = offset+delta
+ self.offset = offset
+ self.next_offset = last_current_index + 1
+ if self.next_offset > last_allowed_index:
+ self.next_offset = -1
+ self.previous_offset = self.offset - delta
+ if self.previous_offset < 0:
+ self.previous_offset = 0
+ self.last_offset = last_allowed_index - delta
+ if self.last_offset < 0:
+ self.last_offset = 0
+
def expose(func):
diff --git a/src/calibre/library/server/xml.py b/src/calibre/library/server/xml.py
index 469d2457e7..e99fc2839c 100644
--- a/src/calibre/library/server/xml.py
+++ b/src/calibre/library/server/xml.py
@@ -49,6 +49,8 @@ class XMLServer(object):
if not search:
search = ''
+ if isbytestring(search):
+ search = search.decode('UTF-8')
ids = self.db.search_getting_ids(search.strip(), self.search_restriction)
diff --git a/src/calibre/manual/customize.rst b/src/calibre/manual/customize.rst
index c35defc0b0..e0f799f572 100644
--- a/src/calibre/manual/customize.rst
+++ b/src/calibre/manual/customize.rst
@@ -24,6 +24,7 @@ Environment variables
* ``CALIBRE_OVERRIDE_DATABASE_PATH`` - allows you to specify the full path to metadata.db. Using this variable you can have metadata.db be in a location other than the library folder. Useful if your library folder is on a networked drive that does not support file locking.
* ``CALIBRE_DEVELOP_FROM`` - Used to run from a calibre development environment. See :ref:`develop`.
* ``CALIBRE_OVERRIDE_LANG`` - Used to force the language used by the interface (ISO 639 language code)
+ * ``CALIBRE_DISABLE_UDISKS`` - Used to disable the use of udisks for mounting/ejecting. Set it to 1 to use calibre-mount-helper instead.
* ``SYSFS_PATH`` - Use if sysfs is mounted somewhere other than /sys
* ``http_proxy`` - Used on linux to specify an HTTP proxy
diff --git a/src/calibre/manual/faq.rst b/src/calibre/manual/faq.rst
index 3cf171bc1b..220e7ff9e4 100644
--- a/src/calibre/manual/faq.rst
+++ b/src/calibre/manual/faq.rst
@@ -20,9 +20,9 @@ What formats does |app| support conversion to/from?
|app| supports the conversion of many input formats to many output formats.
It can convert every input format in the following list, to every output format.
-*Input Formats:* CBZ, CBR, CBC, CHM, EPUB, FB2, HTML, LIT, LRF, MOBI, ODT, PDF, PRC**, PDB, PML, RB, RTF, TCR, TXT
+*Input Formats:* CBZ, CBR, CBC, CHM, EPUB, FB2, HTML, LIT, LRF, MOBI, ODT, PDF, PRC**, PDB, PML, RB, RTF, SNB, TCR, TXT
-*Output Formats:* EPUB, FB2, OEB, LIT, LRF, MOBI, PDB, PML, RB, PDF, TCR, TXT
+*Output Formats:* EPUB, FB2, OEB, LIT, LRF, MOBI, PDB, PML, RB, PDF, SNB, TCR, TXT
** PRC is a generic format, |app| supports PRC files with TextRead and MOBIBook headers
@@ -387,6 +387,12 @@ solve it, look for a corrupted font file on your system, in ~/Library/Fonts or t
check for corrupted fonts in OS X is to start the "Font Book" application, select all fonts and then in the File
menu, choose "Validate fonts".
+
+I downloaded the installer, but it is not working?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Downloading from the internet can sometimes result in a corrupted download. If the |app| installer you downloaded is not opening, try downloading it again. If re-downloading it does not work, download it from `an alternate location `_. If the installer still doesn't work, then something on your computer is preventing it from running. Best place to ask for more help is in the `forums `_.
+
My antivirus program claims |app| is a virus/trojan?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -418,3 +424,14 @@ How do I run calibre from my USB stick?
A portable version of calibre is available at: `portableapps.com `_. However, this is usually out of date. You can also setup your own portable calibre install by following :ref:`these instructions `.
+Why are there so many calibre-parallel processes on my system?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+|app| maintains two separate worker process pools. One is used for adding books/saving to disk and the other for conversions. You can control the number of worker processes via :guilabel:`Preferences->Advanced->Miscellaneous`. So if you set it to 6 that means a maximum of 3 conversions will run simultaneously. And that is why you will see the number of worker processes changes by two when you use the up and down arrows. On windows, you can set the priority that these processes run with. This can be useful on older, single CPU machines, if you find them slowing down to a crawl when conversions are running.
+
+In addition to this some conversion plugins run tasks in their own pool of processes, so for example if you bulk convert comics, each comic conversion will use three separate processes to render the images. The job manager knows this so it will run only a single comic conversion simultaneously.
+
+And since I'm sure someone will ask: The reason adding/saving books are in separate processes is because of PDF. PDF processing libraries can crash on reading PDFs and I dont want the crash to take down all of calibre. Also when adding EPUB books, in order to extract the cover you have to sometimes render the HTML of the first page, which means that it either has to run the GUI thread of the main process or in a separate process.
+
+Finally, the reason calibre keep workers alive and idle instead of launching on demand is to workaround the slow startup time of python processes.
+
diff --git a/src/calibre/manual/index.rst b/src/calibre/manual/index.rst
index d63b0b71a9..bc8e8a97c2 100644
--- a/src/calibre/manual/index.rst
+++ b/src/calibre/manual/index.rst
@@ -17,10 +17,10 @@ To get started with more advanced usage, you should read about the :ref:`Graphic
You will find the list of :ref:`Frequently Asked Questions ` useful as well.
-.. only:: html and online
+.. only:: online
+
+ An e-book version of this User Manual is available in `EPUB format `_.
- An e-book version of this User Manual is available in `EPUB format `_. Because the User Manual uses advanced formatting, it is only suitable for use with the |app| e-book viewer.
-
Sections
------------
diff --git a/src/calibre/manual/news.rst b/src/calibre/manual/news.rst
index de50fd1c19..88b6dd47bc 100644
--- a/src/calibre/manual/news.rst
+++ b/src/calibre/manual/news.rst
@@ -295,6 +295,9 @@ To learn more about writing advanced recipes using some of the facilities, avail
`Built-in recipes `_
The source code for the built-in recipes that come with |app|
+ `The calibre recipes forum `_
+ Lots of knowledgeable |app| recipe writers hang out here.
+
API documentation
--------------------
diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst
index b731dfe26e..e1eb876cb7 100644
--- a/src/calibre/manual/template_lang.rst
+++ b/src/calibre/manual/template_lang.rst
@@ -122,7 +122,7 @@ The functions available are:
* ``switch(pattern, value, pattern, value, ..., else_value)`` -- for each ``pattern, value`` pair, checks if the field matches the regular expression ``pattern`` and if so, returns that ``value``. If no ``pattern`` matches, then ``else_value`` is returned. You can have as many ``pattern, value`` pairs as you want.
* ``re(pattern, replacement)`` -- return the field after applying the regular expression. All instances of `pattern` are replaced with `replacement`. As in all of |app|, these are python-compatible regular expressions.
* ``shorten(left chars, middle text, right chars)`` -- Return a shortened version of the field, consisting of `left chars` characters from the beginning of the field, followed by `middle text`, followed by `right chars` characters from the end of the string. `Left chars` and `right chars` must be integers. For example, assume the title of the book is `Ancient English Laws in the Times of Ivanhoe`, and you want it to fit in a space of at most 15 characters. If you use ``{title:shorten(9,-,5)}``, the result will be `Ancient E-nhoe`. If the field's length is less than ``left chars`` + ``right chars`` + the length of ``middle text``, then the field will be used intact. For example, the title `The Dome` would not be changed.
- * ``lookup(field if not empty, field if empty)`` -- like test, except the arguments are field (metadata) names, not text. The value of the appropriate field will be fetched and used. Note that because composite columns are fields, you can use this function in one composite field to use the value of some other composite field. This is extremely useful when constructing variable save paths (more later).
+ * ``lookup(pattern, field, pattern, field, ..., else_field)`` -- like switch, except the arguments are field (metadata) names, not text. The value of the appropriate field will be fetched and used. Note that because composite columns are fields, you can use this function in one composite field to use the value of some other composite field. This is extremely useful when constructing variable save paths (more later).
Now, about using functions and formatting in the same field. Suppose you have an integer custom column called ``#myint`` that you want to see with leading zeros, as in ``003``. To do this, you would use a format of ``0>3s``. However, by default, if a number (integer or float) equals zero then the field produces the empty value, so zero values will produce nothing, not ``000``. If you really want to see ``000`` values, then you use both the format string and the ``ifempty`` function to change the empty value back to a zero. The field reference would be::
@@ -151,7 +151,7 @@ The lookup function lets us do even fancier processing. For example, assume that
To accomplish this, we:
1. Create a composite field (call it AA) containing ``{series}/{series_index} - {title'}``. If the series is not empty, then this template will produce `series/series_index - title`.
2. Create a composite field (call it BB) containing ``{#genre:ifempty(Unknown)}/{author_sort}/{title}``. This template produces `genre/author_sort/title`, where an empty genre is replaced wuth `Unknown`.
- 3. Set the save template to ``{series:lookup(AA,BB)}``. This template chooses composite field AA if series is not empty, and composite field BB if series is empty. We therefore have two completely different save paths, depending on whether or not `series` is empty.
+ 3. Set the save template to ``{series:lookup(.,AA,BB)}``. This template chooses composite field AA if series is not empty, and composite field BB if series is empty. We therefore have two completely different save paths, depending on whether or not `series` is empty.
Templates and Plugboards
------------------------
diff --git a/src/calibre/startup.py b/src/calibre/startup.py
index 1046cd93b3..e384153993 100644
--- a/src/calibre/startup.py
+++ b/src/calibre/startup.py
@@ -16,7 +16,7 @@ __builtin__.__dict__['_'] = lambda s: s
# immediately translated to the environment language
__builtin__.__dict__['__'] = lambda s: s
-from calibre.constants import iswindows, preferred_encoding, plugins
+from calibre.constants import iswindows, preferred_encoding, plugins, isosx
_run_once = False
winutil = winutilerror = None
@@ -35,9 +35,17 @@ if not _run_once:
################################################################################
# Convert command line arguments to unicode
+ enc = preferred_encoding
+ if isosx:
+ # Newer versions of OS X seem to use UTF-8
+ try:
+ [x.decode('utf-8') for x in sys.argv[1:]]
+ enc = 'utf-8'
+ except:
+ pass
for i in range(1, len(sys.argv)):
if not isinstance(sys.argv[i], unicode):
- sys.argv[i] = sys.argv[i].decode(preferred_encoding, 'replace')
+ sys.argv[i] = sys.argv[i].decode(enc, 'replace')
################################################################################
# Setup resources
@@ -120,7 +128,8 @@ if not _run_once:
object.__setattr__(self, 'name', name)
def __getattribute__(self, attr):
- if attr == 'name':
+ if attr in ('name', '__enter__', '__str__', '__unicode__',
+ '__repr__'):
return object.__getattribute__(self, attr)
fobject = object.__getattribute__(self, 'fobject')
return getattr(fobject, attr)
@@ -141,6 +150,10 @@ if not _run_once:
def __unicode__(self):
return repr(self).decode('utf-8')
+ def __enter__(self):
+ fobject = object.__getattribute__(self, 'fobject')
+ fobject.__enter__()
+ return self
m = mode[0]
random = len(mode) > 1 and mode[1] == '+'
diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py
index 5e2cb6535a..76c086cc58 100644
--- a/src/calibre/utils/formatter.py
+++ b/src/calibre/utils/formatter.py
@@ -22,11 +22,21 @@ class TemplateFormatter(string.Formatter):
self.book = None
self.kwargs = None
- def _lookup(self, val, field_if_set, field_not_set):
- if val:
- return self.vformat('{'+field_if_set.strip()+'}', [], self.kwargs)
- else:
- return self.vformat('{'+field_not_set.strip()+'}', [], self.kwargs)
+ def _lookup(self, val, *args):
+ if len(args) == 2: # here for backwards compatibility
+ if val:
+ return self.vformat('{'+args[0].strip()+'}', [], self.kwargs)
+ else:
+ return self.vformat('{'+args[1].strip()+'}', [], self.kwargs)
+ if (len(args) % 2) != 1:
+ raise ValueError(_('lookup requires either 2 or an odd number of arguments'))
+ i = 0
+ while i < len(args):
+ if i + 1 >= len(args):
+ return self.vformat('{' + args[i].strip() + '}', [], self.kwargs)
+ if re.search(args[i], val):
+ return self.vformat('{'+args[i+1].strip() + '}', [], self.kwargs)
+ i += 2
def _test(self, val, value_if_set, value_not_set):
if val:
@@ -41,6 +51,8 @@ class TemplateFormatter(string.Formatter):
return value_if_not
def _switch(self, val, *args):
+ if (len(args) % 2) != 1:
+ raise ValueError(_('switch requires an odd number of arguments'))
i = 0
while i < len(args):
if i + 1 >= len(args):
@@ -73,7 +85,7 @@ class TemplateFormatter(string.Formatter):
'capitalize' : (0, lambda s,x: x.capitalize()),
'contains' : (3, _contains),
'ifempty' : (1, _ifempty),
- 'lookup' : (2, _lookup),
+ 'lookup' : (-1, _lookup),
're' : (2, _re),
'shorten' : (3, _shorten),
'switch' : (-1, _switch),
@@ -129,9 +141,9 @@ class TemplateFormatter(string.Formatter):
(func[0] > 0 and func[0] != len(args)):
raise ValueError('Incorrect number of arguments for function '+ fmt[0:p])
if func[0] == 0:
- val = func[1](self, val)
+ val = func[1](self, val).strip()
else:
- val = func[1](self, val, *args)
+ val = func[1](self, val, *args).strip()
if val:
val = string.Formatter.format_field(self, val, dispfmt)
if not val:
diff --git a/src/calibre/utils/ipc/launch.py b/src/calibre/utils/ipc/launch.py
index 8d3628d69a..a179f356be 100644
--- a/src/calibre/utils/ipc/launch.py
+++ b/src/calibre/utils/ipc/launch.py
@@ -14,7 +14,10 @@ from calibre.ptempfile import PersistentTemporaryFile, base_dir
if iswindows:
import win32process
- _windows_null_file = open(os.devnull, 'wb')
+ try:
+ _windows_null_file = open(os.devnull, 'wb')
+ except:
+ raise RuntimeError('NUL %r file missing in windows'%os.devnull)
class Worker(object):
'''
diff --git a/src/calibre/utils/magick/__init__.py b/src/calibre/utils/magick/__init__.py
index 3a4fca09c0..bf0f48db7d 100644
--- a/src/calibre/utils/magick/__init__.py
+++ b/src/calibre/utils/magick/__init__.py
@@ -109,6 +109,13 @@ class Image(_magick.Image): # {{{
return _magick.Image.load(self, bytes(data))
def open(self, path_or_file):
+ if not hasattr(path_or_file, 'read') and \
+ path_or_file.lower().endswith('.wmf'):
+ # Special handling for WMF files as ImageMagick seems
+ # to hand while reading them from a blob on linux
+ if isinstance(path_or_file, unicode):
+ path_or_file = path_or_file.encode(filesystem_encoding)
+ return _magick.Image.read(self, path_or_file)
data = path_or_file
if hasattr(data, 'read'):
data = data.read()
diff --git a/src/calibre/utils/magick/draw.py b/src/calibre/utils/magick/draw.py
index 6808215554..5c978a27e0 100644
--- a/src/calibre/utils/magick/draw.py
+++ b/src/calibre/utils/magick/draw.py
@@ -25,6 +25,7 @@ def save_cover_data_to(data, path, bgcolor='#ffffff', resize_to=None,
resize and the input and output image formats are the same, no changes are
made.
+ :param data: Image data as bytestring or Image object
:param compression_quality: The quality of the image after compression.
Number between 1 and 100. 1 means highest compression, 100 means no
compression (lossless).
@@ -33,8 +34,11 @@ def save_cover_data_to(data, path, bgcolor='#ffffff', resize_to=None,
'''
changed = False
- img = Image()
- img.load(data)
+ if isinstance(data, Image):
+ img = data
+ else:
+ img = Image()
+ img.load(data)
orig_fmt = normalize_format_name(img.format)
fmt = os.path.splitext(path)[1]
fmt = normalize_format_name(fmt[1:])
diff --git a/src/calibre/utils/magick/magick.c b/src/calibre/utils/magick/magick.c
index b1436a830b..0aab5f1fd7 100644
--- a/src/calibre/utils/magick/magick.c
+++ b/src/calibre/utils/magick/magick.c
@@ -414,6 +414,24 @@ magick_Image_load(magick_Image *self, PyObject *args, PyObject *kwargs) {
// }}}
+// Image.load {{{
+static PyObject *
+magick_Image_read(magick_Image *self, PyObject *args, PyObject *kwargs) {
+ const char *data;
+ MagickBooleanType res;
+
+ if (!PyArg_ParseTuple(args, "s", &data)) return NULL;
+
+ res = MagickReadImage(self->wand, data);
+
+ if (!res)
+ return magick_set_exception(self->wand);
+
+ Py_RETURN_NONE;
+}
+
+// }}}
+
// Image.create_canvas {{{
static PyObject *
magick_Image_create_canvas(magick_Image *self, PyObject *args, PyObject *kwargs)
@@ -873,6 +891,10 @@ static PyMethodDef magick_Image_methods[] = {
"Load an image from a byte buffer (string)"
},
+ {"read", (PyCFunction)magick_Image_read, METH_VARARGS,
+ "Read image from path. Path must be a bytestring in the filesystem encoding"
+ },
+
{"export", (PyCFunction)magick_Image_export, METH_VARARGS,
"export(format) -> bytestring\n\n Export the image as the specified format"
},
diff --git a/src/calibre/utils/smartypants.py b/src/calibre/utils/smartypants.py
index 44aac4de8c..62845b8d7a 100644
--- a/src/calibre/utils/smartypants.py
+++ b/src/calibre/utils/smartypants.py
@@ -376,7 +376,8 @@ default_smartypants_attr = "1"
import re
-tags_to_skip_regex = re.compile(r"<(/)?(pre|code|kbd|script|math)[^>]*>", re.I)
+# style added by Kovid
+tags_to_skip_regex = re.compile(r"<(/)?(style|pre|code|kbd|script|math)[^>]*>", re.I)
def verify_installation(request):
diff --git a/src/calibre/utils/smtp.py b/src/calibre/utils/smtp.py
index 230a983b74..b8b46a96cb 100644
--- a/src/calibre/utils/smtp.py
+++ b/src/calibre/utils/smtp.py
@@ -11,6 +11,7 @@ This module implements a simple commandline SMTP client that supports:
import sys, traceback, os
from email import encoders
+from calibre import isbytestring
def create_mail(from_, to, subject, text=None, attachment_data=None,
attachment_type=None, attachment_name=None):
@@ -26,7 +27,10 @@ def create_mail(from_, to, subject, text=None, attachment_data=None,
if text is not None:
from email.mime.text import MIMEText
- msg = MIMEText(text)
+ if isbytestring(text):
+ msg = MIMEText(text)
+ else:
+ msg = MIMEText(text, 'plain', 'utf-8')
outer.attach(msg)
if attachment_data is not None:
diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py
index d1e7866198..f3d77061c3 100644
--- a/src/calibre/web/feeds/news.py
+++ b/src/calibre/web/feeds/news.py
@@ -110,9 +110,11 @@ class BasicNewsRecipe(Recipe):
#: If True the GUI will ask the user for a username and password
#: to use while downloading
- #: @type: boolean
+ #: If set to "optional" the use of a username and password becomes optional
needs_subscription = False
+ #:
+
#: If True the navigation bar is center aligned, otherwise it is left aligned
center_navbar = True
@@ -609,7 +611,8 @@ class BasicNewsRecipe(Recipe):
if self.needs_subscription and (\
self.username is None or self.password is None or \
(not self.username and not self.password)):
- raise ValueError(_('The "%s" recipe needs a username and password.')%self.title)
+ if self.needs_subscription != 'optional':
+ raise ValueError(_('The "%s" recipe needs a username and password.')%self.title)
self.browser = self.get_browser()
self.image_map, self.image_counter = {}, 1
diff --git a/src/calibre/web/feeds/recipes/collection.py b/src/calibre/web/feeds/recipes/collection.py
index 1dd19dc524..012e24a799 100644
--- a/src/calibre/web/feeds/recipes/collection.py
+++ b/src/calibre/web/feeds/recipes/collection.py
@@ -45,12 +45,17 @@ def serialize_recipe(urn, recipe_class):
return ans
default_author = _('You') if urn.startswith('custom:') else _('Unknown')
+ ns = attr('needs_subscription', False)
+ if not ns:
+ ns = 'no'
+ if ns is True:
+ ns = 'yes'
return E.recipe({
'id' : str(urn),
'title' : attr('title', _('Unknown')),
'author' : attr('__author__', default_author),
'language' : attr('language', 'und'),
- 'needs_subscription' : 'yes' if attr('needs_subscription', False) else 'no',
+ 'needs_subscription' : ns,
'description' : attr('description', '')
})