diff --git a/COPYRIGHT b/COPYRIGHT index a31d1dbcda..8790fb69dd 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -4,6 +4,11 @@ License: GPL-3 The full text of the GPL is distributed as in /usr/share/common-licenses/GPL-3 on Debian systems. +Files: src/calibre/ebooks/pdf/*.h,*.cpp +License: GPL-2 or later + The full text of the GPL is distributed as in + /usr/share/common-licenses/GPL-2 on Debian systems. + Files: src/calibre/ebooks/BeautifulSoup.py Copyright: Copyright (c) 2004-2007, Leonard Richardson License: BSD diff --git a/resources/catalog/stylesheet.css b/resources/catalog/stylesheet.css index 057c6c9f42..bf83a4c60b 100644 --- a/resources/catalog/stylesheet.css +++ b/resources/catalog/stylesheet.css @@ -2,19 +2,29 @@ body { background-color: white; } p.title { margin-top:0em; - margin-bottom:1em; + margin-bottom:0em; text-align:center; font-style:italic; font-size:xx-large; - border-bottom: solid black 2px; + } + +p.series_id { + margin-top:0em; + margin-bottom:0em; + text-align:center; + } + +a.series_id { + font-style:normal; + font-size:large; } p.author { + font-size:large; margin-top:0em; margin-bottom:0em; text-align: center; text-indent: 0em; - font-size:large; } p.author_index { @@ -26,7 +36,8 @@ p.author_index { text-indent: 0em; } -p.tags { +p.genres { + font-style:normal; margin-top:0.5em; margin-bottom:0em; text-align: left; @@ -108,6 +119,13 @@ p.date_read { text-indent:-6em; } +hr.annotations_divider { + width:50%; + margin-left:1em; + margin-top:0em; + margin-bottom:0em; + } + hr.description_divider { width:90%; margin-left:5%; @@ -117,20 +135,37 @@ hr.description_divider { border-left: solid white 0px; } -hr.annotations_divider { - width:50%; - margin-left:1em; - margin-top:0em; - margin-bottom:0em; +hr.header_divider { + width:100%; + border-top: solid white 1px; + border-right: solid white 0px; + border-bottom: solid black 2px; + border-left: solid white 0px; + } + +hr.merged_comments_divider { + width:80%; + margin-left:10%; + border-top: solid white 0px; + border-right: solid white 0px; + border-bottom: dashed gray 2px; + border-left: solid white 0px; } td.publisher, td.date { font-weight:bold; text-align:center; } -td.rating { - text-align: center; + +td.rating{ + text-align:center; } + +td.notes { + font-size: 100%; + text-align:center; + } + td.thumbnail img { -webkit-box-shadow: 4px 4px 12px #999; } \ No newline at end of file diff --git a/resources/catalog/template.xhtml b/resources/catalog/template.xhtml new file mode 100644 index 0000000000..409086d343 --- /dev/null +++ b/resources/catalog/template.xhtml @@ -0,0 +1,41 @@ + + + {title_str} + + + + +

{title}

+

{series} [{series_index}]

+
+

{author_prefix}{author}

+

{genres}

+

{formats}

+ + + + + + + + + + + + + + + + + + + + + + + +
 
 
{publisher}
{pubyear}
{rating}
{note_source}: {note_content}
 
+
+
+ + diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index efa46fa7ae..9726ed3b09 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -55,6 +55,32 @@ author_sort_copy_method = 'invert' # categories_use_field_for_author_name = 'author_sort' categories_use_field_for_author_name = 'author' +# Control how the tags pane displays categories containing many items. If the +# number of items is larger than categories_collapse_more_than, a sub-category +# will be added. If sorting by name, then the subcategories can be organized by +# first letter (categories_collapse_model = 'first letter') or into equal-sized +# groups (categories_collapse_model = 'partition'). If sorting by average rating +# or by popularity, then 'partition' is always used. The addition of +# subcategories can be disabled by setting categories_collapse_more_than = 0. +# When using partition, the format of the subcategory label is controlled by a +# template: categories_collapsed_name_template if sorting by name, +# categories_collapsed_rating_template if sorting by average rating, and +# categories_collapsed_popularity_template if sorting by popularity. There are +# two variables available to the template: first and last. The variable 'first' +# is the initial item in the subcategory, and the variable 'last' is the final +# item in the subcategory. Both variables are 'objects'; they each have multiple +# values that are obtained by using a suffix. For example, first.name for an +# author category will be the name of the author. The sub-values available are: +# name: the printable name of the item +# count: the number of books that references this item +# avg_rating: the averate rating of all the books referencing this item +# sort: the sort value. For authors, this is the author_sort for that author +# category: the category (e.g., authors, series) that the item is in. +categories_collapse_more_than = 50 +categories_collapsed_name_template = '{first.name:shorten(4,'',0)} - {last.name::shorten(4,'',0)}' +categories_collapsed_rating_template = '{first.avg_rating:4.2f:ifempty(0)} - {last.avg_rating:4.2f:ifempty(0)}' +categories_collapsed_popularity_template = '{first.count:d} - {last.count:d}' +categories_collapse_model = 'first letter' # Set whether boolean custom columns are two- or three-valued. # Two-values for true booleans @@ -289,3 +315,11 @@ locale_for_sorting = '' # metadata one book at a time. If True, then the fields are laid out using two # columns. If False, one column is used. metadata_single_use_2_cols_for_custom_fields = True + +# The number of seconds to wait before sending emails when using a +# public email server like gmail or hotmail. Default is: 5 minutes +# Setting it to lower may cause the server's SPAM controls to kick in, +# making email sending fail. Changes will take effect only after a restart of +# calibre. +public_smtp_relay_delay = 301 + diff --git a/resources/images/news/business_insider.png b/resources/images/news/business_insider.png new file mode 100644 index 0000000000..7e86e583e8 Binary files /dev/null and b/resources/images/news/business_insider.png differ diff --git a/resources/recipes/business_insider.recipe b/resources/recipes/business_insider.recipe new file mode 100644 index 0000000000..d7e6a0d59b --- /dev/null +++ b/resources/recipes/business_insider.recipe @@ -0,0 +1,69 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Darko Miletic ' +''' +www.businessinsider.com +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class Business_insider(BasicNewsRecipe): + title = 'Business Insider' + __author__ = 'Darko Miletic' + description = 'Noticias de Argentina y el resto del mundo' + publisher = 'Business Insider, Inc.' + category = 'news, politics, finances, world' + oldest_article = 2 + max_articles_per_feed = 200 + no_stylesheets = True + encoding = 'utf8' + use_embedded_content = True + language = 'en' + remove_empty_feeds = True + publication_type = 'newsportal' + masthead_url = 'http://static.businessinsider.com/assets/images/logos/tbi_print.jpg' + extra_css = """ + body{font-family: Arial,Helvetica,sans-serif } + img{margin-bottom: 0.4em; display:block} + """ + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language + } + + remove_tags = [ + dict(name=['meta','link']) + ,dict(attrs={'class':'feedflare'}) + ] + remove_attributes=['lang','border'] + + + feeds = [ + (u'Latest' , u'http://feeds2.feedburner.com/businessinsider' ) + ,(u'Markets' , u'http://feeds.feedburner.com/TheMoneyGame' ) + ,(u'Wall Street' , u'http://feeds.feedburner.com/clusterstock' ) + ,(u'Tech' , u'http://feeds.feedburner.com/typepad/alleyinsider/silicon_alley_insider') + ,(u'The Wire' , u'http://feeds.feedburner.com/businessinsider/thewire' ) + ,(u'War Room' , u'http://feeds.feedburner.com/businessinsider/warroom' ) + ,(u'Sports' , u'http://feeds.feedburner.com/businessinsider/sportspage' ) + ,(u'Tools' , u'http://feeds.feedburner.com/businessinsider/tools' ) + ,(u'Travel' , u'http://feeds.feedburner.com/businessinsider/travel' ) + ] + + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + for item in soup.findAll('a'): + if item['href'].startswith('http://feedads'): + item.extract() + else: + if item.string is not None: + tstr = item.string + item.replaceWith(tstr) + for item in soup.findAll('img'): + if not item.has_key('alt'): + item['alt'] = 'image' + return soup diff --git a/resources/recipes/businessworldin.recipe b/resources/recipes/businessworldin.recipe index 99d56e850f..e44682d7e1 100644 --- a/resources/recipes/businessworldin.recipe +++ b/resources/recipes/businessworldin.recipe @@ -1,7 +1,5 @@ -#!/usr/bin/env python - __license__ = 'GPL v3' -__copyright__ = '2009, Darko Miletic ' +__copyright__ = '2009-2010, Darko Miletic ' ''' www.businessworld.in ''' @@ -22,7 +20,11 @@ class BusinessWorldMagazine(BasicNewsRecipe): use_embedded_content = False encoding = 'utf-8' language = 'en_IN' - + extra_css = """ + img{display: block; margin-bottom: 0.5em} + body{font-family: Arial,Helvetica,sans-serif} + h2{color: gray; display: block} + """ conversion_options = { 'comment' : description @@ -42,7 +44,26 @@ class BusinessWorldMagazine(BasicNewsRecipe): articles = [] linklist = [] soup = self.index_to_soup(self.INDEX) - + + tough = soup.find('div', attrs={'id':'tough'}) + if tough: + for item in tough.findAll('h1'): + description = '' + title_prefix = '' + feed_link = item.find('a') + if feed_link and feed_link.has_key('href'): + url = self.ROOT + feed_link['href'] + if not self.is_in_list(linklist,url): + title = title_prefix + self.tag_to_string(feed_link) + date = strftime(self.timefmt) + articles.append({ + 'title' :title + ,'date' :date + ,'url' :url + ,'description':description + }) + linklist.append(url) + for item in soup.findAll('div', attrs={'class':'nametitle'}): description = '' title_prefix = '' @@ -62,8 +83,8 @@ class BusinessWorldMagazine(BasicNewsRecipe): return [(soup.head.title.string, articles)] - keep_only_tags = [dict(name='div', attrs={'id':['register-panel','printwrapper']})] - remove_tags = [dict(name=['object','link'])] + keep_only_tags = [dict(name='div', attrs={'id':'printwrapper'})] + remove_tags = [dict(name=['object','link','meta','base','iframe','link','table'])] def print_version(self, url): return url.replace('/bw/','/bw/storyContent/') diff --git a/resources/recipes/el_periodico.recipe b/resources/recipes/el_periodico.recipe new file mode 100644 index 0000000000..2c3ed456fb --- /dev/null +++ b/resources/recipes/el_periodico.recipe @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__license__ = 'GPL v3' +__copyright__ = '04 December 2010, desUBIKado' +__author__ = 'desUBIKado' +__description__ = 'Daily newspaper from Aragon' +__version__ = 'v0.05' +__date__ = '07, December 2010' +''' +elperiodicodearagon.com +''' +import re +from calibre.web.feeds.news import BasicNewsRecipe + + +class elperiodicodearagon(BasicNewsRecipe): + title = u'El Periodico de Aragon' + __author__ = u'desUBIKado' + description = u'Noticias desde Aragon' + publisher = u'elperiodicodearagon.com' + category = u'news, politics, Spain, Aragon' + oldest_article = 2 + delay = 0 + max_articles_per_feed = 100 + no_stylesheets = True + use_embedded_content = False + language = 'es' + encoding = 'utf8' + remove_empty_feeds = True + remove_javascript = True + + + conversion_options = { + 'comments' : description + ,'tags' : category + ,'language' : language + ,'publisher' : publisher + } + + feeds = [(u'Arag\xf3n', u'http://elperiodicodearagon.com/RSS/2.xml'), + (u'Internacional', u'http://elperiodicodearagon.com/RSS/4.xml'), + (u'Espa\xf1a', u'http://elperiodicodearagon.com/RSS/3.xml'), + (u'Econom\xeda', u'http://elperiodicodearagon.com/RSS/5.xml'), + (u'Deportes', u'http://elperiodicodearagon.com/RSS/7.xml'), + (u'Real Zaragoza', u'http://elperiodicodearagon.com/RSS/10.xml'), + (u'Opini\xf3n', u'http://elperiodicodearagon.com/RSS/103.xml'), + (u'Escenarios', u'http://elperiodicodearagon.com/RSS/105.xml'), + (u'Sociedad', u'http://elperiodicodearagon.com/RSS/104.xml'), + (u'Gente', u'http://elperiodicodearagon.com/RSS/330.xml')] + + + extra_css = ''' + h3{font-family:Arial,Helvetica,sans-serif; font-weight:bold;font-size:xx-large;} + h2{font-family:Arial,Helvetica,sans-serif; font-weight:normal;font-size:small;} + dd{font-family:Arial,Helvetica,sans-serif; font-weight:normal;font-size:small;} + ''' + + remove_attributes = ['height','width'] + + keep_only_tags = [dict(name='div', attrs={'id':'contenidos'})] + + + # Quitar toda la morralla + + remove_tags = [dict(name='ul', attrs={'class':'herramientasDeNoticia'}), + dict(name='span', attrs={'class':'MasInformacion '}), + dict(name='span', attrs={'class':'MasInformacion'}), + dict(name='div', attrs={'class':'Middle'}), + dict(name='div', attrs={'class':'MenuCabeceraRZaragoza'}), + dict(name='div', attrs={'id':'MenuCabeceraRZaragoza'}), + dict(name='div', attrs={'class':'MenuEquipo'}), + dict(name='div', attrs={'class':'TemasRelacionados'}), + dict(name='div', attrs={'class':'GaleriaEnNoticia'}), + dict(name='div', attrs={'class':'Recorte'}), + dict(name='div', attrs={'id':'NoticiasenRecursos'}), + dict(name='div', attrs={'id':'NoticiaEnPapel'}), + dict(name='p', attrs={'class':'RecorteEnNoticias'}), + dict(name='div', attrs={'id':'Comparte'}), + dict(name='div', attrs={'id':'CajaComparte'}), + dict(name='a', attrs={'class':'EscribirComentario'}), + dict(name='a', attrs={'class':'AvisoComentario'}), + dict(name='div', attrs={'class':'CajaAvisoComentario'}), + dict(name='div', attrs={'class':'navegaNoticias'}), + dict(name='div', attrs={'id':'PaginadorDiCom'}), + dict(name='div', attrs={'id':'CajaAccesoCuentaUsuario'}), + dict(name='div', attrs={'id':'CintilloComentario'}), + dict(name='div', attrs={'id':'EscribeComentario'}), + dict(name='div', attrs={'id':'FormularioComentario'}), + dict(name='div', attrs={'id':'FormularioNormas'})] + + # Recuperamos la portada de papel (la imagen format=1 tiene mayor resolucion) + + def get_cover_url(self): + index = 'http://pdf.elperiodicodearagon.com/' + soup = self.index_to_soup(index) + for image in soup.findAll('img',src=True): + if image['src'].startswith('http://pdf.elperiodicodearagon.com/funciones/portada-preview.php?eid='): + return image['src'].rstrip('format=2') + 'format=1' + return None + + # Para quitar espacios entre la noticia y los comentarios (lineas 1 y 2) + # El indice no apuntaba correctamente al empiece de la noticia (linea 3) + + preprocess_regexps = [ + (re.compile(r'

 

', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'

', re.DOTALL|re.IGNORECASE), lambda match: ''), + (re.compile(r'

', re.DOTALL|re.IGNORECASE), lambda match: '

') + ] diff --git a/resources/recipes/el_universal.recipe b/resources/recipes/el_universal.recipe index 1995d0f932..f053812c05 100644 --- a/resources/recipes/el_universal.recipe +++ b/resources/recipes/el_universal.recipe @@ -1,7 +1,5 @@ -#!/usr/bin/env python - __license__ = 'GPL v3' -__copyright__ = '2009, Darko Miletic ' +__copyright__ = '2009-2010, Darko Miletic ' ''' eluniversal.com.mx ''' @@ -18,75 +16,25 @@ class ElUniversal(BasicNewsRecipe): category = 'news, politics, Mexico' no_stylesheets = True use_embedded_content = False - encoding = 'cp1252' + encoding = 'utf8' remove_javascript = True - language = 'es' + remove_empty_feeds = True + publication_type = 'newspaper' + language = 'es' extra_css = ''' - body{font-family:Arial,Helvetica,sans-serif; font-size:x-small;} - .geoGris30{font-family:Georgia,"Times New Roman",Times,serif; font-size:large; color:#003366; font-weight:bold;} - .arnegro16{font-family:Georgia,"Times New Roman",Times,serif; font-weight:bold; font-size:small;} - .tbazull2{font-family:"trebuchet ms",Arial,Helvetica,sans-serif; color:#336699; font-size:xx-small;} - .tbgrisf11{font-family:"trebuchet ms",Arial,Helvetica,sans-serif; color: #666666; font-size:xx-small;} - .verrojo13{font-family:"trebuchet ms",Arial,Helvetica,sans-serif; color: #CC0033; font-size:xx-small;} - .trnegro13{font-family:"trebuchet ms",Arial,Helvetica,sans-serif; font-size:xx-small;} - .txt-fotogaleria{font-family:"trebuchet ms",Arial,Helvetica,sans-serif; font-size:xx-small;} + body{font-family:Arial,Helvetica,sans-serif} + .noteTitle{font-family: Georgia,"Times New Roman",Times,serif; color: #336699; font-size: xx-large; font-weight: bold} + .noteInfo{display: block; color: gray} ''' - keep_only_tags = [ dict(name='table', attrs={'width':"633"}),dict(name='table', attrs={'width':"629"}),] - + keep_only_tags = [ dict(name='div', attrs={'id':'noteContent'})] + remove_tags_after = dict(attrs={'class':'noteText'}) remove_tags = [ - dict(name='table', attrs={'bgcolor':"#f5f5f5"}), - dict(name='td', attrs={'bgcolor':"#f7f8f9"}), - dict(name='td', attrs={'bgcolor':"#f5f5f5"}), - dict(name='table', attrs={'width':"302"}), - dict(name='table', attrs={'width':"214"}), - dict(name='table', attrs={'width':"112"}), - dict(name='table', attrs={'width':"980"}), - dict(name='td', attrs={'height':"1"}), - dict(name='td', attrs={'height':"4"}), - dict(name='td', attrs={'height':"20"}), - dict(name='td', attrs={'height':"10"}), - dict(name='td', attrs={'class':["trrojo11","trbris11","trrojo12","arrojo12s","tbazul13"]}), - dict(name='div', attrs={'id':["mapg","ver_off_todosloscom","todosloscom"]}), - dict(name='span', attrs={'class':["trazul18b","trrojo11","trnaranja11","trbris11","georojo18b","geogris18"]}), - dict(name='span', attrs={'class':["detalles-opinion"]}), - dict(name='a', attrs={'class':["arnaranja12b","trbris11","arazul12rel","trrojo10"]}), - dict(name='img', src = "/img/icono_imprimir.gif"), - dict(name='img', src = "/img/icono_enviar_mail.gif"), - dict(name='img', src = "/img/icono_fuente_g.gif"), - dict(name='img', src = "/img/icono_fuente_m.gif"), - dict(name='img', src = "/img/icono_fuente_c.gif"), - dict(name='img', src = "/img/icono_compartir.gif"), - dict(name='img', src = "/img/icono_enviar_coment.gif"), - dict(name='img', src = "http://www.eluniversal.com.mx/n_img/bot-notasrel.gif"), - dict(name='img', src = "http://www.eluniversal.com.mx/n_img/fr.gif"), - dict(name='img', src = "/img/espiral2.gif"), - dict(name='img', src = "http://www.eluniversal.com.mx/n_img/b"), - dict(name='img', src = "/img/icono_enviar_coment.gifot-notasrel.gif"), - dict(name='img', src = "/n_img/icono_tipo3.gif"), - dict(name='img', src = "/n_img/icono_tipo2.gif"), - dict(name='img', src = "/n_img/icono_print.gif"), - dict(name='img', src = "/n_img/icono_mail2.gif"), - dict(name='img', src = "/n_img/im-comentarios-2a.gif"), - dict(name='img', src = "/n_img/im-comentarios-1a.gif"), - dict(name='img', src = "/img/icono_coment.gif"), - dict(name='img', src = "http://www.eluniversal.com.mx/n_img/bot-sitiosrel.gif"), - dict(name='img', src = "/n_img/icono_tipomenos.gif"), - dict(name='img', src = "/img/futbol/19.jpg"), - dict(name='img', alt = "Facebook"), - dict(name='img', alt = "Twitter"), - dict(name='img', alt = "Google"), - dict(name='img', alt = "LinkedIn"), - dict(name='img', alt = "Viadeo"), - dict(name='img', alt = "Digg"), - dict(name='img', alt = "Delicious"), - dict(name='img', alt = "Meneame"), - dict(name='img', alt = "Yahoo"), - dict(name='img', alt = "Technorati"), - dict(name='a',text =["Compartir","Facebook","Twitter","Google","LinkedIn","Viadeo","Digg","Delicious","Meneame","Yahoo","Technorati"]), - dict(name='select'), - dict(name='a', attrs={'class':"tbgriscompartir"}), - ] + dict(attrs={'class':'noteExtras'}), + dict(name=['meta','iframe','base','embed','object']), + dict(attrs={'id':'tm_box'}) + ] + remove_attributes=['lang','onclick'] feeds = [ (u'Minuto por Minuto', u'http://www.eluniversal.com.mx/rss/universalmxm.xml' ) @@ -101,25 +49,3 @@ class ElUniversal(BasicNewsRecipe): ,(u'Computacion' , u'http://www.eluniversal.com.mx/rss/computo.xml' ) ,(u'Sociedad' , u'http://www.eluniversal.com.mx/rss/sociedad.xml' ) ] - - # def print_version(self, url): - # return url.replace('/notas/','/notas/vi_') - - def preprocess_html(self, soup): - mtag = '' - soup.head.insert(0,mtag) - for tag in soup.findAll(name='td',attrs={'class': 'arazul50'}): - tag.insert(0,"

") - tag.insert(2,"

") - - return soup - - def postprocess_html(self, soup,first): - - for tag in soup.findAll(name=['table', 'span','i']): - tag.name = 'div' - for item in soup.findAll(align = "right"): - del item['align'] - - return soup - diff --git a/resources/recipes/elpais_impreso.recipe b/resources/recipes/elpais_impreso.recipe index bba3bda217..130013286c 100644 --- a/resources/recipes/elpais_impreso.recipe +++ b/resources/recipes/elpais_impreso.recipe @@ -1,86 +1,95 @@ -# -*- coding: utf-8 -*- __license__ = 'GPL v3' __copyright__ = '2010, Darko Miletic ' ''' -www.elpais.com/diario/ +www.elpais.com ''' -from calibre import strftime from calibre.web.feeds.news import BasicNewsRecipe -class ElPaisImpresa(BasicNewsRecipe): - title = u'El Pa\xeds - edicion impresa' +class ElPais_RSS(BasicNewsRecipe): + title = 'El Pais' __author__ = 'Darko Miletic' - description = u'el periodico global en Espa\xf1ol' + description = 'el periodico global en Castellano' publisher = 'EDICIONES EL PAIS, S.L.' - category = 'news, politics,Spain,actualidad,noticias,informacion,videos,fotografias,audios,graficos,nacional,internacional,deportes,economia,tecnologia,cultura,gente,television,sociedad,opinion,blogs,foros,chats,encuestas,entrevistas,participacion' + category = 'news, politics, finances, world, spain' + oldest_article = 2 + max_articles_per_feed = 200 no_stylesheets = True - encoding = 'latin1' + encoding = 'cp1252' use_embedded_content = False - language = 'es' + language = 'es_ES' + remove_empty_feeds = True publication_type = 'newspaper' - masthead_url = 'http://www.elpais.com/im/tit_logo_global.gif' - index = 'http://www.elpais.com/diario/' - extra_css = ' p{text-align: justify} body{ text-align: left; font-family: Georgia,"Times New Roman",Times,serif } h2{font-family: Arial,Helvetica,sans-serif} img{margin-bottom: 0.4em} ' + masthead_url = 'http://www.elpais.com/im/tit_logo.gif' + extra_css = """ + body{font-family: Georgia,"Times New Roman",Times,serif } + h3{font-family: Arial,Helvetica,sans-serif} + img{margin-bottom: 0.4em; display:block} + """ conversion_options = { - 'comment' : description - , 'tags' : category - , 'publisher' : publisher - , 'language' : language + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language } - feeds = [ - (u'Internacional' , index + u'internacional/' ) - ,(u'Espa\xf1a' , index + u'espana/' ) - ,(u'Economia' , index + u'economia/' ) - ,(u'Opinion' , index + u'opinion/' ) - ,(u'Vi\xf1etas' , index + u'vineta/' ) - ,(u'Sociedad' , index + u'sociedad/' ) - ,(u'Cultura' , index + u'cultura/' ) - ,(u'Tendencias' , index + u'tendencias/' ) - ,(u'Gente' , index + u'gente/' ) - ,(u'Obituarios' , index + u'obituarios/' ) - ,(u'Deportes' , index + u'deportes/' ) - ,(u'Pantallas' , index + u'radioytv/' ) - ,(u'Ultima' , index + u'ultima/' ) - ,(u'Educacion' , index + u'educacion/' ) - ,(u'Saludo' , index + u'salud/' ) - ,(u'Ciberpais' , index + u'ciberpais/' ) - ,(u'EP3' , index + u'ep3/' ) - ,(u'Cine' , index + u'cine/' ) - ,(u'Babelia' , index + u'babelia/' ) - ,(u'El viajero' , index + u'viajero/' ) - ,(u'Negocios' , index + u'negocios/' ) - ,(u'Domingo' , index + u'domingo/' ) - ,(u'El Pais semanal' , index + u'eps/' ) - ,(u'Quadern Catalunya' , index + u'quadern-catalunya/' ) - ] + keep_only_tags = [dict(attrs={'class':['cabecera_noticia estirar','cabecera_noticia','','contenido_noticia']})] + remove_tags = [ + dict(name=['meta','link','base','iframe','embed','object']) + ,dict(attrs={'class':['info_complementa','estructura_2col_der','votos estirar','votos']}) + ,dict(attrs={'id':'utilidades'}) + ] + remove_tags_after = dict(attrs={'id':'utilidades'}) + remove_attributes = ['lang','border','width','height'] - keep_only_tags=[dict(attrs={'class':['cabecera_noticia','contenido_noticia']})] - remove_attributes=['width','height'] - remove_tags=[dict(name='link')] - - def parse_index(self): - totalfeeds = [] - lfeeds = self.get_feeds() - for feedobj in lfeeds: - feedtitle, feedurl = feedobj - self.report_progress(0, _('Fetching feed')+' %s...'%(feedtitle if feedtitle else feedurl)) - articles = [] - soup = self.index_to_soup(feedurl) - for item in soup.findAll('a',attrs={'class':['g19r003','g19i003','g17r003','g17i003']}): - url = 'http://www.elpais.com' + item['href'].rpartition('/')[0] - title = self.tag_to_string(item) - date = strftime(self.timefmt) - articles.append({ - 'title' :title - ,'date' :date - ,'url' :url - ,'description':'' - }) - totalfeeds.append((feedtitle, articles)) - return totalfeeds + feeds = [ + (u'Lo ultimo' , u'http://www.elpais.com/rss/feed.html?feedId=17046') + ,(u'America Latina' , u'http://www.elpais.com/rss/feed.html?feedId=17041') + ,(u'Mexico' , u'http://www.elpais.com/rss/feed.html?feedId=17042') + ,(u'Europa' , u'http://www.elpais.com/rss/feed.html?feedId=17043') + ,(u'Estados Unidos' , u'http://www.elpais.com/rss/feed.html?feedId=17044') + ,(u'Oriente proximo' , u'http://www.elpais.com/rss/feed.html?feedId=17045') + ,(u'Espana' , u'http://www.elpais.com/rss/feed.html?feedId=1002' ) + ,(u'Andalucia' , u'http://www.elpais.com/rss/feed.html?feedId=17057') + ,(u'Catalunia' , u'http://www.elpais.com/rss/feed.html?feedId=17059') + ,(u'Comunidad Valenciana' , u'http://www.elpais.com/rss/feed.html?feedId=17061') + ,(u'Madrid' , u'http://www.elpais.com/rss/feed.html?feedId=1016' ) + ,(u'Pais Vasco' , u'http://www.elpais.com/rss/feed.html?feedId=17062') + ,(u'Galicia' , u'http://www.elpais.com/rss/feed.html?feedId=17063') + ,(u'Opinion' , u'http://www.elpais.com/rss/feed.html?feedId=1003' ) + ,(u'Sociedad' , u'http://www.elpais.com/rss/feed.html?feedId=1004' ) + ,(u'Deportes' , u'http://www.elpais.com/rss/feed.html?feedId=1007' ) + ,(u'Cultura' , u'http://www.elpais.com/rss/feed.html?feedId=1008' ) + ,(u'Cine' , u'http://www.elpais.com/rss/feed.html?feedId=17052') + ,(u'Literatura' , u'http://www.elpais.com/rss/feed.html?feedId=17053') + ,(u'Musica' , u'http://www.elpais.com/rss/feed.html?feedId=17051') + ,(u'Arte' , u'http://www.elpais.com/rss/feed.html?feedId=17060') + ,(u'Tecnologia' , u'http://www.elpais.com/rss/feed.html?feedId=1005' ) + ,(u'Economia' , u'http://www.elpais.com/rss/feed.html?feedId=1006' ) + ,(u'Ciencia' , u'http://www.elpais.com/rss/feed.html?feedId=17068') + ,(u'Salud' , u'http://www.elpais.com/rss/feed.html?feedId=17074') + ,(u'Ocio' , u'http://www.elpais.com/rss/feed.html?feedId=17075') + ,(u'Justicia y Leyes' , u'http://www.elpais.com/rss/feed.html?feedId=17069') + ,(u'Guerras y conflictos' , u'http://www.elpais.com/rss/feed.html?feedId=17070') + ,(u'Politica' , u'http://www.elpais.com/rss/feed.html?feedId=17073') + ] def print_version(self, url): return url + '?print=1' + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + for item in soup.findAll('a'): + if item.string is not None: + tstr = item.string + item.replaceWith(tstr) + else: + item.name='span' + for atrs in ['href','target','alt','title']: + if item.has_key(atrs): + del item[atrs] + for item in soup.findAll('img',alt=False): + item['alt'] = 'image' + return soup diff --git a/resources/recipes/heraldo.recipe b/resources/recipes/heraldo.recipe index 381e97b9ce..c5669e116b 100644 --- a/resources/recipes/heraldo.recipe +++ b/resources/recipes/heraldo.recipe @@ -1,50 +1,65 @@ #!/usr/bin/env python -__license__ = 'GPL v3' -__author__ = 'Lorenzo Vigentini' -__copyright__ = '2009, Lorenzo Vigentini ' +__license__ = 'GPL v3' +__copyright__ = '04 December 2010, desUBIKado' +__author__ = 'desUBIKado' __description__ = 'Daily newspaper from Aragon' -__version__ = 'v1.01' -__date__ = '30, January 2010' - +__version__ = 'v0.03' +__date__ = '11, December 2010' ''' -http://www.heraldo.es/ +[url]http://www.heraldo.es/[/url] ''' +import time from calibre.web.feeds.news import BasicNewsRecipe class heraldo(BasicNewsRecipe): - author = 'Lorenzo Vigentini' + __author__ = 'desUBIKado' description = 'Daily newspaper from Aragon' - - cover_url = 'http://www.heraldo.es/MODULOS/global/publico/interfaces/img/logo.gif' title = u'Heraldo de Aragon' publisher = 'OJD Nielsen' category = 'News, politics, culture, economy, general interest' - language = 'es' timefmt = '[%a, %d %b, %Y]' - oldest_article = 1 - max_articles_per_feed = 25 - + max_articles_per_feed = 100 use_embedded_content = False - recursion = 10 - remove_javascript = True no_stylesheets = True - - keep_only_tags = [ - dict(name='div', attrs={'class':['titularNoticiaNN','textoGrisVerdanaContenidos']}) - ] + recursion = 10 feeds = [ - (u'Portadas ', u'http://www.heraldo.es/index.php/mod.portadas/mem.rss') - ] + (u'Portadas', u'http://www.heraldo.es/index.php/mod.portadas/mem.rss') + ] + + + + keep_only_tags = [dict(name='div', attrs={'id':['dts','com']})] + + remove_tags = [dict(name='a', attrs={'class':['com flo-r','enl-if','enl-df']}), + dict(name='div', attrs={'class':['brb-b-s con marg-btt','cnt-rel con']}), + dict(name='form', attrs={'class':'form'})] + + remove_tags_before = dict(name='div' , attrs={'id':'dts'}) + remove_tags_after = dict(name='div' , attrs={'id':'com'}) + + def get_cover_url(self): + cover = None + st = time.localtime() + year = str(st.tm_year) + month = "%.2d" % st.tm_mon + day = "%.2d" % st.tm_mday + #[url]http://oldorigin-www.heraldo.es/20101211/primeras/portada_aragon.pdf[/url] + cover='http://oldorigin-www.heraldo.es/'+ year + month + day +'/primeras/portada_aragon.pdf' + br = BasicNewsRecipe.get_browser() + try: + br.open(cover) + except: + self.log("\nPortada no disponible") + cover ='http://www.heraldo.es/MODULOS/global/publico/interfaces/img/logo-Heraldo.png' + return cover + + + extra_css = ''' - .articledate {color: gray;font-family: monospace;} - .articledescription {display: block;font-family: sans;font-size: 0.7em; text-indent: 0;} - .firma {color: #666;display: block;font-family: verdana, arial, helvetica;font-size: 1em;margin-bottom: 8px;} - .textoGrisVerdanaContenidos {color: #56595c;display: block;font-family: Verdana;font-size: 1.28571em;padding-bottom: 10px} - .titularNoticiaNN {display: block;padding-bottom: 10px;padding-left: 0;padding-right: 0;padding-top: 4px} - .titulo {color: #003066;font-family: Tahoma;font-size: 1.92857em;font-weight: bold;line-height: 1.2em} - ''' + h2{font-family:Arial,Helvetica,sans-serif; font-weight:bold;font-size:xx-large;} + ''' diff --git a/resources/recipes/karlsruhe.recipe b/resources/recipes/karlsruhe.recipe new file mode 100644 index 0000000000..c0bc5369f1 --- /dev/null +++ b/resources/recipes/karlsruhe.recipe @@ -0,0 +1,52 @@ +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class KANewsRecipe(BasicNewsRecipe): + title = u'KA-News.de' + description = u'Nachrichten aus Karlsruhe, Deutschland und der Welt.' + __author__ = 'tfeld' + lang='de' + no_stylesheets = True + + oldest_article = 7 + max_articles_per_feed = 100 + + feeds = [ + (u'News aus Karlsruhe', 'http://www.ka-news.de/storage/rss/rss/karlsruhe.xml'), + (u'Kulturnachrichten aus Karlsruhe', 'http://www.ka-news.de/storage/rss/rss/kultur.xml'), + (u'Durlach: News aus Durlach', 'http://www.ka-news.de/storage/rss/rss/durlach.xml'), + (u'Stutensee: News aus Stutensee Blankenloch, Büchig, Friedrichstal, Staffort, Spöck', 'http://www.ka-news.de/storage/rss/rss/stutensee.xml'), + (u'Bruchsal: News aus Bruchsal', 'http://www.ka-news.de/storage/rss/rss/bruchsal.xml'), + (u'Wirtschaftsnews aus Karlsruhe', 'http://www.ka-news.de/storage/rss/rss/wirtschaft.xml'), + (u'ka-news.de - Sport', 'http://www.ka-news.de/storage/rss/rss/sport.xml'), + (u'KSC-News - News rund um den KSC', 'http://www.ka-news.de/storage/rss/rss/ksc.xml'), + (u'ka-news.de - BG Karlsruhe', 'http://www.ka-news.de/storage/rss/rss/basketball.xml') + ] + + preprocess_regexps = [ + (re.compile(r'width:[0-9]*?px', re.DOTALL|re.IGNORECASE), lambda match: ''), + ] + + remove_tags_before = dict(id='artdetail_ueberschrift') + remove_tags_after = dict(id='artdetail_unterzeile') + remove_tags = [dict(name=['div'], attrs={'class': 'lbx_table'}), + dict(name=['div'], attrs={'class': 'lk_zumthema'}), + dict(name=['div'], attrs={'class': 'lk_thumb'}), + dict(name=['div'], attrs={'class': 'lk_trenner'}), + dict(name=['div'], attrs={'class': 'lupen_container'}), + dict(name=['script']), + dict(name=['span'], attrs={'style': 'display:none;'}), + dict(name=['span'], attrs={'class': 'comm_info'}), + dict(name=['h3'], attrs={'id': 'artdetail_unterzeile'})] + + # removing style attribute _after_ removing specifig tags above + remove_attributes = ['width','height','style'] + + extra_css = ''' + h1{ font-size:large; font-weight:bold; } + h2{ font-size:medium; font-weight:bold; } + ''' + + def get_cover_url(self): + return 'http://www.ka-news.de/storage/scl/techkanews/logos/434447_m1t1w250q75s1v29681_ka-news-Logo_mit_Schatten_transparent.png' + diff --git a/resources/recipes/red_aragon.recipe b/resources/recipes/red_aragon.recipe new file mode 100644 index 0000000000..4681e6660b --- /dev/null +++ b/resources/recipes/red_aragon.recipe @@ -0,0 +1,47 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '11 December 2010, desUBIKado' +__author__ = 'desUBIKado' +__description__ = 'Entertainment guide from Aragon' +__version__ = 'v0.01' +__date__ = '11, December 2010' +''' +[url]http://www.redaragon.es/[/url] +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class heraldo(BasicNewsRecipe): + __author__ = 'desUBIKado' + description = u'Guia de ocio desde Aragon' + title = u'RedAragon' + publisher = 'Grupo Z' + category = 'Concerts, Movies, Entertainment news' + cover_url = 'http://www.redaragon.com/2008_img/logotipo.gif' + language = 'es' + timefmt = '[%a, %d %b, %Y]' + oldest_article = 15 + max_articles_per_feed = 100 + encoding = 'iso-8859-1' + use_embedded_content = False + remove_javascript = True + no_stylesheets = True + + feeds = [(u'Conciertos', u'http://redaragon.com/rss/agenda.asp?tid=1'), + (u'Exposiciones', u'http://redaragon.com/rss/agenda.asp?tid=5'), + (u'Teatro', u'http://redaragon.com/rss/agenda.asp?tid=10'), + (u'Conferencias', u'http://redaragon.com/rss/agenda.asp?tid=2'), + (u'Ferias', u'http://redaragon.com/rss/agenda.asp?tid=6'), + (u'Filmotecas/Cineclubs', u'http://redaragon.com/rss/agenda.asp?tid=7'), + (u'Presentaciones', u'http://redaragon.com/rss/agenda.asp?tid=9'), + (u'Fiestas', u'http://redaragon.com/rss/agenda.asp?tid=11'), + (u'Infantil', u'http://redaragon.com/rss/agenda.asp?tid=13'), + (u'Otros', u'http://redaragon.com/rss/agenda.asp?tid=8')] + + keep_only_tags = [dict(name='div', attrs={'id':'FichaEventoAgenda'})] + + remove_tags = [dict(name='div', attrs={'class':['Comparte','CajaAgenda','Caja','Cintillo']})] + + remove_tags_before = dict(name='div' , attrs={'id':'FichaEventoAgenda'}) + + remove_tags_after = dict(name='div' , attrs={'class':'Cintillo'}) diff --git a/resources/recipes/salon.recipe b/resources/recipes/salon.recipe index ed7ec98f10..c421ab094d 100644 --- a/resources/recipes/salon.recipe +++ b/resources/recipes/salon.recipe @@ -25,22 +25,20 @@ class Salon_com(BasicNewsRecipe): feeds = [ ('News & Politics', 'http://feeds.salon.com/salon/news'), - ('War Room', 'http://feeds.salon.com/salon/war_room'), - ('Arts & Entertainment', 'http://feeds.salon.com/salon/ent'), - ('I Like to Watch', 'http://feeds.salon.com/salon/iltw'), - ('Beyond Multiplex', 'http://feeds.salon.com/salon/btm'), - ('Book Reviews', 'http://feeds.salon.com/salon/books'), - ('All Life', 'http://feeds.salon.com/salon/mwt'), - ('All Opinion', 'http://feeds.salon.com/salon/opinion'), - ('Glenn Greenwald', 'http://feeds.salon.com/salon/greenwald'), - ('Garrison Keillor', 'http://dir.salon.com/topics/garrison_keillor/index.rss'), - ('Joan Walsh', 'http://www.salon.com/rss/walsh.rss'), - ('All Sports', 'http://feeds.salon.com/salon/sports'), + ('War Room', 'http://feeds.feedburner.com/salon/war_room'), + ('Joan Walsh', 'http://feeds.feedburner.com/Salon_Joan_Walsh'), + ('Glenn Greenwald', 'http://feeds.feedburner.com/salon/greenwald'), ('Tech & Business', 'http://feeds.salon.com/salon/tech'), - ('How World Works', 'http://feeds.salon.com/salon/htww') + ('Ask the Pilot', 'http://feeds.feedburner.com/salon/ask_the_pilot'), + ('How World Works', 'http://feeds.feedburner.com/salon/htww'), + ('Life', 'http://feeds.feedburner.com/salon/mwt'), + ('Broadsheet', 'http://feeds.feedburner.com/salon/broadsheet'), + ('Movie Reviews', 'http://feeds.feedburner.com/salon/movie_reviews'), + ('Film Salon', 'http://feeds.feedburner.com/Salon/Film_Salon'), + ('TV', 'http://feeds.feedburner.com/salon/tv'), + ('Books', 'http://feeds.feedburner.com/salon/books') ] def print_version(self, url): return url.replace('/index.html', '/print.html') - diff --git a/resources/recipes/smith.recipe b/resources/recipes/smith.recipe index e52b2ee709..98f7d98517 100644 --- a/resources/recipes/smith.recipe +++ b/resources/recipes/smith.recipe @@ -17,8 +17,8 @@ class SmithsonianMagazine(BasicNewsRecipe): remove_tags = [ dict(name='iframe'), dict(name='div', attrs={'class':'article_sidebar_border'}), - dict(name='div', attrs={'id':['article_sidebar_border', 'most-popular_large']}), - #dict(name='ul', attrs={'class':'article-tools'}), + dict(name='div', attrs={'id':['article_sidebar_border', 'most-popular_large', 'most-popular-body_large']}), + ##dict(name='ul', attrs={'class':'article-tools'}), dict(name='ul', attrs={'class':'cat-breadcrumb col three last'}), ] @@ -37,16 +37,16 @@ class SmithsonianMagazine(BasicNewsRecipe): ] def preprocess_html(self, soup): - story = soup.find(name='div', attrs={'id':'article-left'}) - #td = heading.findParent(name='td') - #td.extract() + story = soup.find(name='div', attrs={'id':'article-body'}) + ##td = heading.findParent(name='td') + ##td.extract() soup = BeautifulSoup('t') body = soup.find(name='body') body.insert(0, story) return soup - def postprocess_html(self, soup, first): - for p in soup.findAll(id='articlePaginationWrapper'): p.extract() - if not first: - for div in soup.findAll(id='article-head'): div.extract() - return soup + #def postprocess_html(self, soup, first): + #for p in soup.findAll(id='articlePaginationWrapper'): p.extract() + #if not first: + #for div in soup.findAll(id='article-head'): div.extract() + #return soup diff --git a/resources/recipes/the_week_magazine_free.recipe b/resources/recipes/the_week_magazine_free.recipe index 1bac4133e7..6e033eaf82 100644 --- a/resources/recipes/the_week_magazine_free.recipe +++ b/resources/recipes/the_week_magazine_free.recipe @@ -1,17 +1,19 @@ - __license__ = 'GPL v3' -__copyright__ = '2010, Darko Miletic ' +__copyright__ = '2010, JOlo' ''' www.theweek.com ''' from calibre.web.feeds.news import BasicNewsRecipe +import re -class TheWeekFree(BasicNewsRecipe): - title = 'The Week Magazine - Free content' - __author__ = 'Darko Miletic' +class TheWeek(BasicNewsRecipe): + title = 'The Week Magazine' + __author__ = 'Jim Olo' description = "The best of the US and international media. Daily coverage of commentary and analysis of the day's events, as well as arts, entertainment, people and gossip, and political cartoons." publisher = 'The Week Publications, Inc.' + masthead_url = 'http://test.theweek.com/images/logo_theweek.gif' + cover_url = masthead_url category = 'news, politics, USA' oldest_article = 7 max_articles_per_feed = 100 @@ -19,31 +21,27 @@ class TheWeekFree(BasicNewsRecipe): encoding = 'utf-8' use_embedded_content = False language = 'en' + preprocess_regexps = [(re.compile(r'

', re.DOTALL), lambda match: '')] + remove_tags_before = dict(name='h1') + remove_tags_after = dict(name='div', attrs={'class':'articleSubscribe4free'}) + remove_tags = [ + dict(name='div', attrs={'class':['floatLeft','imageCaption','slideshowImageAttribution','postDate','utilities','cartoonInfo','left','middle','col300','articleSubscribe4free',' articleFlyout','articleFlyout floatRight','fourFreeBar']}) + ,dict(name='div', attrs={'id':['cartoonThumbs','rightColumn','header','partners']}) + ,dict(name='ul', attrs={'class':['slideshowNav','hotTopicsList topicList']}) + ] + remove_attributes = ['width','height', 'style', 'font', 'color'] + extra_css = ''' + h1{font-family:Geneva, Arial, Helvetica, sans-serif;color:#154B7A;} + h3{font-size: 14px;color:#999999; font-family:Geneva, Arial, Helvetica, sans-serif;font-weight: bold;} + h2{color:#666666; font-family:Geneva, Arial, Helvetica, sans-serif;font-size:small;} + p {font-family:Arial,Helvetica,sans-serif;} + ''' + filter_regexps = [r'www\.palmcoastdata\.com'] - conversion_options = { - 'comment' : description - , 'tags' : category - , 'publisher' : publisher - , 'language' : language - } - - keep_only_tags = [ - dict(name=['h1','h2']) - , dict(name='div', attrs={'class':'basefont'}) - , dict(name='div', attrs={'id':'slideshowLoader'}) - ] - - remove_tags = [ - dict(name='div', attrs={'id':['digg_dugg','articleRight','dateHeader']}) - ,dict(name=['object','embed','iframe']) - ] - - - feeds = [ - (u'News & Opinions' , u'http://www.theweek.com/section/index/news_opinion.rss') - ,(u'Arts & Leisure' , u'http://www.theweek.com/section/index/arts_leisure.rss') - ,(u'Business' , u'http://www.theweek.com/section/index/business.rss' ) - ,(u'Cartoon & Short takes' , u'http://www.theweek.com/section/index/cartoons_wit.rss') - ] - + feeds = [ + (u'News-Opinion', u'http://theweek.com/section/index/news_opinion.rss'), + (u'Business', u'http://theweek.com/section/index/business.rss'), + (u'Arts-Life', u'http://theweek.com/section/index/arts_life.rss'), + (u'Cartoons', u'http://theweek.com/section/index/cartoon_wit/0/all-cartoons.rss') + ] diff --git a/resources/recipes/wired.recipe b/resources/recipes/wired.recipe index 9599d54de9..bb9a97f5c4 100644 --- a/resources/recipes/wired.recipe +++ b/resources/recipes/wired.recipe @@ -38,12 +38,12 @@ class Wired(BasicNewsRecipe): keep_only_tags = [dict(name='div', attrs={'class':'post'})] remove_tags_after = dict(name='div', attrs={'class':'tweetmeme_button'}) remove_tags = [ - dict(name=['object','embed','iframe','link']) + dict(name=['object','embed','iframe','link','meta','base']) ,dict(name='div', attrs={'class':['podcast_storyboard','tweetmeme_button']}) ,dict(attrs={'id':'ff_bottom_nav'}) ,dict(name='a',attrs={'href':'http://www.wired.com/app'}) ] - remove_attributes = ['height','width'] + remove_attributes = ['height','width','lang','border','clear'] def parse_index(self): @@ -78,7 +78,9 @@ class Wired(BasicNewsRecipe): divurl = item.find('div',attrs={'class':'feature-header'}) if divurl: divdesc = item.find('div',attrs={'class':'feature-text'}) - url = 'http://www.wired.com' + divurl.a['href'] + url = divurl.a['href'] + if not divurl.a['href'].startswith('http://www.wired.com'): + url = 'http://www.wired.com' + divurl.a['href'] title = self.tag_to_string(divurl.a) description = self.tag_to_string(divdesc) date = strftime(self.timefmt) @@ -127,5 +129,17 @@ class Wired(BasicNewsRecipe): def preprocess_html(self, soup): for item in soup.findAll(style=True): del item['style'] + for item in soup.findAll('a'): + if item.string is not None: + tstr = item.string + item.replaceWith(tstr) + else: + item.name='span' + for atrs in ['href','target','alt','title','name','id']: + if item.has_key(atrs): + del item[atrs] + for item in soup.findAll('img'): + if not item.has_key('alt'): + item['alt'] = 'image' return soup diff --git a/setup/installer/osx/app/main.py b/setup/installer/osx/app/main.py index 140f58a6b7..209ea14955 100644 --- a/setup/installer/osx/app/main.py +++ b/setup/installer/osx/app/main.py @@ -612,8 +612,13 @@ class Py2App(object): dmg = os.path.join(destdir, volname+'.dmg') if os.path.exists(dmg): os.unlink(dmg) - subprocess.check_call(['/usr/bin/hdiutil', 'create', '-srcfolder', os.path.abspath(d), + tdir = tempfile.mkdtemp() + shutil.copytree(d, os.path.join(tdir, os.path.basename(d)), + symlinks=True) + os.symlink('/Applications', os.path.join(tdir, 'Applications')) + subprocess.check_call(['/usr/bin/hdiutil', 'create', '-srcfolder', tdir, '-volname', volname, '-format', format, dmg]) + shutil.rmtree(tdir) if internet_enable: subprocess.check_call(['/usr/bin/hdiutil', 'internet-enable', '-yes', dmg]) size = os.stat(dmg).st_size/(1024*1024.) diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index a76cb71acd..770d405203 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -307,6 +307,14 @@ class CatalogPlugin(Plugin): # {{{ #: cli_options parsed in library.cli:catalog_option_parser() cli_options = [] + def _field_sorter(self, key): + ''' + Custom fields sort after standard fields + ''' + if key.startswith('#'): + return '~%s' % key[1:] + else: + return key def search_sort_db(self, db, opts): @@ -315,18 +323,18 @@ class CatalogPlugin(Plugin): # {{{ if opts.sort_by: # 2nd arg = ascending db.sort(opts.sort_by, True) - return db.get_data_as_dict(ids=opts.ids) - def get_output_fields(self, opts): + def get_output_fields(self, db, opts): # Return a list of requested fields, with opts.sort_by first - all_fields = set( + all_std_fields = set( ['author_sort','authors','comments','cover','formats', 'id','isbn','ondevice','pubdate','publisher','rating', 'series_index','series','size','tags','timestamp', 'title','uuid']) + all_custom_fields = set(db.custom_field_keys()) + all_fields = all_std_fields.union(all_custom_fields) - fields = all_fields if opts.fields != 'all': # Make a list from opts.fields requested_fields = set(opts.fields.split(',')) @@ -337,7 +345,7 @@ class CatalogPlugin(Plugin): # {{{ if not opts.connected_device['is_device_connected'] and 'ondevice' in fields: fields.pop(int(fields.index('ondevice'))) - fields.sort() + fields = sorted(fields, key=self._field_sorter) if opts.sort_by and opts.sort_by in fields: fields.insert(0,fields.pop(int(fields.index(opts.sort_by)))) return fields diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index aea0e340c4..d0f986209c 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -478,7 +478,7 @@ from calibre.devices.teclast.driver import TECLAST_K3, NEWSMY, IPAPYRUS, \ from calibre.devices.sne.driver import SNE from calibre.devices.misc import PALMPRE, AVANT, SWEEX, PDNOVEL, KOGAN, \ GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600, LUMIREAD, ALURATEK_COLOR, \ - TREKSTOR, EEEREADER + TREKSTOR, EEEREADER, NEXTBOOK from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG from calibre.devices.kobo.driver import KOBO from calibre.devices.bambook.driver import BAMBOOK @@ -606,6 +606,7 @@ plugins += [ BAMBOOK, TREKSTOR, EEEREADER, + NEXTBOOK, ITUNES, ] plugins += [x for x in list(locals().values()) if isinstance(x, type) and \ diff --git a/src/calibre/customize/profiles.py b/src/calibre/customize/profiles.py index 54c4259678..0c27069df3 100644 --- a/src/calibre/customize/profiles.py +++ b/src/calibre/customize/profiles.py @@ -439,6 +439,13 @@ class TabletOutput(iPadOutput): screen_size = (sys.maxint, sys.maxint) comic_screen_size = (sys.maxint, sys.maxint) +class SamsungGalaxy(TabletOutput): + name = 'Samsung Galaxy' + shortname = 'galaxy' + description = _('Intended for the Samsung Galaxy and similar tablet devices with ' + 'a resolution of 600x1280') + screen_size = comic_screen_size = (600, 1280) + class SonyReaderOutput(OutputProfile): name = 'Sony Reader' @@ -617,6 +624,8 @@ class KindleDXOutput(OutputProfile): #comic_screen_size = (741, 1022) supports_mobi_indexing = True periodical_date_in_title = False + missing_char = u'x\u2009' + empty_ratings_char = u'\u2606' ratings_char = u'\u2605' read_char = u'\u2713' mobi_ems_per_blockquote = 2.0 @@ -707,7 +716,7 @@ class BambookOutput(OutputProfile): output_profiles = [OutputProfile, SonyReaderOutput, SonyReader300Output, SonyReader900Output, MSReaderOutput, MobipocketOutput, HanlinV3Output, HanlinV5Output, CybookG3Output, CybookOpusOutput, KindleOutput, - iPadOutput, KoboReaderOutput, TabletOutput, + iPadOutput, KoboReaderOutput, TabletOutput, SamsungGalaxy, SonyReaderLandscapeOutput, KindleDXOutput, IlliadOutput, IRexDR1000Output, IRexDR800Output, JetBook5Output, NookOutput, BambookOutput, NookColorOutput] diff --git a/src/calibre/devices/android/driver.py b/src/calibre/devices/android/driver.py index 492b00617d..8e9bd4edfb 100644 --- a/src/calibre/devices/android/driver.py +++ b/src/calibre/devices/android/driver.py @@ -27,8 +27,8 @@ class ANDROID(USBMS): 0x040d : { 0x8510 : [0x0001], 0x0851 : [0x1] }, # Motorola - 0x22b8 : { 0x41d9 : [0x216], 0x2d67 : [0x100], 0x41db : [0x216], - 0x4285 : [0x216], 0x42a3 : [0x216] }, + 0x22b8 : { 0x41d9 : [0x216], 0x2d61: [0x100], 0x2d67 : [0x100], + 0x41db : [0x216], 0x4285 : [0x216], 0x42a3 : [0x216] }, # Sony Ericsson 0xfce : { 0xd12e : [0x0100]}, @@ -64,7 +64,8 @@ class ANDROID(USBMS): WINDOWS_MAIN_MEM = ['ANDROID_PHONE', 'A855', 'A853', 'INC.NEXUS_ONE', '__UMS_COMPOSITE', '_MB200', 'MASS_STORAGE', '_-_CARD', 'SGH-I897', 'GT-I9000', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', - 'SCH-I500_CARD', 'SPH-D700_CARD', 'MB810', 'GT-P1000'] + 'SCH-I500_CARD', 'SPH-D700_CARD', 'MB810', 'GT-P1000', 'DESIRE', + 'SGH-T849', '_MB300'] WINDOWS_CARD_A_MEM = ['ANDROID_PHONE', 'GT-I9000_CARD', 'SGH-I897', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', 'GT-P1000_CARD'] diff --git a/src/calibre/devices/bambook/driver.py b/src/calibre/devices/bambook/driver.py index 930c67a159..e7fa66c939 100644 --- a/src/calibre/devices/bambook/driver.py +++ b/src/calibre/devices/bambook/driver.py @@ -29,12 +29,16 @@ class BAMBOOK(DeviceConfig, DevicePlugin): booklist_class = BookList book_class = Book + ip = None + FORMATS = [ "snb" ] VENDOR_ID = 0x230b PRODUCT_ID = 0x0001 BCD = None CAN_SET_METADATA = False THUMBNAIL_HEIGHT = 155 + EXTRA_CUSTOMIZATION_MESSAGE = \ + _("Device IP Address (restart calibre after changing)") icon = I("devices/bambook.png") # OPEN_FEEDBACK_MESSAGE = _( @@ -47,6 +51,10 @@ class BAMBOOK(DeviceConfig, DevicePlugin): METADATA_FILE_GUID = 'calibremetadata.snb' bambook = None + is_connected = False + + def __init__(self, ip): + self.ip = ip def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None) : @@ -60,15 +68,23 @@ class BAMBOOK(DeviceConfig, DevicePlugin): self.eject() # Connect self.bambook = Bambook() - self.bambook.Connect() + self.bambook.Connect(ip = self.ip, timeout = 10000) if self.bambook.GetState() != CONN_CONNECTED: self.bambook = None - raise Exception(_("Unable to connect to Bambook.")) + raise OpenFeedback(_("Unable to connect to Bambook. \n" + "If you are trying to connect via Wi-Fi, " + "please make sure the IP address of Bambook has been correctly configured.")) + self.is_connected = True + return True + + def unmount_device(self): + self.eject() def eject(self): if self.bambook: self.bambook.Disconnect() self.bambook = None + self.is_connected = False def post_yank_cleanup(self): self.eject() @@ -475,3 +491,8 @@ class BAMBOOK(DeviceConfig, DevicePlugin): def get_guid(uuid): guid = hashlib.md5(uuid).hexdigest()[0:15] + ".snb" return guid + +class BAMBOOKWifi(BAMBOOK): + def is_usb_connected(self, devices_on_system, debug=False, + only_presence=False): + return self.is_connected, self diff --git a/src/calibre/devices/bambook/libbambookcore.py b/src/calibre/devices/bambook/libbambookcore.py index a11c5e9e87..35d04ba4ac 100644 --- a/src/calibre/devices/bambook/libbambookcore.py +++ b/src/calibre/devices/bambook/libbambookcore.py @@ -329,6 +329,8 @@ class Bambook: self.handle = None def Connect(self, ip = DEFAULT_BAMBOOK_IP, timeout = 10000): + if ip == None or ip == '': + ip = DEFAULT_BAMBOOK_IP self.handle = BambookConnect(ip, timeout) if self.handle and self.handle != 0: return True diff --git a/src/calibre/devices/eb600/driver.py b/src/calibre/devices/eb600/driver.py index 246b753fa8..3201229699 100644 --- a/src/calibre/devices/eb600/driver.py +++ b/src/calibre/devices/eb600/driver.py @@ -230,7 +230,7 @@ class POCKETBOOK301(USBMS): class POCKETBOOK602(USBMS): name = 'PocketBook Pro 602/902 Device Interface' - description = _('Communicate with the PocketBook 602 reader.') + description = _('Communicate with the PocketBook 602/603/902/903 reader.') author = 'Kovid Goyal' supported_platforms = ['windows', 'osx', 'linux'] FORMATS = ['epub', 'fb2', 'prc', 'mobi', 'pdf', 'djvu', 'rtf', 'chm', @@ -244,7 +244,7 @@ class POCKETBOOK602(USBMS): BCD = [0x0324] VENDOR_NAME = '' - WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = ['PB602', 'PB902'] + WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = ['PB602', 'PB603', 'PB902', 'PB903'] class POCKETBOOK701(USBMS): diff --git a/src/calibre/devices/folder_device/driver.py b/src/calibre/devices/folder_device/driver.py index d2bcf7ce3d..b852715b97 100644 --- a/src/calibre/devices/folder_device/driver.py +++ b/src/calibre/devices/folder_device/driver.py @@ -18,9 +18,9 @@ class FOLDER_DEVICE_FOR_CONFIG(USBMS): supported_platforms = ['windows', 'osx', 'linux'] FORMATS = ['epub', 'fb2', 'mobi', 'azw', 'lrf', 'tcr', 'pmlz', 'lit', 'rtf', 'rb', 'pdf', 'oeb', 'txt', 'pdb', 'prc'] - VENDOR_ID = 0xffff - PRODUCT_ID = 0xffff - BCD = 0xffff + VENDOR_ID = [0xffff] + PRODUCT_ID = [0xffff] + BCD = [0xffff] DEVICE_PLUGBOARD_NAME = 'FOLDER_DEVICE' @@ -34,9 +34,9 @@ class FOLDER_DEVICE(USBMS): supported_platforms = ['windows', 'osx', 'linux'] FORMATS = FOLDER_DEVICE_FOR_CONFIG.FORMATS - VENDOR_ID = 0xffff - PRODUCT_ID = 0xffff - BCD = 0xffff + VENDOR_ID = [0xffff] + PRODUCT_ID = [0xffff] + BCD = [0xffff] DEVICE_PLUGBOARD_NAME = 'FOLDER_DEVICE' THUMBNAIL_HEIGHT = 68 # Height for thumbnails on device diff --git a/src/calibre/devices/iriver/driver.py b/src/calibre/devices/iriver/driver.py index 10945f17cc..0ad540f8a3 100644 --- a/src/calibre/devices/iriver/driver.py +++ b/src/calibre/devices/iriver/driver.py @@ -20,11 +20,11 @@ class IRIVER_STORY(USBMS): FORMATS = ['epub', 'fb2', 'pdf', 'djvu', 'txt'] VENDOR_ID = [0x1006] - PRODUCT_ID = [0x4023, 0x4025] + PRODUCT_ID = [0x4023, 0x4024, 0x4025] BCD = [0x0323] VENDOR_NAME = 'IRIVER' - WINDOWS_MAIN_MEM = ['STORY', 'STORY_EB05'] + WINDOWS_MAIN_MEM = ['STORY', 'STORY_EB05', 'STORY_WI-FI'] WINDOWS_CARD_A_MEM = ['STORY', 'STORY_SD'] #OSX_MAIN_MEM = 'Kindle Internal Storage Media' diff --git a/src/calibre/devices/misc.py b/src/calibre/devices/misc.py index d4776ecca7..2a0fdf6433 100644 --- a/src/calibre/devices/misc.py +++ b/src/calibre/devices/misc.py @@ -264,3 +264,23 @@ class EEEREADER(USBMS): VENDOR_NAME = 'LINUX' WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'FILE-STOR_GADGET' +class NEXTBOOK(USBMS): + + name = 'Nextbook device interface' + gui_name = 'Nextbook' + description = _('Communicate with the Nextbook Reader') + author = 'Kovid Goyal' + supported_platforms = ['windows', 'osx', 'linux'] + + # Ordered list of supported formats + FORMATS = ['epub', 'fb2', 'txt', 'pdf'] + + VENDOR_ID = [0x05e3] + PRODUCT_ID = [0x0726] + BCD = [0x021a] + + EBOOK_DIR_MAIN = '' + + VENDOR_NAME = 'NEXT2' + WINDOWS_MAIN_MEM = '1.0.14' + diff --git a/src/calibre/devices/usbms/books.py b/src/calibre/devices/usbms/books.py index 1e7d74480a..8c92aa8a6e 100644 --- a/src/calibre/devices/usbms/books.py +++ b/src/calibre/devices/usbms/books.py @@ -140,11 +140,19 @@ class CollectionsBookList(BookList): all_by_author = '' all_by_title = '' ca = [] + all_by_something = [] for c in collection_attributes: - if c.startswith('aba:') and c[4:]: + if c.startswith('aba:') and c[4:].strip(): all_by_author = c[4:].strip() - elif c.startswith('abt:') and c[4:]: + elif c.startswith('abt:') and c[4:].strip(): all_by_title = c[4:].strip() + elif c.startswith('abs:') and c[4:].strip(): + name = c[4:].strip() + sby = self.in_category_sort_rules(name) + if sby is None: + sby = name + if name and sby: + all_by_something.append((name, sby)) else: ca.append(c.lower()) collection_attributes = ca @@ -251,6 +259,10 @@ class CollectionsBookList(BookList): if all_by_title not in collections: collections[all_by_title] = {} collections[all_by_title][lpath] = (book, tsval, asval) + for (n, sb) in all_by_something: + if n not in collections: + collections[n] = {} + collections[n][lpath] = (book, book.get(sb, ''), tsval) # Sort collections result = {} diff --git a/src/calibre/ebooks/fb2/input.py b/src/calibre/ebooks/fb2/input.py index 2b08a716cc..1f9a3ffe95 100644 --- a/src/calibre/ebooks/fb2/input.py +++ b/src/calibre/ebooks/fb2/input.py @@ -41,9 +41,12 @@ class FB2Input(InputFormatPlugin): from calibre.ebooks.metadata.opf2 import OPFCreator from calibre.ebooks.metadata.meta import get_metadata from calibre.ebooks.oeb.base import XLINK_NS, XHTML_NS, RECOVER_PARSER + from calibre.ebooks.chardet import xml_to_unicode NAMESPACES = {'f':FB2NS, 'l':XLINK_NS} log.debug('Parsing XML...') raw = stream.read().replace('\0', '') + raw = xml_to_unicode(raw, strip_encoding_pats=True, + assume_utf8=True)[0] try: doc = etree.fromstring(raw) except etree.XMLSyntaxError: diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 22752ca09e..e3fb8092e6 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -159,6 +159,11 @@ class Metadata(object): try: return self.__getattribute__(field) except AttributeError: + if field.startswith('#') and field.endswith('_index'): + try: + return self.get_extra(field[:-6]) + except: + pass return default def get_extra(self, field): diff --git a/src/calibre/ebooks/metadata/fb2.py b/src/calibre/ebooks/metadata/fb2.py index 3636b89df4..2d6192f949 100644 --- a/src/calibre/ebooks/metadata/fb2.py +++ b/src/calibre/ebooks/metadata/fb2.py @@ -9,6 +9,7 @@ import mimetypes, os from base64 import b64decode from lxml import etree from calibre.ebooks.metadata import MetaInformation +from calibre.ebooks.chardet import xml_to_unicode XLINK_NS = 'http://www.w3.org/1999/xlink' def XLINK(name): @@ -23,7 +24,10 @@ def get_metadata(stream): tostring = lambda x : etree.tostring(x, method='text', encoding=unicode).strip() parser = etree.XMLParser(recover=True, no_network=True) - root = etree.fromstring(stream.read(), parser=parser) + raw = stream.read() + raw = xml_to_unicode(raw, strip_encoding_pats=True, + assume_utf8=True)[0] + root = etree.fromstring(raw, parser=parser) authors, author_sort = [], None for au in XPath('//fb2:author')(root): fname = lname = author = None diff --git a/src/calibre/ebooks/metadata/zip.py b/src/calibre/ebooks/metadata/zip.py index b8c260bd1f..30ebe7e2b3 100644 --- a/src/calibre/ebooks/metadata/zip.py +++ b/src/calibre/ebooks/metadata/zip.py @@ -27,7 +27,39 @@ def get_metadata(stream): with TemporaryDirectory() as tdir: with CurrentDir(tdir): path = zf.extract(f) - return get_metadata(open(path, 'rb'), stream_type) + mi = get_metadata(open(path,'rb'), stream_type) + if stream_type == 'opf' and mi.application_id == None: + try: + # zip archive opf files without an application_id were assumed not to have a cover + # reparse the opf and if cover exists read its data from zip archive for the metadata + nmi = zip_opf_metadata(path, zf) + return nmi + except: + pass + return mi raise ValueError('No ebook found in ZIP archive') +def zip_opf_metadata(opfpath, zf): + from calibre.ebooks.metadata.opf2 import OPF + if hasattr(opfpath, 'read'): + f = opfpath + opfpath = getattr(f, 'name', os.getcwd()) + else: + f = open(opfpath, 'rb') + opf = OPF(f, os.path.dirname(opfpath)) + mi = opf.to_book_metadata() + # This is broken, in that it only works for + # when both the OPF file and the cover file are in the root of the + # zip file and the cover is an actual raster image, but I don't care + # enough to make it more robust + if getattr(mi, 'cover', None): + covername = os.path.basename(mi.cover) + mi.cover = None + names = zf.namelist() + if covername in names: + fmt = covername.rpartition('.')[-1] + data = zf.read(covername) + mi.cover_data = (fmt, data) + return mi + diff --git a/src/calibre/ebooks/mobi/reader.py b/src/calibre/ebooks/mobi/reader.py index 02abc51cd3..14e3ed11c3 100644 --- a/src/calibre/ebooks/mobi/reader.py +++ b/src/calibre/ebooks/mobi/reader.py @@ -513,11 +513,14 @@ class MobiReader(object): mobi_version = self.book_header.mobi_version for x in root.xpath('//ncx'): x.getparent().remove(x) + svg_tags = [] for i, tag in enumerate(root.iter(etree.Element)): tag.attrib.pop('xmlns', '') for x in tag.attrib: if ':' in x: del tag.attrib[x] + if tag.tag and barename(tag.tag) == 'svg': + svg_tags.append(tag) if tag.tag and barename(tag.tag.lower()) in \ ('country-region', 'place', 'placetype', 'placename', 'state', 'city', 'street', 'address', 'content', 'form'): @@ -628,6 +631,11 @@ class MobiReader(object): cls = cls + (' ' if cls else '') + ncls attrib['class'] = cls + for tag in svg_tags: + p = tag.getparent() + if hasattr(p, 'remove'): + p.remove(tag) + def create_opf(self, htmlfile, guide=None, root=None): mi = getattr(self.book_header.exth, 'mi', self.embedded_mi) if mi is None: diff --git a/src/calibre/ebooks/pdf/fonts.cpp b/src/calibre/ebooks/pdf/fonts.cpp index 3cd7ef0c5b..99ab7517c1 100644 --- a/src/calibre/ebooks/pdf/fonts.cpp +++ b/src/calibre/ebooks/pdf/fonts.cpp @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ diff --git a/src/calibre/ebooks/pdf/fonts.h b/src/calibre/ebooks/pdf/fonts.h index 55202c9573..1b380e1b87 100644 --- a/src/calibre/ebooks/pdf/fonts.h +++ b/src/calibre/ebooks/pdf/fonts.h @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ diff --git a/src/calibre/ebooks/pdf/images.cpp b/src/calibre/ebooks/pdf/images.cpp index b3b062e1f4..4cd1ace776 100644 --- a/src/calibre/ebooks/pdf/images.cpp +++ b/src/calibre/ebooks/pdf/images.cpp @@ -1,3 +1,10 @@ +/** + * Copyright 2009 Kovid Goyal + * License: GNU GPL v2+ + */ + + + #include #include #include diff --git a/src/calibre/ebooks/pdf/images.h b/src/calibre/ebooks/pdf/images.h index 7d6f143147..1b4d9b58bf 100644 --- a/src/calibre/ebooks/pdf/images.h +++ b/src/calibre/ebooks/pdf/images.h @@ -1,3 +1,10 @@ +/** + * Copyright 2009 Kovid Goyal + * License: GNU GPL v2+ + */ + + + #pragma once #include diff --git a/src/calibre/ebooks/pdf/links.cpp b/src/calibre/ebooks/pdf/links.cpp index 414ff5ce24..8d28492bab 100644 --- a/src/calibre/ebooks/pdf/links.cpp +++ b/src/calibre/ebooks/pdf/links.cpp @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ diff --git a/src/calibre/ebooks/pdf/links.h b/src/calibre/ebooks/pdf/links.h index a8a3127a77..c43911ddca 100644 --- a/src/calibre/ebooks/pdf/links.h +++ b/src/calibre/ebooks/pdf/links.h @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ diff --git a/src/calibre/ebooks/pdf/main.cpp b/src/calibre/ebooks/pdf/main.cpp index 44257b50f5..4e6ec60388 100644 --- a/src/calibre/ebooks/pdf/main.cpp +++ b/src/calibre/ebooks/pdf/main.cpp @@ -1,3 +1,10 @@ +/** + * Copyright 2009 Kovid Goyal + * License: GNU GPL v2+ + */ + + + #ifndef PDF2XML #define UNICODE #define PY_SSIZE_T_CLEAN diff --git a/src/calibre/ebooks/pdf/reflow.cpp b/src/calibre/ebooks/pdf/reflow.cpp index c08d7e5507..0c569fe0d1 100644 --- a/src/calibre/ebooks/pdf/reflow.cpp +++ b/src/calibre/ebooks/pdf/reflow.cpp @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ #include diff --git a/src/calibre/ebooks/pdf/reflow.h b/src/calibre/ebooks/pdf/reflow.h index deb1dec326..ad4b79929d 100644 --- a/src/calibre/ebooks/pdf/reflow.h +++ b/src/calibre/ebooks/pdf/reflow.h @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ * Based on pdftohtml from the poppler project. */ diff --git a/src/calibre/ebooks/pdf/utils.h b/src/calibre/ebooks/pdf/utils.h index 43f435b1e3..4246239ac7 100644 --- a/src/calibre/ebooks/pdf/utils.h +++ b/src/calibre/ebooks/pdf/utils.h @@ -1,6 +1,6 @@ /** * Copyright 2009 Kovid Goyal - * License: GNU GPL v3 + * License: GNU GPL v2+ */ diff --git a/src/calibre/ebooks/pdf/writer.py b/src/calibre/ebooks/pdf/writer.py index 7af0ed05c9..4ff10290c9 100644 --- a/src/calibre/ebooks/pdf/writer.py +++ b/src/calibre/ebooks/pdf/writer.py @@ -59,7 +59,13 @@ def get_pdf_printer(opts, for_comic=False): dpi = opts.output_profile.dpi printer.setPaperSize(QSizeF(float(w) / dpi, float(h) / dpi), QPrinter.Inch) - printer.setPageMargins(opts.margin_left, opts.margin_top, opts.margin_right, opts.margin_bottom, QPrinter.Point) + if for_comic: + # Comic pages typically have their own margins, or their background + # color is not white, in which case the margin looks bad + printer.setPageMargins(0, 0, 0, 0, QPrinter.Point) + else: + printer.setPageMargins(opts.margin_left, opts.margin_top, + opts.margin_right, opts.margin_bottom, QPrinter.Point) printer.setOrientation(orientation(opts.orientation)) printer.setOutputFormat(QPrinter.PdfFormat) printer.setFullPage(True) diff --git a/src/calibre/ebooks/rtf2xml/field_strings.py b/src/calibre/ebooks/rtf2xml/field_strings.py index eec9509f34..d8b665fd26 100755 --- a/src/calibre/ebooks/rtf2xml/field_strings.py +++ b/src/calibre/ebooks/rtf2xml/field_strings.py @@ -402,7 +402,7 @@ class FieldStrings: Logic: self.__link_switch = re.compile(r'\\l\s{1,}(.*?)\s') """ - self.__link_switch = re.compile(r'\\l\s{1,}(.*?)\s') + self.__link_switch = re.compile(r'\\l\s{1,}"{0,1}(.*?)"{0,1}\s') the_string = name match_group = re.search(self.__link_switch, line) if match_group: diff --git a/src/calibre/ebooks/snb/input.py b/src/calibre/ebooks/snb/input.py index 659ca79619..d2acb257aa 100755 --- a/src/calibre/ebooks/snb/input.py +++ b/src/calibre/ebooks/snb/input.py @@ -46,14 +46,27 @@ class SNBInput(InputFormatPlugin): 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) + l = { 'title' : './/head/name', + 'creator' : './/head/author', + 'language' : './/head/language', + 'generator': './/head/generator', + 'publisher': './/head/publisher', + 'cover' : './/head/cover', } + d = {} + for item in l: + node = meta.find(l[item]) + if node != None: + d[item] = node.text if node.text != None else '' + else: + d[item] = '' + + oeb.metadata.add('title', d['title']) + oeb.metadata.add('creator', d['creator'], attrib={'role':'aut'}) + oeb.metadata.add('language', d['language'].lower().replace('_', '-')) + oeb.metadata.add('generator', d['generator']) + oeb.metadata.add('publisher', d['publisher']) + if d['cover'] != '': + oeb.guide.add('cover', 'Cover', d['cover']) bookid = str(uuid.uuid4()) oeb.metadata.add('identifier', bookid, id='uuid_id', scheme='uuid') diff --git a/src/calibre/gui2/actions/catalog.py b/src/calibre/gui2/actions/catalog.py index a253664a1e..0eba0406a1 100644 --- a/src/calibre/gui2/actions/catalog.py +++ b/src/calibre/gui2/actions/catalog.py @@ -57,7 +57,7 @@ class GenerateCatalogAction(InterfaceAction): if job.result: # Search terms nulled catalog results return error_dialog(self.gui, _('No books found'), - _("No books to catalog\nCheck exclude tags"), + _("No books to catalog\nCheck exclusion criteria"), show=True) if job.failed: return self.gui.job_exception(job) diff --git a/src/calibre/gui2/actions/device.py b/src/calibre/gui2/actions/device.py index 744ab20d10..fb3e627789 100644 --- a/src/calibre/gui2/actions/device.py +++ b/src/calibre/gui2/actions/device.py @@ -12,11 +12,15 @@ from PyQt4.Qt import QToolButton, QMenu, pyqtSignal, QIcon from calibre.gui2.actions import InterfaceAction from calibre.utils.smtp import config as email_config from calibre.constants import iswindows, isosx +from calibre.customize.ui import is_disabled +from calibre.devices.bambook.driver import BAMBOOK class ShareConnMenu(QMenu): # {{{ connect_to_folder = pyqtSignal() connect_to_itunes = pyqtSignal() + connect_to_bambook = pyqtSignal() + config_email = pyqtSignal() toggle_server = pyqtSignal() dont_add_to = frozenset(['toolbar-device', 'context-menu-device']) @@ -34,6 +38,17 @@ class ShareConnMenu(QMenu): # {{{ self.connect_to_itunes_action = mitem if not (iswindows or isosx): mitem.setVisible(False) + mitem = self.addAction(QIcon(I('devices/bambook.png')), _('Connect to Bambook')) + mitem.setEnabled(True) + mitem.triggered.connect(lambda x : self.connect_to_bambook.emit()) + self.connect_to_bambook_action = mitem + bambook_visible = False + if not is_disabled(BAMBOOK): + device_ip = BAMBOOK.settings().extra_customization + if device_ip: + bambook_visible = True + self.connect_to_bambook_action.setVisible(bambook_visible) + self.addSeparator() self.toggle_server_action = \ self.addAction(QIcon(I('network-server.png')), @@ -88,6 +103,7 @@ class ShareConnMenu(QMenu): # {{{ def set_state(self, device_connected): self.connect_to_folder_action.setEnabled(not device_connected) self.connect_to_itunes_action.setEnabled(not device_connected) + self.connect_to_bambook_action.setEnabled(not device_connected) # }}} @@ -126,6 +142,7 @@ class ConnectShareAction(InterfaceAction): self.qaction.setMenu(self.share_conn_menu) self.share_conn_menu.connect_to_folder.connect(self.gui.connect_to_folder) self.share_conn_menu.connect_to_itunes.connect(self.gui.connect_to_itunes) + self.share_conn_menu.connect_to_bambook.connect(self.gui.connect_to_bambook) def location_selected(self, loc): enabled = loc == 'library' diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index dd12080d7f..8e3e8b10de 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -8,7 +8,7 @@ __docformat__ = 'restructuredtext en' import os, collections, sys from Queue import Queue -from PyQt4.Qt import QPixmap, QSize, QWidget, Qt, pyqtSignal, \ +from PyQt4.Qt import QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, \ QPropertyAnimation, QEasingCurve, QThread, QApplication, QFontInfo, \ QSizePolicy, QPainter, QRect, pyqtProperty, QLayout, QPalette from PyQt4.QtWebKit import QWebView @@ -18,7 +18,7 @@ from calibre.gui2.widgets import IMAGE_EXTENSIONS from calibre.ebooks import BOOK_EXTENSIONS from calibre.constants import preferred_encoding from calibre.library.comments import comments_to_html -from calibre.gui2 import config, open_local_file +from calibre.gui2 import config, open_local_file, open_url from calibre.utils.icu import sort_key # render_rows(data) {{{ @@ -412,6 +412,12 @@ class BookDetails(QWidget): # {{{ self.view_specific_format.emit(int(id_), fmt) elif typ == 'devpath': open_local_file(val) + else: + try: + open_url(QUrl(link, QUrl.TolerantMode)) + except: + import traceback + traceback.print_exc() def mouseDoubleClickEvent(self, ev): diff --git a/src/calibre/gui2/catalog/catalog_csv_xml.py b/src/calibre/gui2/catalog/catalog_csv_xml.py index 077d4cbbca..18f2c210dc 100644 --- a/src/calibre/gui2/catalog/catalog_csv_xml.py +++ b/src/calibre/gui2/catalog/catalog_csv_xml.py @@ -6,9 +6,9 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' - from calibre.gui2 import gprefs from calibre.gui2.catalog.catalog_csv_xml_ui import Ui_Form +from calibre.library import db as db_ from PyQt4.Qt import QWidget, QListWidgetItem class PluginWidget(QWidget, Ui_Form): @@ -28,6 +28,12 @@ class PluginWidget(QWidget, Ui_Form): self.all_fields.append(x) QListWidgetItem(x, self.db_fields) + db = db_() + for x in sorted(db.custom_field_keys()): + self.all_fields.append(x) + QListWidgetItem(x, self.db_fields) + + def initialize(self, name, db): self.name = name fields = gprefs.get(name+'_db_fields', self.all_fields) diff --git a/src/calibre/gui2/catalog/catalog_epub_mobi.py b/src/calibre/gui2/catalog/catalog_epub_mobi.py index 1ae4efd014..7a35fdb3c2 100644 --- a/src/calibre/gui2/catalog/catalog_epub_mobi.py +++ b/src/calibre/gui2/catalog/catalog_epub_mobi.py @@ -17,18 +17,55 @@ class PluginWidget(QWidget,Ui_Form): TITLE = _('E-book options') HELP = _('Options specific to')+' EPUB/MOBI '+_('output') - OPTION_FIELDS = [('exclude_genre','\[.+\]'), - ('exclude_tags','~,'+_('Catalog')), - ('generate_titles', True), - ('generate_series', True), - ('generate_recently_added', True), - ('note_tag','*'), - ('numbers_as_text', False), - ('read_pattern','+'), - ('read_source_field_cb','Tag'), - ('wishlist_tag','Wishlist'), - ] + CheckBoxControls = [ + 'generate_titles', + 'generate_series', + 'generate_genres', + 'generate_recently_added', + 'generate_descriptions', + 'include_hr' + ] + ComboBoxControls = [ + 'read_source_field', + 'exclude_source_field', + 'header_note_source_field', + 'merge_source_field' + ] + LineEditControls = [ + 'exclude_genre', + 'exclude_pattern', + 'exclude_tags', + 'read_pattern', + 'wishlist_tag' + ] + RadioButtonControls = [ + 'merge_before', + 'merge_after' + ] + SpinBoxControls = [ + 'thumb_width' + ] + + OPTION_FIELDS = zip(CheckBoxControls, + [True for i in CheckBoxControls], + ['check_box' for i in CheckBoxControls]) + OPTION_FIELDS += zip(ComboBoxControls, + [None for i in ComboBoxControls], + ['combo_box' for i in ComboBoxControls]) + OPTION_FIELDS += zip(RadioButtonControls, + [None for i in RadioButtonControls], + ['radio_button' for i in RadioButtonControls]) + + # LineEditControls + OPTION_FIELDS += zip(['exclude_genre'],['\[.+\]'],['line_edit']) + OPTION_FIELDS += zip(['exclude_pattern'],[None],['line_edit']) + OPTION_FIELDS += zip(['exclude_tags'],['~,'+_('Catalog')],['line_edit']) + OPTION_FIELDS += zip(['read_pattern'],['+'],['line_edit']) + OPTION_FIELDS += zip(['wishlist_tag'],['Wishlist'],['line_edit']) + + # SpinBoxControls + OPTION_FIELDS += zip(['thumb_width'],[1.00],['spin_box']) # Output synced to the connected device? sync_enabled = True @@ -42,105 +79,203 @@ class PluginWidget(QWidget,Ui_Form): def initialize(self, name, db): self.name = name - - # Populate the 'Read book' source fields - all_custom_fields = db.custom_field_keys() - custom_fields = {} - custom_fields['Tag'] = {'field':'tag', 'datatype':u'text'} - for custom_field in all_custom_fields: - field_md = db.metadata_for_field(custom_field) - if field_md['datatype'] in ['bool','composite','datetime','text']: - custom_fields[field_md['name']] = {'field':custom_field, - 'datatype':field_md['datatype']} - - # Add the sorted eligible fields to the combo box - for cf in sorted(custom_fields): - self.read_source_field_cb.addItem(cf) - - self.read_source_fields = custom_fields - self.read_source_field_cb.currentIndexChanged.connect(self.read_source_field_changed) + self.db = db + self.populateComboBoxes() # Update dialog fields from stored options for opt in self.OPTION_FIELDS: - opt_value = gprefs.get(self.name + '_' + opt[0], opt[1]) - if opt[0] in [ - 'generate_recently_added', - 'generate_series', - 'generate_titles', - 'numbers_as_text', - ]: - getattr(self, opt[0]).setChecked(opt_value) + c_name, c_def, c_type = opt + opt_value = gprefs.get(self.name + '_' + c_name, c_def) + if c_type in ['check_box']: + getattr(self, c_name).setChecked(eval(str(opt_value))) + elif c_type in ['combo_box'] and opt_value is not None: + # *** Test this code with combo boxes *** + #index = self.read_source_field.findText(opt_value) + index = getattr(self,c_name).findText(opt_value) + if index == -1 and c_name == 'read_source_field': + index = self.read_source_field.findText('Tag') + #self.read_source_field.setCurrentIndex(index) + getattr(self,c_name).setCurrentIndex(index) + elif c_type in ['line_edit']: + getattr(self, c_name).setText(opt_value if opt_value else '') + elif c_type in ['radio_button'] and opt_value is not None: + getattr(self, c_name).setChecked(opt_value) + elif c_type in ['spin_box']: + getattr(self, c_name).setValue(float(opt_value)) - # Combo box - elif opt[0] in ['read_source_field_cb']: - # Look for last-stored combo box value - index = self.read_source_field_cb.findText(opt_value) - if index == -1: - index = self.read_source_field_cb.findText('Tag') - self.read_source_field_cb.setCurrentIndex(index) - - # Text fields - else: - getattr(self, opt[0]).setText(opt_value) - - # Init self.read_source_field - cs = unicode(self.read_source_field_cb.currentText()) + # Init self.read_source_field_name + cs = unicode(self.read_source_field.currentText()) read_source_spec = self.read_source_fields[cs] - self.read_source_field = read_source_spec['field'] + self.read_source_field_name = read_source_spec['field'] + + # Init self.exclude_source_field_name + self.exclude_source_field_name = '' + cs = unicode(self.exclude_source_field.currentText()) + if cs > '': + exclude_source_spec = self.exclude_source_fields[cs] + self.exclude_source_field_name = exclude_source_spec['field'] + + # Init self.merge_source_field_name + self.merge_source_field_name = '' + cs = unicode(self.merge_source_field.currentText()) + if cs > '': + merge_source_spec = self.merge_source_fields[cs] + self.merge_source_field_name = merge_source_spec['field'] + + # Init self.header_note_source_field_name + self.header_note_source_field_name = '' + cs = unicode(self.header_note_source_field.currentText()) + if cs > '': + header_note_source_spec = self.header_note_source_fields[cs] + self.header_note_source_field_name = header_note_source_spec['field'] + + # Hook changes to thumb_width + self.thumb_width.valueChanged.connect(self.thumb_width_changed) def options(self): # Save/return the current options # exclude_genre stores literally # generate_titles, generate_recently_added, numbers_as_text stores as True/False # others store as lists + opts_dict = {} + # Save values to gprefs for opt in self.OPTION_FIELDS: - # Save values to gprefs - if opt[0] in [ - 'generate_recently_added', - 'generate_series', - 'generate_titles', - 'numbers_as_text', - ]: - opt_value = getattr(self,opt[0]).isChecked() + c_name, c_def, c_type = opt + if c_type in ['check_box', 'radio_button']: + opt_value = getattr(self, c_name).isChecked() + elif c_type in ['combo_box']: + opt_value = unicode(getattr(self,c_name).currentText()).strip() + elif c_type in ['line_edit']: + opt_value = unicode(getattr(self, c_name).text()).strip() + elif c_type in ['spin_box']: + opt_value = unicode(getattr(self, c_name).value()) + gprefs.set(self.name + '_' + c_name, opt_value) - # Combo box uses .currentText() - elif opt[0] in ['read_source_field_cb']: - opt_value = unicode(getattr(self, opt[0]).currentText()) - - # text fields use .text() + # Construct opts object + if c_name == 'exclude_tags': + # store as list + opts_dict[c_name] = opt_value.split(',') else: - opt_value = unicode(getattr(self, opt[0]).text()) - gprefs.set(self.name + '_' + opt[0], opt_value) + opts_dict[c_name] = opt_value - # Construct opts - if opt[0] in [ - 'exclude_genre', - 'generate_recently_added', - 'generate_series', - 'generate_titles', - 'numbers_as_text', - ]: - opts_dict[opt[0]] = opt_value - else: - opts_dict[opt[0]] = opt_value.split(',') + # Generate markers for hybrids + opts_dict['read_book_marker'] = "%s:%s" % (self.read_source_field_name, + self.read_pattern.text()) + opts_dict['exclude_book_marker'] = "%s:%s" % (self.exclude_source_field_name, + self.exclude_pattern.text()) - # Generate read_book_marker - opts_dict['read_book_marker'] = "%s:%s" % (self.read_source_field, self.read_pattern.text()) + # Generate specs for merge_comments, header_note_source_field + checked = '' + if self.merge_before.isChecked(): + checked = 'before' + elif self.merge_after.isChecked(): + checked = 'after' + include_hr = self.include_hr.isChecked() + opts_dict['merge_comments'] = "%s:%s:%s" % \ + (self.merge_source_field_name, checked, include_hr) + + opts_dict['header_note_source_field'] = self.header_note_source_field_name # Append the output profile opts_dict['output_profile'] = [load_defaults('page_setup')['output_profile']] + if False: + print "opts_dict" + for opt in sorted(opts_dict.keys()): + print " %s: %s" % (opt, repr(opts_dict[opt])) return opts_dict + def populateComboBoxes(self): + # Custom column types declared in + # gui2.preferences.create_custom_column:CreateCustomColumn() + # As of 0.7.34: + # bool Yes/No + # comments Long text, like comments, not shown in tag browser + # composite Column built from other columns + # datetime Date + # enumeration Text, but with a fixed set of permitted values + # float Floating point numbers + # int Integers + # rating Ratings, shown with stars + # series Text column for keeping series-like information + # text Column shown in the tag browser + # *text Comma-separated text, like tags, shown in tag browser + + all_custom_fields = self.db.custom_field_keys() + # Populate the 'Read book' hybrid + custom_fields = {} + custom_fields['Tag'] = {'field':'tag', 'datatype':u'text'} + for custom_field in all_custom_fields: + field_md = self.db.metadata_for_field(custom_field) + if field_md['datatype'] in ['bool','composite','datetime','enumeration','text']: + custom_fields[field_md['name']] = {'field':custom_field, + 'datatype':field_md['datatype']} + # Add the sorted eligible fields to the combo box + for cf in sorted(custom_fields): + self.read_source_field.addItem(cf) + self.read_source_fields = custom_fields + self.read_source_field.currentIndexChanged.connect(self.read_source_field_changed) + + + # Populate the 'Excluded books' hybrid + custom_fields = {} + for custom_field in all_custom_fields: + field_md = self.db.metadata_for_field(custom_field) + if field_md['datatype'] in ['bool','composite','datetime','enumeration','text']: + custom_fields[field_md['name']] = {'field':custom_field, + 'datatype':field_md['datatype']} + # Blank field first + self.exclude_source_field.addItem('') + # Add the sorted eligible fields to the combo box + for cf in sorted(custom_fields): + self.exclude_source_field.addItem(cf) + self.exclude_source_fields = custom_fields + self.exclude_source_field.currentIndexChanged.connect(self.exclude_source_field_changed) + + + # Populate the 'Header note' combo box + custom_fields = {} + for custom_field in all_custom_fields: + field_md = self.db.metadata_for_field(custom_field) + if field_md['datatype'] in ['bool','composite','datetime','enumeration','text']: + custom_fields[field_md['name']] = {'field':custom_field, + 'datatype':field_md['datatype']} + # Blank field first + self.header_note_source_field.addItem('') + # Add the sorted eligible fields to the combo box + for cf in sorted(custom_fields): + self.header_note_source_field.addItem(cf) + self.header_note_source_fields = custom_fields + self.header_note_source_field.currentIndexChanged.connect(self.header_note_source_field_changed) + + + # Populate the 'Merge with Comments' combo box + custom_fields = {} + for custom_field in all_custom_fields: + field_md = self.db.metadata_for_field(custom_field) + if field_md['datatype'] in ['text','comments']: + custom_fields[field_md['name']] = {'field':custom_field, + 'datatype':field_md['datatype']} + # Blank field first + self.merge_source_field.addItem('') + # Add the sorted eligible fields to the combo box + for cf in sorted(custom_fields): + self.merge_source_field.addItem(cf) + self.merge_source_fields = custom_fields + self.merge_source_field.currentIndexChanged.connect(self.merge_source_field_changed) + self.merge_before.setEnabled(False) + self.merge_after.setEnabled(False) + self.include_hr.setEnabled(False) + def read_source_field_changed(self,new_index): ''' Process changes in the read_source_field combo box Currently using QLineEdit for all field types Possible to modify to switch QWidget type ''' - new_source = str(self.read_source_field_cb.currentText()) + new_source = str(self.read_source_field.currentText()) read_source_spec = self.read_source_fields[str(new_source)] - self.read_source_field = read_source_spec['field'] + self.read_source_field_name = read_source_spec['field'] # Change pattern input widget to match the source field datatype if read_source_spec['datatype'] in ['bool','composite','datetime','text']: @@ -152,3 +287,63 @@ class PluginWidget(QWidget,Ui_Form): self.read_pattern = dw self.read_spec_hl.addWidget(dw) + def exclude_source_field_changed(self,new_index): + ''' + Process changes in the exclude_source_field combo box + Currently using QLineEdit for all field types + Possible to modify to switch QWidget type + ''' + new_source = str(self.exclude_source_field.currentText()) + self.exclude_source_field_name = new_source + if new_source > '': + exclude_source_spec = self.exclude_source_fields[str(new_source)] + self.exclude_source_field_name = exclude_source_spec['field'] + self.exclude_pattern.setEnabled(True) + + # Change pattern input widget to match the source field datatype + if exclude_source_spec['datatype'] in ['bool','composite','datetime','text']: + if not isinstance(self.exclude_pattern, QLineEdit): + self.exclude_spec_hl.removeWidget(self.exclude_pattern) + dw = QLineEdit(self) + dw.setObjectName('exclude_pattern') + dw.setToolTip('Exclusion pattern') + self.exclude_pattern = dw + self.exclude_spec_hl.addWidget(dw) + else: + self.exclude_pattern.setEnabled(False) + + def header_note_source_field_changed(self,new_index): + ''' + Process changes in the header_note_source_field combo box + ''' + new_source = str(self.header_note_source_field.currentText()) + self.header_note_source_field_name = new_source + if new_source > '': + header_note_source_spec = self.header_note_source_fields[str(new_source)] + self.header_note_source_field_name = header_note_source_spec['field'] + + def merge_source_field_changed(self,new_index): + ''' + Process changes in the header_note_source_field combo box + ''' + new_source = str(self.merge_source_field.currentText()) + self.merge_source_field_name = new_source + if new_source > '': + merge_source_spec = self.merge_source_fields[str(new_source)] + self.merge_source_field_name = merge_source_spec['field'] + if not self.merge_before.isChecked() and not self.merge_after.isChecked(): + self.merge_after.setChecked(True) + self.merge_before.setEnabled(True) + self.merge_after.setEnabled(True) + self.include_hr.setEnabled(True) + + else: + self.merge_before.setEnabled(False) + self.merge_after.setEnabled(False) + self.include_hr.setEnabled(False) + + def thumb_width_changed(self,new_value): + ''' + Process changes in the thumb_width spin box + ''' + pass diff --git a/src/calibre/gui2/catalog/catalog_epub_mobi.ui b/src/calibre/gui2/catalog/catalog_epub_mobi.ui index d72566f581..7a2a86c690 100644 --- a/src/calibre/gui2/catalog/catalog_epub_mobi.ui +++ b/src/calibre/gui2/catalog/catalog_epub_mobi.ui @@ -6,163 +6,681 @@ 0 0 - 627 - 549 + 650 + 582 + + + 0 + 0 + + Form - - - - - 'Don't include this book' tag: + + + + + + 0 + 0 + - - - - - - - - - - - - - Additional note tag prefix: - - - - - - - - - - - - - - - - - - - - - Regex pattern describing tags to exclude as genres: - - - Qt::LogText - - - true - - - - - - - Regex tips: -- The default regex - \[.+\] - excludes genre tags of the form [tag], e.g., [Amazon Freebie] -- A regex pattern of a single dot excludes all genre tags, generating no Genre Section - - - true - - - - - - - Qt::Vertical - - + - 20 - 40 + 0 + 0 - - - - - - Include 'Titles' Section + + Sections to include in catalog. All catalogs include 'Books by Author'. + + Included sections + + + + + + Books by &Title + + + + + + + Books by &Series + + + + + + + Recently &Added + + + + + + + Books by &Genre + + + + + + + &Descriptions + + + + - - - - Include 'Recently Added' Section + + + + + 0 + 0 + + + + 0 + 0 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Lucida Grande'; font-size:13pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Default pattern </p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Courier New,courier';">\[.+\]</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">excludes tags of the form [<span style=" font-family:'Courier New,courier';">tag</span>], </p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">e.g., [Project Gutenberg]</p></body></html> + + + Excluded genres + + + + QFormLayout::FieldsStayAtSizeHint + + + + + -1 + + + 0 + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + Tags to &exclude + + + Qt::AutoText + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + exclude_genre + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + + + - - - - Sort numbers as text + + + + + 0 + 0 + + + + 0 + 0 + + + + Books matching either pattern will not be included in generated catalog. + + + Excluded books + + + + + + + + + 0 + 0 + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + Tags to &exclude + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + exclude_tags + + + + + + + + 0 + 0 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Lucida Grande'; font-size:13pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt;">Comma-separated list of tags to exclude.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt;">Default:</span><span style=" font-family:'Courier New,courier'; font-size:12pt;"> ~,Catalog</span></p></body></html> + + + + + + + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + &Column/value + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + exclude_source_field + + + + + + + + 0 + 0 + + + + Column containing additional exclusion criteria + + + QComboBox::AdjustToMinimumContentsLengthWithIcon + + + 18 + + + + + + + + 150 + 0 + + + + Exclusion pattern + + + + + + - - - - Include 'Series' Section + + + + + 0 + 0 + + + + 0 + 0 + + + + Matching books will be displayed with ✓ + + + Read books + + + + + + QLayout::SetDefaultConstraint + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + &Column/value + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + read_source_field + + + + + + + + 0 + 0 + + + + Column containing 'read' status + + + + + + QComboBox::AdjustToMinimumContentsLengthWithIcon + + + 18 + + + + + + + + 150 + 0 + + + + 'read book' pattern + + + + + + + + + - - - - - - - Wishlist tag: + + + + + 0 + 0 + - - - - - - QLayout::SetMinimumSize + + + 0 + 0 + - - - - - 0 - 0 - - - - Source column for read book - - - - - - - - - - Pattern for read book - - - - - - - - - - - - Books marked as read: + + Other options + + + QFormLayout::FieldsStayAtSizeHint + + + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + + + + &Wishlist tag + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + wishlist_tag + + + + + + + Books tagged as Wishlist items will be displayed with ✕ + + + + + + + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + &Thumbnail width + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + thumb_width + + + + + + + + 0 + 0 + + + + Size hint for Description cover thumbnails + + + inch + + + 2 + + + 1.000000000000000 + + + 2.000000000000000 + + + 0.100000000000000 + + + + + + + + + + + + 0 + 0 + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + + + + &Description note + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + header_note_source_field + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Custom column source for note to include in Description header area + + + + + + + + + + + + 175 + 0 + + + + + 200 + 16777215 + + + + &Merge with Comments + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + merge_source_field + + + + + + + + 0 + 0 + + + + Additional content merged with Comments during catalog generation + + + + + + + Qt::Vertical + + + + + + + Merge additional content before Comments + + + &Before + + + + + + + Merge additional content after Comments + + + &After + + + + + + + Qt::Vertical + + + + + + + Separate Comments and additional content with horizontal rule + + + &Separator + + + + + + diff --git a/src/calibre/gui2/catalog/catalog_tab_template.ui b/src/calibre/gui2/catalog/catalog_tab_template.ui index 5df881beac..4b24507f80 100644 --- a/src/calibre/gui2/catalog/catalog_tab_template.ui +++ b/src/calibre/gui2/catalog/catalog_tab_template.ui @@ -6,8 +6,8 @@ 0 0 - 579 - 411 + 650 + 575 diff --git a/src/calibre/gui2/comments_editor.py b/src/calibre/gui2/comments_editor.py index 97a218a10b..72e73efa03 100644 --- a/src/calibre/gui2/comments_editor.py +++ b/src/calibre/gui2/comments_editor.py @@ -259,6 +259,19 @@ class EditorWidget(QWebView): # {{{ return property(fget=fget, fset=fset) + def keyPressEvent(self, ev): + if ev.key() in (Qt.Key_Tab, Qt.Key_Escape, Qt.Key_Backtab): + ev.ignore() + else: + return QWebView.keyPressed(self, ev) + + def keyReleaseEvent(self, ev): + if ev.key() in (Qt.Key_Tab, Qt.Key_Escape, Qt.Key_Backtab): + ev.ignore() + else: + return QWebView.keyReleased(self, ev) + + # }}} # Highlighter {{{ @@ -480,6 +493,9 @@ class Editor(QWidget): # {{{ self.toolbar1 = QToolBar(self) self.toolbar2 = QToolBar(self) self.toolbar3 = QToolBar(self) + for i in range(1, 4): + t = getattr(self, 'toolbar%d'%i) + t.setIconSize(QSize(18, 18)) self.editor = EditorWidget(self) self.tabs = QTabWidget(self) self.tabs.setTabPosition(self.tabs.South) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 3b071aa024..6d289a3e5c 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -24,6 +24,7 @@ from calibre.utils.filenames import ascii_filename from calibre.devices.errors import FreeSpaceError from calibre.devices.apple.driver import ITUNES_ASYNC from calibre.devices.folder_device.driver import FOLDER_DEVICE +from calibre.devices.bambook.driver import BAMBOOK, BAMBOOKWifi from calibre.ebooks.metadata.meta import set_metadata from calibre.constants import DEBUG from calibre.utils.config import prefs, tweaks @@ -635,6 +636,10 @@ class DeviceMixin(object): # {{{ if dir is not None: self.device_manager.mount_device(kls=FOLDER_DEVICE, kind='folder', path=dir) + def connect_to_bambook(self): + self.device_manager.mount_device(kls=BAMBOOKWifi, kind='bambook', + path=BAMBOOK.settings().extra_customization) + def connect_to_itunes(self): self.device_manager.mount_device(kls=ITUNES_ASYNC, kind='itunes', path=None) diff --git a/src/calibre/gui2/dialogs/book_info.py b/src/calibre/gui2/dialogs/book_info.py index 1384c27b8c..eac8461299 100644 --- a/src/calibre/gui2/dialogs/book_info.py +++ b/src/calibre/gui2/dialogs/book_info.py @@ -9,7 +9,7 @@ from PyQt4.Qt import QCoreApplication, SIGNAL, QModelIndex, QTimer, Qt, \ QDialog, QPixmap, QGraphicsScene, QIcon, QSize from calibre.gui2.dialogs.book_info_ui import Ui_BookInfo -from calibre.gui2 import dynamic, open_local_file +from calibre.gui2 import dynamic, open_local_file, open_url from calibre import fit_image from calibre.library.comments import comments_to_html from calibre.utils.icu import sort_key @@ -22,6 +22,8 @@ class BookInfo(QDialog, Ui_BookInfo): self.setupUi(self) self.cover_pixmap = None self.comments.sizeHint = self.comments_size_hint + self.comments.page().setLinkDelegationPolicy(self.comments.page().DelegateAllLinks) + self.comments.linkClicked(self.link_clicked) self.view_func = view_func @@ -41,6 +43,8 @@ class BookInfo(QDialog, Ui_BookInfo): screen_height = desktop.availableGeometry().height() - 100 self.resize(self.size().width(), screen_height) + def link_clicked(self, url): + open_url(url) def comments_size_hint(self): return QSize(350, 250) @@ -115,6 +119,7 @@ class BookInfo(QDialog, Ui_BookInfo): lines = [x if x.strip() else '

' for x in lines] comments = '\n'.join(lines) self.comments.setHtml('
%s
' % comments) + self.comments.page().setLinkDelegationPolicy(self.comments.page().DelegateAllLinks) cdata = info.pop('cover', '') self.cover_pixmap = QPixmap.fromImage(cdata) self.resize_cover() diff --git a/src/calibre/gui2/dialogs/catalog.ui b/src/calibre/gui2/dialogs/catalog.ui index e1de9407ea..62ac7cb5af 100644 --- a/src/calibre/gui2/dialogs/catalog.ui +++ b/src/calibre/gui2/dialogs/catalog.ui @@ -6,8 +6,8 @@ 0 0 - 611 - 514 + 674 + 660
@@ -33,6 +33,18 @@
+ + + 0 + 0 + + + + + 650 + 575 + + 0 diff --git a/src/calibre/gui2/dialogs/tag_editor.py b/src/calibre/gui2/dialogs/tag_editor.py index 48a07c4b9e..6c5aa6de66 100644 --- a/src/calibre/gui2/dialogs/tag_editor.py +++ b/src/calibre/gui2/dialogs/tag_editor.py @@ -4,7 +4,7 @@ from PyQt4.QtCore import SIGNAL, Qt from PyQt4.QtGui import QDialog from calibre.gui2.dialogs.tag_editor_ui import Ui_TagEditor -from calibre.gui2 import question_dialog, error_dialog +from calibre.gui2 import question_dialog, error_dialog, gprefs from calibre.constants import islinux from calibre.utils.icu import sort_key @@ -49,6 +49,10 @@ class TagEditor(QDialog, Ui_TagEditor): self.connect(self.available_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.apply_tags) self.connect(self.applied_tags, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) + geom = gprefs.get('tag_editor_geometry', None) + if geom is not None: + self.restoreGeometry(geom) + def delete_tags(self, item=None): confirms, deletes = [], [] @@ -121,3 +125,15 @@ class TagEditor(QDialog, Ui_TagEditor): self.applied_tags.addItem(tag) self.add_tag_input.setText('') + + def accept(self): + self.save_state() + return QDialog.accept(self) + + def reject(self): + self.save_state() + return QDialog.reject(self) + + def save_state(self): + gprefs['tag_editor_geometry'] = bytearray(self.saveGeometry()) + diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index aaa4e2bb9a..60d4025ef9 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -19,7 +19,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): if text is not None: self.textbox.setPlainText(text) - self.textbox.setTabChangesFocus(True) + self.textbox.setTabStopWidth(50) self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel')) diff --git a/src/calibre/gui2/dialogs/template_dialog.ui b/src/calibre/gui2/dialogs/template_dialog.ui index 3eacace2c5..a30d6ef273 100644 --- a/src/calibre/gui2/dialogs/template_dialog.ui +++ b/src/calibre/gui2/dialogs/template_dialog.ui @@ -6,7 +6,7 @@ 0 0 - 336 + 500 235 diff --git a/src/calibre/gui2/email.py b/src/calibre/gui2/email.py index 2911222ba8..6b2ed81413 100644 --- a/src/calibre/gui2/email.py +++ b/src/calibre/gui2/email.py @@ -22,6 +22,7 @@ from calibre.customize.ui import available_input_formats, available_output_forma from calibre.ebooks.metadata import authors_to_string from calibre.constants import preferred_encoding from calibre.gui2 import config, Dispatcher, warning_dialog +from calibre.utils.config import tweaks class EmailJob(BaseJob): # {{{ @@ -83,7 +84,7 @@ class Emailer(Thread): # {{{ rh = opts.relay_host if rh and ( 'gmail.com' in rh or 'live.com' in rh): - self.rate_limit = 301 + self.rate_limit = tweaks['public_smtp_relay_delay'] def stop(self): self._run = False diff --git a/src/calibre/gui2/library/__init__.py b/src/calibre/gui2/library/__init__.py index d7180de99a..a40195e72e 100644 --- a/src/calibre/gui2/library/__init__.py +++ b/src/calibre/gui2/library/__init__.py @@ -5,6 +5,4 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -from PyQt4.Qt import Qt - -DEFAULT_SORT = ('timestamp', Qt.DescendingOrder) +DEFAULT_SORT = ('timestamp', False) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 920753a77d..49cb1ce182 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -247,9 +247,10 @@ class BooksModel(QAbstractTableModel): # {{{ if not self.db: return self.about_to_be_sorted.emit(self.db.id) - ascending = order == Qt.AscendingOrder + if not isinstance(order, bool): + order = order == Qt.AscendingOrder label = self.column_map[col] - self.db.sort(label, ascending) + self.db.sort(label, order) if reset: self.reset() self.sorted_on = (label, order) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 8dad4c21b1..322199a4f9 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -165,7 +165,7 @@ class BooksView(QTableView): # {{{ partial(self.column_header_context_handler, action='descending', column=col)) if self._model.sorted_on[0] == col: - ac = a if self._model.sorted_on[1] == Qt.AscendingOrder else d + ac = a if self._model.sorted_on[1] else d ac.setCheckable(True) ac.setChecked(True) if col not in ('ondevice', 'rating', 'inlibrary') and \ @@ -282,17 +282,21 @@ class BooksView(QTableView): # {{{ def cleanup_sort_history(self, sort_history): history = [] for col, order in sort_history: + if not isinstance(order, bool): + continue if col == 'date': col = 'timestamp' - if col in self.column_map and (not history or history[0][0] != col): - history.append([col, order]) + if col in self.column_map: + if (not history or history[-1][0] != col): + history.append([col, order]) return history def apply_sort_history(self, saved_history): if not saved_history: return for col, order in reversed(self.cleanup_sort_history(saved_history)[:3]): - self.sortByColumn(self.column_map.index(col), order) + self.sortByColumn(self.column_map.index(col), + Qt.AscendingOrder if order else Qt.DescendingOrder) def apply_state(self, state): h = self.column_header diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 3d43d49a75..f04902283e 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -10,22 +10,25 @@ Browsing book collection by tags. from itertools import izip from functools import partial -from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ - QFont, QSize, QIcon, QPoint, QVBoxLayout, QComboBox, \ - QAbstractItemModel, QVariant, QModelIndex, QMenu, \ - QPushButton, QWidget, QItemDelegate +from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, \ + QIcon, QPoint, QVBoxLayout, QHBoxLayout, QComboBox, QTimer,\ + QAbstractItemModel, QVariant, QModelIndex, QMenu, QFrame,\ + QPushButton, QWidget, QItemDelegate, QString, QLabel, \ + QShortcut, QKeySequence, SIGNAL from calibre.ebooks.metadata import title_sort from calibre.gui2 import config, NONE from calibre.library.field_metadata import TagsIcons, category_icon_map from calibre.utils.config import tweaks -from calibre.utils.icu import sort_key +from calibre.utils.icu import sort_key, upper, lower, strcmp from calibre.utils.search_query_parser import saved_searches +from calibre.utils.formatter import eval_formatter from calibre.gui2 import error_dialog from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories from calibre.gui2.dialogs.tag_list_editor import TagListEditor from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog +from calibre.gui2.widgets import HistoryLineEdit class TagDelegate(QItemDelegate): # {{{ @@ -52,6 +55,8 @@ class TagDelegate(QItemDelegate): # {{{ painter.setClipRect(r) # Paint the text + if item.boxed: + painter.drawRoundedRect(r.adjusted(1,1,-1,-1), 5, 5) r.setLeft(r.left()+r.height()+3) painter.drawText(r, Qt.AlignLeft|Qt.AlignVCenter, model.data(index, Qt.DisplayRole).toString()) @@ -322,21 +327,18 @@ class TagsView(QTreeView): # {{{ path = None except: #Database connection could be closed if an integrity check is happening pass - if path: - idx = self.model().index_for_path(path) - if idx.isValid(): - self.setCurrentIndex(idx) - self.scrollTo(idx, QTreeView.PositionAtCenter) + self._model.show_item_at_path(path) # If the number of user categories changed, if custom columns have come or # gone, or if columns have been hidden or restored, we must rebuild the # model. Reason: it is much easier than reconstructing the browser tree. - def set_new_model(self): + def set_new_model(self, filter_categories_by=None): try: self._model = TagsModel(self.db, parent=self, hidden_categories=self.hidden_categories, search_restriction=self.search_restriction, - drag_drop_finished=self.drag_drop_finished) + drag_drop_finished=self.drag_drop_finished, + filter_categories_by=filter_categories_by) self.setModel(self._model) except: # The DB must be gone. Set the model to None and hope that someone @@ -355,6 +357,7 @@ class TagTreeItem(object): # {{{ parent=None, tooltip=None, category_key=None): self.parent = parent self.children = [] + self.boxed = False if self.parent is not None: self.parent.append(self) if data is None: @@ -371,7 +374,13 @@ class TagTreeItem(object): # {{{ elif self.type == self.TAG: icon_map[0] = data.icon self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) - self.tooltip = tooltip + if tooltip: + if tooltip.endswith(':'): + self.tooltip = tooltip + ' ' + else: + self.tooltip = tooltip + ': ' + else: + self.tooltip = '' def __str__(self): if self.type == self.ROOT: @@ -400,7 +409,7 @@ class TagTreeItem(object): # {{{ def category_data(self, role): if role == Qt.DisplayRole: - return QVariant(self.py_name + ' [%d]'%len(self.children)) + return QVariant(self.py_name + ' [%d]'%len(self.child_tags())) if role == Qt.DecorationRole: return self.icon if role == Qt.FontRole: @@ -433,20 +442,32 @@ class TagTreeItem(object): # {{{ return QVariant('(%s) %s'%(tag.name, tag.tooltip)) else: return QVariant(tag.name) - if tag.tooltip is not None: - return QVariant(tag.tooltip) + if tag.tooltip: + return QVariant(self.tooltip + tag.tooltip) + else: + return QVariant(self.tooltip) return NONE def toggle(self): if self.type == self.TAG: self.tag.state = (self.tag.state + 1)%3 + def child_tags(self): + res = [] + for t in self.children: + if t.type == TagTreeItem.CATEGORY: + for c in t.children: + res.append(c) + else: + res.append(t) + return res # }}} class TagsModel(QAbstractItemModel): # {{{ def __init__(self, db, parent, hidden_categories=None, - search_restriction=None, drag_drop_finished=None): + search_restriction=None, drag_drop_finished=None, + filter_categories_by=None): QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication @@ -466,6 +487,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.hidden_categories = hidden_categories self.search_restriction = search_restriction self.row_map = [] + self.filter_categories_by = filter_categories_by # get_node_tree cannot return None here, because row_map is empty data = self.get_node_tree(config['sort_tags_by']) @@ -477,19 +499,11 @@ class TagsModel(QAbstractItemModel): # {{{ tt = _('The lookup/search name is "{0}"').format(r) else: tt = '' - c = TagTreeItem(parent=self.root_item, + TagTreeItem(parent=self.root_item, data=self.categories[i], category_icon=self.category_icon_map[r], tooltip=tt, category_key=r) - # This duplicates code in refresh(). Having it here as well - # can save seconds during startup, because we avoid a second - # call to get_node_tree. - for tag in data[r]: - if r not in self.categories_with_ratings and \ - not self.db.field_metadata[r]['is_custom'] and \ - not self.db.field_metadata[r]['kind'] == 'user': - tag.avg_rating = None - TagTreeItem(parent=c, data=tag, icon_map=self.icon_state_map) + self.refresh(data=data) def mimeTypes(self): return ["application/calibre+from_library"] @@ -641,6 +655,11 @@ class TagsModel(QAbstractItemModel): # {{{ else: data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map) + if self.filter_categories_by: + for category in data.keys(): + data[category] = [t for t in data[category] + if lower(t.name).find(self.filter_categories_by) >= 0] + tb_categories = self.db.field_metadata for category in tb_categories: if category in data: # The search category can come and go @@ -652,35 +671,85 @@ class TagsModel(QAbstractItemModel): # {{{ return None return data - def refresh(self): - data = self.get_node_tree(config['sort_tags_by']) # get category data + def refresh(self, data=None): + sort_by = config['sort_tags_by'] + if data is None: + data = self.get_node_tree(sort_by) # get category data if data is None: return False row_index = -1 + collapse = tweaks['categories_collapse_more_than'] + collapse_model = tweaks['categories_collapse_model'] + if sort_by == 'name': + collapse_template = tweaks['categories_collapsed_name_template'] + elif sort_by == 'rating': + collapse_model = 'partition' + collapse_template = tweaks['categories_collapsed_rating_template'] + else: + collapse_model = 'partition' + collapse_template = tweaks['categories_collapsed_popularity_template'] + collapse_letter = None + for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue row_index += 1 category = self.root_item.children[row_index] - names = [t.tag.name for t in category.children] - states = [t.tag.state for t in category.children] + names = [] + states = [] + children = category.child_tags() + states = [t.tag.state for t in children] + names = [t.tag.name for names in children] state_map = dict(izip(names, states)) category_index = self.index(row_index, 0, QModelIndex()) + category_node = category_index.internalPointer() if len(category.children) > 0: self.beginRemoveRows(category_index, 0, len(category.children)-1) category.children = [] self.endRemoveRows() - if len(data[r]) > 0: - self.beginInsertRows(category_index, 0, len(data[r])-1) - for tag in data[r]: - if r not in self.categories_with_ratings and \ + cat_len = len(data[r]) + if cat_len <= 0: + continue + + self.beginInsertRows(category_index, 0, len(data[r])-1) + clear_rating = True if r not in self.categories_with_ratings and \ not self.db.field_metadata[r]['is_custom'] and \ - not self.db.field_metadata[r]['kind'] == 'user': - tag.avg_rating = None - tag.state = state_map.get(tag.name, 0) - t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_state_map) - self.endInsertRows() + not self.db.field_metadata[r]['kind'] == 'user' \ + else False + for idx,tag in enumerate(data[r]): + if clear_rating: + tag.avg_rating = None + tag.state = state_map.get(tag.name, 0) + + if collapse > 0 and cat_len > collapse: + if collapse_model == 'partition': + if (idx % collapse) == 0: + d = {'first': tag} + if cat_len > idx + collapse: + d['last'] = data[r][idx+collapse-1] + else: + d['last'] = data[r][cat_len-1] + name = eval_formatter.safe_format(collapse_template, + d, 'TAG_VIEW', None) + sub_cat = TagTreeItem(parent=category, + data = name, tooltip = None, + category_icon = category_node.icon, + category_key=category_node.category_key) + else: + if upper(tag.sort[0]) != collapse_letter: + collapse_letter = upper(tag.name[0]) + sub_cat = TagTreeItem(parent=category, + data = collapse_letter, + category_icon = category_node.icon, + tooltip = None, + category_key=category_node.category_key) + t = TagTreeItem(parent=sub_cat, data=tag, tooltip=r, + icon_map=self.icon_state_map) + else: + t = TagTreeItem(parent=category, data=tag, tooltip=r, + icon_map=self.icon_state_map) + self.endInsertRows() return True def columnCount(self, parent): @@ -737,11 +806,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.tags_view.tag_item_renamed.emit() item.tag.name = val self.refresh() # Should work, because no categories can have disappeared - if path: - idx = self.index_for_path(path) - if idx.isValid(): - self.tags_view.setCurrentIndex(idx) - self.tags_view.scrollTo(idx, QTreeView.PositionAtCenter) + self.show_item_at_path(path) return True def headerData(self, *args): @@ -824,19 +889,27 @@ class TagsModel(QAbstractItemModel): # {{{ def reset_all_states(self, except_=None): update_list = [] - for i in xrange(self.rowCount(QModelIndex())): - category_index = self.index(i, 0, QModelIndex()) + def process_tag(tag_index, tag_item): + tag = tag_item.tag + if tag is except_: + self.dataChanged.emit(tag_index, tag_index) + return + if tag.state != 0 or tag in update_list: + tag.state = 0 + update_list.append(tag) + self.dataChanged.emit(tag_index, tag_index) + + def process_level(category_index): for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() - tag = tag_item.tag - if tag is except_: - self.dataChanged.emit(tag_index, tag_index) - continue - if tag.state != 0 or tag in update_list: - tag.state = 0 - update_list.append(tag) - self.dataChanged.emit(tag_index, tag_index) + if tag_item.type == TagTreeItem.CATEGORY: + process_level(tag_index) + else: + process_tag(tag_index, tag_item) + + for i in xrange(self.rowCount(QModelIndex())): + process_level(self.index(i, 0, QModelIndex())) def clear_state(self): self.reset_all_states() @@ -856,14 +929,16 @@ class TagsModel(QAbstractItemModel): # {{{ ans = [] tags_seen = set() row_index = -1 + for i, key in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue row_index += 1 - if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category + if key.endswith(':'): + # User category, so skip it. The tag will be marked in its real category continue category_item = self.root_item.children[row_index] - for tag_item in category_item.children: + for tag_item in category_item.child_tags(): tag = tag_item.tag if tag.state > 0: prefix = ' not ' if tag.state == 2 else '' @@ -878,6 +953,102 @@ class TagsModel(QAbstractItemModel): # {{{ ans.append('%s%s:"=%s"'%(prefix, category, tag.name)) return ans + def find_node(self, key, txt, start_path): + ''' + Search for an item (a node) in the tags browser list that matches both + the key (exact case-insensitive match) and txt (contains case- + insensitive match). Returns the path to the node. Note that paths are to + a location (second item, fourth item, 25 item), not to a node. If + start_path is None, the search starts with the topmost node. If the tree + is changed subsequent to calling this method, the path can easily refer + to a different node or no node at all. + ''' + if not txt: + return None + txt = lower(txt) + self.path_found = None + if start_path is None: + start_path = [] + + def process_tag(depth, tag_index, tag_item, start_path): + path = self.path_for_index(tag_index) + if depth < len(start_path) and path[depth] <= start_path[depth]: + return False + tag = tag_item.tag + if tag is None: + return False + if lower(tag.name).find(txt) >= 0: + self.path_found = path + return True + return False + + def process_level(depth, category_index, start_path): + path = self.path_for_index(category_index) + if depth < len(start_path): + if path[depth] < start_path[depth]: + return False + if path[depth] > start_path[depth]: + start_path = path + if key and strcmp(category_index.internalPointer().category_key, key) != 0: + return False + for j in xrange(self.rowCount(category_index)): + tag_index = self.index(j, 0, category_index) + tag_item = tag_index.internalPointer() + if tag_item.type == TagTreeItem.CATEGORY: + if process_level(depth+1, tag_index, start_path): + return True + else: + if process_tag(depth+1, tag_index, tag_item, start_path): + return True + return False + + for i in xrange(self.rowCount(QModelIndex())): + if process_level(0, self.index(i, 0, QModelIndex()), start_path): + break + return self.path_found + + def show_item_at_path(self, path, box=False): + ''' + Scroll the browser and open categories to show the item referenced by + path. If possible, the item is placed in the center. If box=True, a + box is drawn around the item. + ''' + if path: + self.show_item_at_index(self.index_for_path(path), box) + + def show_item_at_index(self, idx, box=False): + if idx.isValid(): + self.tags_view.setCurrentIndex(idx) + self.tags_view.scrollTo(idx, QTreeView.PositionAtCenter) + if box: + tag_item = idx.internalPointer() + tag_item.boxed = True + self.dataChanged.emit(idx, idx) + + def clear_boxed(self): + ''' + Clear all boxes around items. + ''' + def process_tag(tag_index, tag_item): + if tag_item.boxed: + tag_item.boxed = False + self.dataChanged.emit(tag_index, tag_index) + + def process_level(category_index): + for j in xrange(self.rowCount(category_index)): + tag_index = self.index(j, 0, category_index) + tag_item = tag_index.internalPointer() + if tag_item.type == TagTreeItem.CATEGORY: + process_level(tag_index) + else: + process_tag(tag_index, tag_item) + + for i in xrange(self.rowCount(QModelIndex())): + process_level(self.index(i, 0, QModelIndex())) + + def get_filter_categories_by(self): + return self.filter_categories_by + # }}} class TagBrowserMixin(object): # {{{ @@ -993,14 +1164,73 @@ class TagBrowserWidget(QWidget): # {{{ def __init__(self, parent): QWidget.__init__(self, parent) + self.parent = parent self._layout = QVBoxLayout() self.setLayout(self._layout) self._layout.setContentsMargins(0,0,0,0) + # Set up the find box & button + search_layout = QHBoxLayout() + self._layout.addLayout(search_layout) + self.item_search = HistoryLineEdit(parent) + try: + self.item_search.lineEdit().setPlaceholderText( + _('Find item in tag browser')) + except: + pass # Using Qt < 4.7 + self.item_search.setToolTip(_( + 'Search for items. This is a "contains" search; items containing the\n' + 'text anywhere in the name will be found. You can limit the search\n' + 'to particular categories using syntax similar to search. For example,\n' + 'tags:foo will find foo in any tag, but not in authors etc. Entering\n' + '*foo will filter all categories at once, showing only those items\n' + 'containing the text "foo"')) + search_layout.addWidget(self.item_search) + # Not sure if the shortcut should be translatable ... + sc = QShortcut(QKeySequence(_('ALT+f')), parent) + sc.connect(sc, SIGNAL('activated()'), self.set_focus_to_find_box) + + self.search_button = QPushButton() + self.search_button.setText(_('F&ind')) + self.search_button.setToolTip(_('Find the first/next matching item')) + self.search_button.setFixedWidth(40) + search_layout.addWidget(self.search_button) + + self.expand_button = QPushButton() + self.expand_button.setText('-') + self.expand_button.setFixedWidth(20) + self.expand_button.setToolTip(_('Collapse all categories')) + search_layout.addWidget(self.expand_button) + + self.current_find_position = None + self.search_button.clicked.connect(self.find) + self.item_search.initialize('tag_browser_search') + self.item_search.lineEdit().returnPressed.connect(self.do_find) + self.item_search.lineEdit().textEdited.connect(self.find_text_changed) + self.item_search.activated[QString].connect(self.do_find) + self.item_search.completer().setCaseSensitivity(Qt.CaseSensitive) + parent.tags_view = TagsView(parent) self.tags_view = parent.tags_view + self.expand_button.clicked.connect(self.tags_view.collapseAll) self._layout.addWidget(parent.tags_view) + # Now the floating 'not found' box + l = QLabel(self.tags_view) + self.not_found_label = l + l.setFrameStyle(QFrame.StyledPanel) + l.setAutoFillBackground(True) + l.setText('

'+_('No More Matches.

Click Find again to go to first match')) + l.setAlignment(Qt.AlignVCenter) + l.setWordWrap(True) + l.resize(l.sizeHint()) + l.move(10,20) + l.setVisible(False) + self.not_found_label_timer = QTimer() + self.not_found_label_timer.setSingleShot(True) + self.not_found_label_timer.timeout.connect(self.not_found_label_timer_event, + type=Qt.QueuedConnection) + parent.sort_by = QComboBox(parent) # Must be in the same order as db2.CATEGORY_SORTS for x in (_('Sort by name'), _('Sort by popularity'), @@ -1031,6 +1261,63 @@ class TagBrowserWidget(QWidget): # {{{ def set_pane_is_visible(self, to_what): self.tags_view.set_pane_is_visible(to_what) + def find_text_changed(self, str): + self.current_find_position = None + + def set_focus_to_find_box(self): + self.item_search.setFocus() + self.item_search.lineEdit().selectAll() + + def do_find(self, str=None): + self.current_find_position = None + self.find() + + def find(self): + model = self.tags_view.model() + model.clear_boxed() + txt = unicode(self.item_search.currentText()).strip() + + if txt.startswith('*'): + self.tags_view.set_new_model(filter_categories_by=txt[1:]) + self.current_find_position = None + return + if model.get_filter_categories_by(): + self.tags_view.set_new_model(filter_categories_by=None) + self.current_find_position = None + model = self.tags_view.model() + + if not txt: + return + + self.item_search.lineEdit().blockSignals(True) + self.search_button.setFocus(True) + self.item_search.lineEdit().blockSignals(False) + + colon = txt.find(':') + key = None + if colon > 0: + key = self.parent.library_view.model().db.\ + field_metadata.search_term_to_field_key(txt[:colon]) + txt = txt[colon+1:] + + self.current_find_position = model.find_node(key, txt, + self.current_find_position) + if self.current_find_position: + model.show_item_at_path(self.current_find_position, box=True) + elif self.item_search.text(): + self.not_found_label.setVisible(True) + if self.tags_view.verticalScrollBar().isVisible(): + sbw = self.tags_view.verticalScrollBar().width() + else: + sbw = 0 + width = self.width() - 8 - sbw + height = self.not_found_label.heightForWidth(width) + 20 + self.not_found_label.resize(width, height) + self.not_found_label.move(4, 10) + self.not_found_label_timer.start(2000) + + def not_found_label_timer_event(self): + self.not_found_label.setVisible(False) # }}} diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py index b2d8e4b8fd..bc3c23876f 100644 --- a/src/calibre/gui2/widgets.py +++ b/src/calibre/gui2/widgets.py @@ -551,7 +551,11 @@ class HistoryLineEdit(QComboBox): item = unicode(self.itemText(i)) if item not in items: items.append(item) - + self.blockSignals(True) + self.clear() + self.addItems(items) + self.setEditText(ct) + self.blockSignals(False) history.set(self.store_name, items) def setText(self, t): diff --git a/src/calibre/gui2/wizard/send_email.py b/src/calibre/gui2/wizard/send_email.py index 20e73fabe2..b9b65dc940 100644 --- a/src/calibre/gui2/wizard/send_email.py +++ b/src/calibre/gui2/wizard/send_email.py @@ -144,8 +144,10 @@ class SendEmail(QWidget, Ui_Form): bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel) bb.accepted.connect(d.accept) bb.rejected.connect(d.reject) - d.tl = QLabel('

'+_('You can sign up for a free {name} email ' - 'account at http://{url}. {extra}').format( + d.tl = QLabel(('

'+_('Setup sending email using') + + ' {name}

' + + _('If you don\'t have an account, you can sign up for a free {name} email ' + 'account at http://{url}. {extra}')).format( **service)) l.addWidget(d.tl, 0, 0, 3, 0) d.tl.setWordWrap(True) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index ff3aa0bf67..a32c45191f 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -669,6 +669,9 @@ class ResultCache(SearchQueryParser): # {{{ fields = [('timestamp', False)] keyg = SortKeyGenerator(fields, self.field_metadata, self._data) + # For efficiency, the key generator returns a plain value if only one + # field is in the sort field list. Because the normal cmp function will + # always assume asc, we must deal with asc/desc here. if len(fields) == 1: self._map.sort(key=keyg, reverse=not fields[0][1]) else: @@ -697,7 +700,7 @@ class SortKeyGenerator(object): def __init__(self, fields, field_metadata, data): from calibre.utils.icu import sort_key self.field_metadata = field_metadata - self.orders = [-1 if x[1] else 1 for x in fields] + self.orders = [1 if x[1] else -1 for x in fields] self.entries = [(x[0], field_metadata[x[0]]) for x in fields] self.library_order = tweaks['title_series_sorting'] == 'library_order' self.data = data diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 0b317d6a6e..5f771a8a6d 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -1,22 +1,24 @@ # -*- coding: utf-8 -*- __license__ = 'GPL v3' -__copyright__ = '2010, Greg Riker ' +__copyright__ = '2010, Greg Riker' import codecs, datetime, htmlentitydefs, os, re, shutil, time, zlib from contextlib import closing from collections import namedtuple from copy import deepcopy from xml.sax.saxutils import escape +from lxml import etree from calibre import prints, prepare_string_for_xml, strftime from calibre.constants import preferred_encoding from calibre.customize import CatalogPlugin from calibre.customize.conversion import OptionRecommendation, DummyReporter from calibre.ebooks.BeautifulSoup import BeautifulSoup, BeautifulStoneSoup, Tag, NavigableString +from calibre.ebooks.oeb.base import RECOVER_PARSER, XHTML_NS from calibre.ptempfile import PersistentTemporaryDirectory from calibre.utils.config import config_dir -from calibre.utils.date import isoformat, now as nowf +from calibre.utils.date import format_date, isoformat, now as nowf from calibre.utils.logging import default_log as log from calibre.utils.zipfile import ZipFile, ZipInfo from calibre.utils.magick.draw import thumbnail @@ -26,6 +28,7 @@ FIELDS = ['all', 'author_sort', 'authors', 'comments', 'series_index', 'series', 'size', 'tags', 'timestamp', 'title', 'uuid'] + #Allowed fields for template TEMPLATE_ALLOWED_FIELDS = [ 'author_sort', 'authors', 'id', 'isbn', 'pubdate', 'publisher', 'series_index', 'series', 'tags', 'timestamp', 'title', 'uuid' ] @@ -49,7 +52,8 @@ class CSV_XML(CatalogPlugin): action = None, help = _('The fields to output when cataloging books in the ' 'database. Should be a comma-separated list of fields.\n' - 'Available fields: %s.\n' + 'Available fields: %s,\n' + 'plus user-created custom fields.\n' "Default: '%%default'\n" "Applies to: CSV, XML output formats")%', '.join(FIELDS)), @@ -96,7 +100,7 @@ class CSV_XML(CatalogPlugin): #raise SystemExit(1) # Get the requested output fields as a list - fields = self.get_output_fields(opts) + fields = self.get_output_fields(db, opts) # If connected device, add 'On Device' values to data if opts.connected_device['is_device_connected'] and 'ondevice' in fields: @@ -106,6 +110,9 @@ class CSV_XML(CatalogPlugin): if self.fmt == 'csv': outfile = codecs.open(path_to_output, 'w', 'utf8') + # Write a UTF-8 BOM + outfile.write('\xef\xbb\xbf') + # Output the field headers outfile.write(u'%s\n' % u','.join(fields)) @@ -113,7 +120,10 @@ class CSV_XML(CatalogPlugin): for entry in data: outstr = [] for field in fields: - item = entry[field] + if field.startswith('#'): + item = db.get_field(entry['id'],field,index_is_id=True) + else: + item = entry[field] if item is None: outstr.append('""') continue @@ -132,14 +142,12 @@ class CSV_XML(CatalogPlugin): elif field == 'comments': item = item.replace(u'\r\n',u' ') item = item.replace(u'\n',u' ') - outstr.append(u'"%s"' % unicode(item).replace('"','""')) outfile.write(u','.join(outstr) + u'\n') outfile.close() elif self.fmt == 'xml': - from lxml import etree from lxml.builder import E root = E.calibredb() @@ -147,6 +155,14 @@ class CSV_XML(CatalogPlugin): record = E.record() root.append(record) + for field in fields: + if field.startswith('#'): + val = db.get_field(r['id'],field,index_is_id=True) + if not isinstance(val, (str, unicode)): + val = unicode(val) + item = getattr(E, field.replace('#','_'))(val) + record.append(item) + for field in ('id', 'uuid', 'title', 'publisher', 'rating', 'size', 'isbn','ondevice'): if field in fields: @@ -468,7 +484,7 @@ class BIBTEX(CatalogPlugin): log.error("\nNo matching database entries for search criteria '%s'" % opts.search_text) # Get the requested output fields as a list - fields = self.get_output_fields(opts) + fields = self.get_output_fields(db, opts) if not len(data): log.error("\nNo matching database entries for search criteria '%s'" % opts.search_text) @@ -533,6 +549,20 @@ class EPUB_MOBI(CatalogPlugin): version = (0, 0, 1) file_types = set(['epub','mobi']) + THUMB_SMALLEST = "1.0" + THUMB_LARGEST = "2.0" + + ''' + # Deprecated, keeping this just in case there are complaints + Option('--numbers-as-text', + default=False, + dest='numbers_as_text', + action = None, + help=_("Sort titles with leading numbers as text, e.g.,\n'2001: A Space Odyssey' sorts as \n'Two Thousand One: A Space Odyssey'.\n" + "Default: '%default'\n" + "Applies to: ePub, MOBI output formats")), + ''' + cli_options = [Option('--catalog-title', default = 'My Books', dest = 'catalog_title', @@ -550,6 +580,13 @@ class EPUB_MOBI(CatalogPlugin): "of the conversion process a bug is occurring.\n" "Default: '%default'None\n" "Applies to: ePub, MOBI output formats")), + Option('--exclude-book-marker', + default=':', + dest='exclude_book_marker', + action = None, + help=_("field:pattern specifying custom field/contents indicating book should be excluded.\n" + "Default: '%default'\n" + "Applies to ePub, MOBI output formats")), Option('--exclude-genre', default='\[.+\]', dest='exclude_genre', @@ -564,41 +601,58 @@ class EPUB_MOBI(CatalogPlugin): "--exclude-tags=skip will match 'skip this book' and 'Skip will like this'.\n" "Default: '%default'\n" "Applies to: ePub, MOBI output formats")), + Option('--generate-descriptions', + default=True, + dest='generate_descriptions', + action = 'store_true', + help=_("Include book descriptions in catalog.\n" + "Default: '%default'\n" + "Applies to: ePub, MOBI output formats")), + Option('--generate-genres', + default=True, + dest='generate_genres', + action = 'store_true', + help=_("Include 'Genres' section in catalog.\n" + "Default: '%default'\n" + "Applies to: ePub, MOBI output formats")), Option('--generate-titles', - default=False, + default=True, dest='generate_titles', action = 'store_true', help=_("Include 'Titles' section in catalog.\n" "Default: '%default'\n" "Applies to: ePub, MOBI output formats")), Option('--generate-series', - default=False, + default=True, dest='generate_series', action = 'store_true', help=_("Include 'Series' section in catalog.\n" "Default: '%default'\n" "Applies to: ePub, MOBI output formats")), Option('--generate-recently-added', - default=False, + default=True, dest='generate_recently_added', action = 'store_true', help=_("Include 'Recently Added' section in catalog.\n" "Default: '%default'\n" "Applies to: ePub, MOBI output formats")), - Option('--note-tag', - default='*', - dest='note_tag', + Option('--header-note-source-field', + default='', + dest='header_note_source_field', action = None, - help=_("Tag prefix for user notes, e.g. '*Jeff might enjoy reading this'.\n" + help=_("Custom field containing note text to insert in Description header.\n" "Default: '%default'\n" "Applies to: ePub, MOBI output formats")), - Option('--numbers-as-text', - default=False, - dest='numbers_as_text', + Option('--merge-comments', + default='::', + dest='merge_comments', action = None, - help=_("Sort titles with leading numbers as text, e.g.,\n'2001: A Space Odyssey' sorts as \n'Two Thousand One: A Space Odyssey'.\n" + help=_(":[before|after]:[True|False] specifying:\n" + " Custom field containing notes to merge with Comments\n" + " [before|after] Placement of notes with respect to Comments\n" + " [True|False] - A horizontal rule is inserted between notes and Comments\n" "Default: '%default'\n" - "Applies to: ePub, MOBI output formats")), + "Applies to ePub, MOBI output formats")), Option('--output-profile', default=None, dest='output_profile', @@ -612,6 +666,14 @@ class EPUB_MOBI(CatalogPlugin): action = None, help=_("field:pattern indicating book has been read.\n" "Default: '%default'\n" "Applies to ePub, MOBI output formats")), + Option('--thumb-width', + default='1.0', + dest='thumb_width', + action = None, + help=_("Size hint (in inches) for book covers in catalog.\n" + "Range: 1.0 - 2.0\n" + "Default: '%default'\n" + "Applies to ePub, MOBI output formats")), Option('--wishlist-tag', default='Wishlist', dest='wishlist_tag', @@ -845,6 +907,7 @@ class EPUB_MOBI(CatalogPlugin): catalog.copyResources() catalog.buildSources() ''' + # A single number creates 'Last x days' only. # Multiple numbers create 'Last x days', 'x to y days ago' ... # e.g, [7,15,30,60], [30] @@ -889,8 +952,10 @@ class EPUB_MOBI(CatalogPlugin): and self.generateForKindle \ else False self.__genres = None + self.genres = [] self.__genre_tags_dict = None - self.__htmlFileList = [] + self.__htmlFileList_1 = [] + self.__htmlFileList_2 = [] self.__markerTags = self.getMarkerTags() self.__ncxSoup = None self.__output_profile = None @@ -900,13 +965,15 @@ class EPUB_MOBI(CatalogPlugin): self.__progressString = '' f, _, p = opts.read_book_marker.partition(':') self.__read_book_marker = {'field':f, 'pattern':p} + f, p, hr = self.opts.merge_comments.split(':') + self.__merge_comments = {'field':f, 'position':p, 'hr':hr} self.__reporter = report_progress self.__stylesheet = stylesheet self.__thumbs = None self.__thumbWidth = 0 self.__thumbHeight = 0 self.__title = opts.catalog_title - self.__totalSteps = 11.0 + self.__totalSteps = 8.0 self.__useSeriesPrefixInTitlesSection = False self.__verbose = opts.verbose @@ -916,17 +983,35 @@ class EPUB_MOBI(CatalogPlugin): self.__output_profile = profile break - # Confirm/create thumbs archive - if not os.path.exists(self.__cache_dir): - self.opts.log.info(" creating new thumb cache '%s'" % self.__cache_dir) - os.makedirs(self.__cache_dir) - if not os.path.exists(self.__archive_path): - self.opts.log.info(" creating thumbnail archive") - zfw = ZipFile(self.__archive_path, mode='w') - zfw.writestr("Catalog Thumbs Archive",'') - zfw.close() - else: - self.opts.log.info(" existing thumb cache at '%s'" % self.__archive_path) + # Confirm/create thumbs archive. + if self.opts.generate_descriptions: + if not os.path.exists(self.__cache_dir): + self.opts.log.info(" creating new thumb cache '%s'" % self.__cache_dir) + os.makedirs(self.__cache_dir) + if not os.path.exists(self.__archive_path): + self.opts.log.info(' creating thumbnail archive, thumb_width: %1.2f"' % + float(self.opts.thumb_width)) + zfw = ZipFile(self.__archive_path, mode='w') + zfw.writestr("Catalog Thumbs Archive",'') + #zfw.comment = "thumb_width: %1.2f" % float(self.opts.thumb_width) + zfw.close() + else: + with closing(ZipFile(self.__archive_path, mode='r')) as zfr: + try: + cached_thumb_width = zfr.read('thumb_width') + except: + cached_thumb_width = "-1" + + if float(cached_thumb_width) != float(self.opts.thumb_width): + self.opts.log.warning(" invalidating cache at '%s'" % self.__archive_path) + self.opts.log.warning(' thumb_width changed: %1.2f" => %1.2f"' % + (float(cached_thumb_width),float(self.opts.thumb_width))) + os.remove(self.__archive_path) + with closing(ZipFile(self.__archive_path, mode='w')) as zfw: + zfw.writestr("Catalog Thumbs Archive",'') + else: + self.opts.log.info(' existing thumb cache at %s, cached_thumb_width: %1.2f"' % + (self.__archive_path, float(cached_thumb_width))) # Tweak build steps based on optional sections: 1 call for HTML, 1 for NCX if self.opts.generate_titles: @@ -937,6 +1022,9 @@ class EPUB_MOBI(CatalogPlugin): self.__totalSteps += 2 if self.opts.generate_series: self.__totalSteps += 2 + if self.opts.generate_descriptions: + # +1 thumbs + self.__totalSteps += 3 # Accessors if True: @@ -1079,11 +1167,18 @@ class EPUB_MOBI(CatalogPlugin): self.__genre_tags_dict = val return property(fget=fget, fset=fset) @dynamic_property - def htmlFileList(self): + def htmlFileList_1(self): def fget(self): - return self.__htmlFileList + return self.__htmlFileList_1 def fset(self, val): - self.__htmlFileList = val + self.__htmlFileList_1 = val + return property(fget=fget, fset=fset) + @dynamic_property + def htmlFileList_2(self): + def fget(self): + return self.__htmlFileList_2 + def fset(self, val): + self.__htmlFileList_2 = val return property(fget=fget, fset=fset) @dynamic_property def libraryPath(self): @@ -1246,20 +1341,21 @@ class EPUB_MOBI(CatalogPlugin): return False self.fetchBooksByAuthor() self.fetchBookmarks() - self.generateHTMLDescriptions() + if self.opts.generate_descriptions: + self.generateHTMLDescriptions() self.generateHTMLByAuthor() if self.opts.generate_titles: self.generateHTMLByTitle() if self.opts.generate_series: self.generateHTMLBySeries() + if self.opts.generate_genres: + self.generateHTMLByTags() if self.opts.generate_recently_added: self.generateHTMLByDateAdded() if self.generateRecentlyRead: self.generateHTMLByDateRead() - self.generateHTMLByTags() - - self.generateThumbnails() - + if self.opts.generate_descriptions: + self.generateThumbnails() self.generateOPF() self.generateNCXHeader() self.generateNCXByAuthor("Authors") @@ -1267,12 +1363,15 @@ class EPUB_MOBI(CatalogPlugin): self.generateNCXByTitle("Titles") if self.opts.generate_series: self.generateNCXBySeries("Series") + if self.opts.generate_genres: + self.generateNCXByGenre("Genres") if self.opts.generate_recently_added: self.generateNCXByDateAdded("Recently Added") if self.generateRecentlyRead: self.generateNCXByDateRead("Recently Read") - self.generateNCXByGenre("Genres") - self.generateNCXDescriptions("Descriptions") + if self.opts.generate_descriptions: + self.generateNCXDescriptions("Descriptions") + self.writeNCX() return True @@ -1340,10 +1439,13 @@ class EPUB_MOBI(CatalogPlugin): #print "fetchBooksByTitle(): opts.search_text: %s" % self.opts.search_text # Fetch the database as a dictionary data = self.plugin.search_sort_db(self.db, self.opts) + data = self.processExclusions(data) # Populate this_title{} from data[{},{}] titles = [] for record in data: + if False: + print "available record metadata:\n%s" % sorted(record.keys()) this_title = {} this_title['id'] = record['id'] @@ -1360,6 +1462,9 @@ class EPUB_MOBI(CatalogPlugin): this_title['title_sort'] = self.generateSortTitle(this_title['title']) if 'authors' in record: + # from calibre.ebooks.metadata import authors_to_string + # return authors_to_string(self.authors) + this_title['authors'] = record['authors'] if record['authors']: this_title['author'] = " & ".join(record['authors']) @@ -1388,6 +1493,8 @@ class EPUB_MOBI(CatalogPlugin): record['comments'] = record['comments'][:ad_offset] this_title['description'] = self.markdownComments(record['comments']) + + # Create short description paras = BeautifulSoup(this_title['description']).findAll('p') tokens = [] for p in paras: @@ -1399,6 +1506,10 @@ class EPUB_MOBI(CatalogPlugin): this_title['description'] = None this_title['short_description'] = None + # Merge with custom field/value + if self.__merge_comments['field']: + this_title['description'] = self.mergeComments(this_title) + if record['cover']: this_title['cover'] = re.sub('&', '&', record['cover']) @@ -1413,6 +1524,21 @@ class EPUB_MOBI(CatalogPlugin): formats.append(self.convertHTMLEntities(format)) this_title['formats'] = formats + # Add user notes to be displayed in header + # Special case handling for datetime fields + if self.opts.header_note_source_field: + field_md = self.__db.metadata_for_field(self.opts.header_note_source_field) + notes = self.__db.get_field(record['id'], + self.opts.header_note_source_field, + index_is_id=True) + if notes and field_md['datatype'] == 'datetime': + # Reformat date fields to match UI presentation: dd MMM YYYY + notes = format_date(notes,'dd MMM yyyy') + + if notes: + this_title['notes'] = {'source':field_md['name'], + 'content':notes} + titles.append(this_title) # Re-sort based on title_sort @@ -1610,183 +1736,10 @@ class EPUB_MOBI(CatalogPlugin): (title_num, len(self.booksByTitle)), float(title_num*100/len(self.booksByTitle))/100) - # Generate the header - soup = self.generateHTMLDescriptionHeader("%s" % title['title']) - body = soup.find('body') - - btc = 0 - - # Insert the anchor - aTag = Tag(soup, "a") - aTag['name'] = "book%d" % int(title['id']) - body.insert(btc, aTag) - btc += 1 - - # Insert the book title - #

Book Title

- emTag = Tag(soup, "em") - if title['series']: - # title
series series_index - if self.opts.generate_series: - brTag = Tag(soup,'br') - title_tokens = list(title['title'].partition(':')) - emTag.insert(0, escape(NavigableString(title_tokens[2].strip()))) - emTag.insert(1, brTag) - smallTag = Tag(soup,'small') - aTag = Tag(soup,'a') - aTag['href'] = "%s.html#%s_series" % ('BySeries', - re.sub('\W','',title['series']).lower()) - aTag.insert(0, title_tokens[0]) - smallTag.insert(0, aTag) - emTag.insert(2, smallTag) - else: - brTag = Tag(soup,'br') - title_tokens = list(title['title'].partition(':')) - emTag.insert(0, escape(NavigableString(title_tokens[2].strip()))) - emTag.insert(1, brTag) - smallTag = Tag(soup,'small') - smallTag.insert(0, escape(NavigableString(title_tokens[0]))) - emTag.insert(2, smallTag) - else: - emTag.insert(0, NavigableString(escape(title['title']))) - titleTag = body.find(attrs={'class':'title'}) - titleTag.insert(0,emTag) - - # Create the author anchor - authorTag = body.find(attrs={'class':'author'}) - aTag = Tag(soup, "a") - aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", - self.generateAuthorAnchor(title['author'])) - aTag.insert(0, title['author']) - - # Prefix author with read|reading|none symbol or missing symbol - if self.opts.wishlist_tag in title.get('tags', []): - authorTag.insert(0, NavigableString(self.MISSING_SYMBOL + " by ")) - else: - if title['read']: - authorTag.insert(0, NavigableString(self.READ_SYMBOL + " by ")) - elif self.opts.connected_kindle and title['id'] in self.bookmarked_books: - authorTag.insert(0, NavigableString(self.READING_SYMBOL + " by ")) - else: - #authorTag.insert(0, NavigableString(self.NOT_READ_SYMBOL + " by ")) - authorTag.insert(0, NavigableString("by ")) - authorTag.insert(1, aTag) - - ''' - # Insert Series info or remove. - seriesTag = body.find(attrs={'class':'series'}) - if title['series']: - # Insert a spacer to match the author indent - stc = 0 - fontTag = Tag(soup,"font") - fontTag['style'] = 'color:white;font-size:large' - if self.opts.fmt == 'epub': - fontTag['style'] += ';opacity: 0.0' - fontTag.insert(0, NavigableString("by ")) - seriesTag.insert(stc, fontTag) - stc += 1 - if float(title['series_index']) - int(title['series_index']): - series_str = 'Series: %s [%4.2f]' % (title['series'], title['series_index']) - else: - series_str = '%s [%d]' % (title['series'], title['series_index']) - seriesTag.insert(stc,NavigableString(series_str)) - else: - seriesTag.extract() - ''' - # Insert linked genres - if 'tags' in title: - tagsTag = body.find(attrs={'class':'tags'}) - ttc = 0 - - ''' - # Insert a spacer to match the author indent - fontTag = Tag(soup,"font") - fontTag['style'] = 'color:white;font-size:large' - if self.opts.fmt == 'epub': - fontTag['style'] += ';opacity: 0.0' - fontTag.insert(0, NavigableString(" by ")) - tagsTag.insert(ttc, fontTag) - ttc += 1 - ''' - - for tag in title.get('tags', []): - aTag = Tag(soup,'a') - #print "aTag: %s" % "Genre_%s.html" % re.sub("\W","",tag.lower()) - aTag['href'] = "Genre_%s.html" % re.sub("\W","",tag.lower()) - aTag.insert(0,escape(NavigableString(tag))) - emTag = Tag(soup, "em") - emTag.insert(0, aTag) - if ttc < len(title['tags'])-1: - emTag.insert(1, NavigableString(' · ')) - tagsTag.insert(ttc, emTag) - ttc += 1 - - # Insert formats - if 'formats' in title: - formatsTag = body.find(attrs={'class':'formats'}) - formats = [] - for format in sorted(title['formats']): - formats.append(format.rpartition('.')[2].upper()) - formatsTag.insert(0, NavigableString(' · '.join(formats))) - - # Insert the cover if available - imgTag = Tag(soup,"img") - if 'cover' in title: - imgTag['src'] = "../images/thumbnail_%d.jpg" % int(title['id']) - else: - imgTag['src'] = "../images/thumbnail_default.jpg" - imgTag['alt'] = "cover" - - ''' - if self.opts.fmt == 'mobi': - imgTag['style'] = 'width: %dpx; height:%dpx;' % (self.thumbWidth, self.thumbHeight) - ''' - - thumbnailTag = body.find(attrs={'class':'thumbnail'}) - thumbnailTag.insert(0,imgTag) - - # Insert the publisher - publisherTag = body.find(attrs={'class':'publisher'}) - if 'publisher' in title: - publisherTag.insert(0,NavigableString(title['publisher'] + '
' )) - else: - publisherTag.insert(0,NavigableString('
')) - - # Insert the publication date - pubdateTag = body.find(attrs={'class':'date'}) - if title['date'] is not None: - pubdateTag.insert(0,NavigableString(title['date'] + '
')) - else: - pubdateTag.insert(0,NavigableString('
')) - - # Insert the rating, remove if unrated - # Render different ratings chars for epub/mobi - stars = int(title['rating']) / 2 - ratingTag = body.find(attrs={'class':'rating'}) - if stars: - star_string = self.FULL_RATING_SYMBOL * stars - empty_stars = self.EMPTY_RATING_SYMBOL * (5 - stars) - ratingTag.insert(0,NavigableString('%s%s
' % (star_string,empty_stars))) - else: - #ratingLabel = body.find('td',text="Rating").replaceWith("Unrated") - ratingTag.insert(0,NavigableString('
')) + # Generate the header from user-customizable template + soup = self.generateHTMLDescriptionHeader(title) - # Insert user notes or remove Notes label. Notes > 1 line will push formatting down - if 'notes' in title: - notesTag = body.find(attrs={'class':'notes'}) - notesTag.insert(0,NavigableString(title['notes'] + '
')) - else: - pass -# notes_labelTag = body.find(attrs={'class':'notes_label'}) -# empty_labelTag = Tag(soup, "td") -# empty_labelTag.insert(0,NavigableString('
')) -# notes_labelTag.replaceWith(empty_labelTag) - - # Insert the blurb - if 'description' in title and title['description'] > '': - blurbTag = body.find(attrs={'class':'description'}) - blurbTag.insert(0,NavigableString(title['description'])) # Write the book entry to contentdir outfile = open("%s/book_%d.html" % (self.contentDir, int(title['id'])), 'w') @@ -1854,9 +1807,17 @@ class EPUB_MOBI(CatalogPlugin): title_list = self.booksByTitle if not self.useSeriesPrefixInTitlesSection: title_list = self.booksByTitle_noSeriesPrefix + drtc = 0 + divRunningTag = None for book in title_list: if self.letter_or_symbol(book['title_sort'][0]) != current_letter : # Start a new letter + if drtc and divRunningTag is not None: + divTag.insert(dtc, divRunningTag) + dtc += 1 + divRunningTag = Tag(soup, 'div') + divRunningTag['style'] = 'display:inline-block;width:100%' + drtc = 0 current_letter = self.letter_or_symbol(book['title_sort'][0]) pIndexTag = Tag(soup, "p") pIndexTag['class'] = "letter_index" @@ -1864,8 +1825,8 @@ class EPUB_MOBI(CatalogPlugin): aTag['name'] = "%s" % self.letter_or_symbol(book['title_sort'][0]) pIndexTag.insert(0,aTag) pIndexTag.insert(1,NavigableString(self.letter_or_symbol(book['title_sort'][0]))) - divTag.insert(dtc,pIndexTag) - dtc += 1 + divRunningTag.insert(dtc,pIndexTag) + drtc += 1 # Add books pBookTag = Tag(soup, "p") @@ -1894,7 +1855,8 @@ class EPUB_MOBI(CatalogPlugin): # Link to book aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(book['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(book['id']))) aTag.insert(0,escape(book['title'])) pBookTag.insert(ptc, aTag) ptc += 1 @@ -1912,7 +1874,13 @@ class EPUB_MOBI(CatalogPlugin): pBookTag.insert(ptc, emTag) ptc += 1 - divTag.insert(dtc, pBookTag) + if divRunningTag is not None: + divRunningTag.insert(drtc, pBookTag) + drtc += 1 + + # Add the last divRunningTag to divTag + if divRunningTag is not None: + divTag.insert(dtc, divRunningTag) dtc += 1 # Add the divTag to the body @@ -1924,7 +1892,7 @@ class EPUB_MOBI(CatalogPlugin): outfile = open(outfile_spec, 'w') outfile.write(soup.prettify()) outfile.close() - self.htmlFileList.append("content/ByAlphaTitle.html") + self.htmlFileList_1.append("content/ByAlphaTitle.html") def generateHTMLByAuthor(self): # Write books by author A-Z @@ -1972,31 +1940,41 @@ class EPUB_MOBI(CatalogPlugin): # Loop through booksByAuthor book_count = 0 + divRunningTag = None for book in self.booksByAuthor: book_count += 1 if self.letter_or_symbol(book['author_sort'][0].upper()) != current_letter : - ''' - # Start a new letter - anchor only, hidden - current_letter = book['author_sort'][0].upper() - aTag = Tag(soup, "a") - aTag['name'] = "%sauthors" % current_letter - divTag.insert(dtc, aTag) - dtc += 1 - ''' # Start a new letter with Index letter current_letter = self.letter_or_symbol(book['author_sort'][0].upper()) + author_count = 0 + divOpeningTag = Tag(soup, 'div') + divOpeningTag['style'] = 'display:inline-block;width:100%' + dotc = 0 pIndexTag = Tag(soup, "p") pIndexTag['class'] = "letter_index" aTag = Tag(soup, "a") aTag['name'] = "%sauthors" % self.letter_or_symbol(current_letter) pIndexTag.insert(0,aTag) pIndexTag.insert(1,NavigableString(self.letter_or_symbol(book['author_sort'][0].upper()))) - divTag.insert(dtc,pIndexTag) - dtc += 1 + divOpeningTag.insert(dotc,pIndexTag) + dotc += 1 if book['author'] != current_author: # Start a new author current_author = book['author'] + author_count += 1 + if author_count == 2: + # Add divOpeningTag to divTag + divTag.insert(dtc, divOpeningTag) + dtc += 1 + elif author_count > 2 and divRunningTag is not None: + divTag.insert(dtc, divRunningTag) + dtc += 1 + + divRunningTag = Tag(soup, 'div') + divRunningTag['style'] = 'display:inline-block;width:100%' + drtc = 0 + non_series_books = 0 current_series = None pAuthorTag = Tag(soup, "p") @@ -2005,18 +1983,12 @@ class EPUB_MOBI(CatalogPlugin): aTag['name'] = "%s" % self.generateAuthorAnchor(current_author) aTag.insert(0,NavigableString(current_author)) pAuthorTag.insert(0,aTag) - divTag.insert(dtc,pAuthorTag) - dtc += 1 - - ''' - # Insert an
between non-series and series - if not current_series and non_series_books and book['series']: - # Insert an
- hrTag = Tag(soup,'hr') - hrTag['class'] = "series_divider" - divTag.insert(dtc,hrTag) - dtc += 1 - ''' + if author_count == 1: + divOpeningTag.insert(dotc, pAuthorTag) + dotc += 1 + elif divRunningTag is not None: + divRunningTag.insert(drtc,pAuthorTag) + drtc += 1 # Check for series if book['series'] and book['series'] != current_series: @@ -2036,8 +2008,12 @@ class EPUB_MOBI(CatalogPlugin): #pSeriesTag.insert(0,NavigableString(self.NOT_READ_SYMBOL + '%s' % book['series'])) pSeriesTag.insert(0,NavigableString('%s' % book['series'])) - divTag.insert(dtc,pSeriesTag) - dtc += 1 + if author_count == 1: + divOpeningTag.insert(dotc, pSeriesTag) + dotc += 1 + elif divRunningTag is not None: + divRunningTag.insert(drtc,pSeriesTag) + drtc += 1 if current_series and not book['series']: current_series = None @@ -2067,8 +2043,9 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(book['id']))) - # Use series, series index if avail else just title, + year of publication + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(book['id']))) + # Use series, series index if avail else title, + year of publication if current_series: aTag.insert(0,'%s (%s)' % (escape(book['title'][len(book['series'])+1:]), book['date'].split()[1])) @@ -2079,9 +2056,12 @@ class EPUB_MOBI(CatalogPlugin): pBookTag.insert(ptc, aTag) ptc += 1 - - divTag.insert(dtc, pBookTag) - dtc += 1 + if author_count == 1: + divOpeningTag.insert(dotc, pBookTag) + dotc += 1 + elif divRunningTag is not None: + divRunningTag.insert(drtc,pBookTag) + drtc += 1 if not self.__generateForKindle: # Insert the

tag with book_count at the head @@ -2097,6 +2077,10 @@ class EPUB_MOBI(CatalogPlugin): body.insert(btc,pTag) btc += 1 + if author_count == 1: + divTag.insert(dtc, divOpeningTag) + dtc += 1 + # Add the divTag to the body body.insert(btc, divTag) @@ -2106,7 +2090,7 @@ class EPUB_MOBI(CatalogPlugin): outfile = open(outfile_spec, 'w') outfile.write(soup.prettify()) outfile.close() - self.htmlFileList.append("content/ByAlphaAuthor.html") + self.htmlFileList_1.append("content/ByAlphaAuthor.html") def generateHTMLByDateAdded(self): # Write books by reverse chronological order @@ -2200,7 +2184,8 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) if current_series: aTag.insert(0,escape(new_entry['title'][len(new_entry['series'])+1:])) else: @@ -2251,7 +2236,8 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) aTag.insert(0,escape(new_entry['title'])) pBookTag.insert(ptc, aTag) ptc += 1 @@ -2379,7 +2365,7 @@ class EPUB_MOBI(CatalogPlugin): outfile = open(outfile_spec, 'w') outfile.write(soup.prettify()) outfile.close() - self.htmlFileList.append("content/ByDateAdded.html") + self.htmlFileList_2.append("content/ByDateAdded.html") def generateHTMLByDateRead(self): # Write books by active bookmarks @@ -2411,7 +2397,8 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) aTag.insert(0,escape(new_entry['title'])) pBookTag.insert(ptc, aTag) ptc += 1 @@ -2458,7 +2445,8 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(new_entry['id']))) aTag.insert(0,escape(new_entry['title'])) pBookTag.insert(ptc, aTag) ptc += 1 @@ -2568,7 +2556,7 @@ class EPUB_MOBI(CatalogPlugin): outfile = open(outfile_spec, 'w') outfile.write(soup.prettify()) outfile.close() - self.htmlFileList.append("content/ByDateRead.html") + self.htmlFileList_2.append("content/ByDateRead.html") def generateHTMLBySeries(self): ''' @@ -2599,7 +2587,9 @@ class EPUB_MOBI(CatalogPlugin): self.opts.search_text = search_phrase # Fetch the database as a dictionary - self.booksBySeries = self.plugin.search_sort_db(self.db, self.opts) + data = self.plugin.search_sort_db(self.db, self.opts) + self.booksBySeries = self.processExclusions(data) + if not self.booksBySeries: self.opts.generate_series = False self.opts.log(" no series found in selected books, cancelling series generation") @@ -2638,14 +2628,6 @@ class EPUB_MOBI(CatalogPlugin): # Check for initial letter change sort_title = self.generateSortTitle(book['series']) if self.letter_or_symbol(sort_title[0].upper()) != current_letter : - ''' - # Start a new letter - anchor only, hidden - current_letter = book['author_sort'][0].upper() - aTag = Tag(soup, "a") - aTag['name'] = "%sseries" % current_letter - divTag.insert(dtc, aTag) - dtc += 1 - ''' # Start a new letter with Index letter current_letter = self.letter_or_symbol(sort_title[0].upper()) pIndexTag = Tag(soup, "p") @@ -2699,7 +2681,8 @@ class EPUB_MOBI(CatalogPlugin): ptc += 1 aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(book['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(book['id']))) # Use series, series index if avail else just title #aTag.insert(0,'%d. %s · %s' % (book['series_index'],escape(book['title']), ' & '.join(book['authors']))) @@ -2747,7 +2730,7 @@ class EPUB_MOBI(CatalogPlugin): outfile = open(outfile_spec, 'w') outfile.write(soup.prettify()) outfile.close() - self.htmlFileList.append("content/BySeries.html") + self.htmlFileList_1.append("content/BySeries.html") def generateHTMLByTags(self): # Generate individual HTML files for each tag, e.g. Fiction, Nonfiction ... @@ -2922,6 +2905,11 @@ class EPUB_MOBI(CatalogPlugin): title['cover'] = cover self.generateThumbnail(title, image_dir, "thumbnail_default.jpg") + # Write the thumb_width to the file validating cache contents + # Allows detection of aborted catalog builds + with closing(ZipFile(self.__archive_path, mode='a'))as zfw: + zfw.writestr('thumb_width', self.opts.thumb_width) + self.thumbs = thumbs def generateOPF(self): @@ -2983,22 +2971,25 @@ class EPUB_MOBI(CatalogPlugin): manifest.insert(mtc, itemTag) mtc += 1 - # Write the thumbnail images to the manifest - for thumb in self.thumbs: - itemTag = Tag(soup, "item") - itemTag['href'] = "images/%s" % (thumb) - end = thumb.find('.jpg') - itemTag['id'] = "%s-image" % thumb[:end] - itemTag['media-type'] = 'image/jpeg' - manifest.insert(mtc, itemTag) - mtc += 1 + # Write the thumbnail images, descriptions to the manifest + sort_descriptions_by = [] + if self.opts.generate_descriptions: + for thumb in self.thumbs: + itemTag = Tag(soup, "item") + itemTag['href'] = "images/%s" % (thumb) + end = thumb.find('.jpg') + itemTag['id'] = "%s-image" % thumb[:end] + itemTag['media-type'] = 'image/jpeg' + manifest.insert(mtc, itemTag) + mtc += 1 - # HTML files - add books to manifest and spine - sort_descriptions_by = self.booksByAuthor if self.opts.sort_descriptions_by_author \ + # HTML files - add descriptions to manifest and spine + sort_descriptions_by = self.booksByAuthor if self.opts.sort_descriptions_by_author \ else self.booksByTitle # Add html_files to manifest and spine - for file in self.htmlFileList: + for file in self.htmlFileList_1: + # By Author, By Title, By Series, itemTag = Tag(soup, "item") start = file.find('/') + 1 end = file.find('.') @@ -3032,6 +3023,23 @@ class EPUB_MOBI(CatalogPlugin): spine.insert(stc, itemrefTag) stc += 1 + for file in self.htmlFileList_2: + # By Date Added, By Date Read + itemTag = Tag(soup, "item") + start = file.find('/') + 1 + end = file.find('.') + itemTag['href'] = file + itemTag['id'] = file[start:end].lower() + itemTag['media-type'] = "application/xhtml+xml" + manifest.insert(mtc, itemTag) + mtc += 1 + + # spine + itemrefTag = Tag(soup, "itemref") + itemrefTag['idref'] = file[start:end].lower() + spine.insert(stc, itemrefTag) + stc += 1 + for book in sort_descriptions_by: # manifest itemTag = Tag(soup, "item") @@ -3970,15 +3978,15 @@ class EPUB_MOBI(CatalogPlugin): from calibre.customize.ui import output_profiles for x in output_profiles(): if x.short_name == self.opts.output_profile: - # .9" width aspect ratio: 3:4 - self.thumbWidth = int(x.dpi * 1) - self.thumbHeight = int(self.thumbWidth * 1.33) + # aspect ratio: 3:4 + self.thumbWidth = x.dpi * float(self.opts.thumb_width) + self.thumbHeight = self.thumbWidth * 1.33 if 'kindle' in x.short_name and self.opts.fmt == 'mobi': # Kindle DPI appears to be off by a factor of 2 - self.thumbWidth = int(self.thumbWidth/2) - self.thumbHeight = int(self.thumbHeight/2) + self.thumbWidth = self.thumbWidth/2 + self.thumbHeight = self.thumbHeight/2 break - if False and self.verbose: + if True and self.verbose: self.opts.log(" DPI = %d; thumbnail dimensions: %d x %d" % \ (x.dpi, self.thumbWidth, self.thumbHeight)) @@ -4043,9 +4051,13 @@ class EPUB_MOBI(CatalogPlugin): field, index_is_id=True) if field_contents: - if re.search(pat, unicode(field_contents), - re.IGNORECASE) is not None: - return True + try: + if re.search(pat, unicode(field_contents), + re.IGNORECASE) is not None: + return True + except: + # Compiling of pat failed, ignore it + pass return False @@ -4238,7 +4250,8 @@ class EPUB_MOBI(CatalogPlugin): # Add the book title aTag = Tag(soup, "a") - aTag['href'] = "book_%d.html" % (int(float(book['id']))) + if self.opts.generate_descriptions: + aTag['href'] = "book_%d.html" % (int(float(book['id']))) # Use series, series index if avail else just title if current_series: aTag.insert(0,escape(book['title'][len(book['series'])+1:])) @@ -4263,59 +4276,196 @@ class EPUB_MOBI(CatalogPlugin): return titles_spanned - def generateHTMLDescriptionHeader(self, title): + def generateHTMLDescriptionHeader(self, book): + ''' + Generate description header from template + ''' + NBSP = ' ' + MIDDOT = '·' + def generate_html(): + args = dict( + author=author, + author_prefix=author_prefix, + css=css, + formats=formats, + genres=genres, + note_content=note_content, + note_source=note_source, + pubdate=pubdate, + publisher=publisher, + pubmonth=pubmonth, + pubyear=pubyear, + rating=rating, + series=series, + series_index=series_index, + title=title, + title_str=title_str, + xmlns=XHTML_NS, + ) - title_border = '' if self.opts.fmt == 'epub' else \ - '
' - header = ''' - - - - - - - - -

- {0} -

- -

 

-

 

- - - - - - - - - - - - - - - - - - - - - - - -
 
 
 
-
-
- - - '''.format(title_border) + generated_html = P('catalog/template.xhtml', + data=True).decode('utf-8').format(**args) - # Insert the supplied title + soup = BeautifulSoup(generated_html) + return soup.renderContents(None) + + if False: + print "title metadata:\n%s" % ', '.join(sorted(book.keys())) + if False: + for item in sorted(book.keys()): + try: + print "%s: %s%s" % (item, book[item][:50], '...' if len(book[item])>50 else '') + except: + print "%s: %s" % (item, book[item]) + + # Generate the template arguments + css = P('catalog/stylesheet.css', data=True).decode('utf-8') + title_str = escape(book['title']) + + # Title/series + if book['series']: + series_id, _, title = book['title'].partition(':') + title = escape(title.strip()) + series = escape(book['series']) + series_index = str(book['series_index']) + if series_index.endswith('.0'): + series_index = series_index[:-2] + else: + title = escape(book['title']) + series = '' + series_index = '' + + # Author, author_prefix (read|reading|none symbol or missing symbol) + author = book['author'] + if self.opts.wishlist_tag in book.get('tags', []): + author_prefix = self.MISSING_SYMBOL + " by " + else: + if book['read']: + author_prefix = self.READ_SYMBOL + " by " + elif self.opts.connected_kindle and book['id'] in self.bookmarked_books: + author_prefix = self.READING_SYMBOL + " by " + else: + author_prefix = "by " + + # Genres + genres = '' + if 'tags' in book: + _soup = BeautifulSoup('') + genresTag = Tag(_soup,'p') + gtc = 0 + for (i, tag) in enumerate(book.get('tags', [])): + aTag = Tag(_soup,'a') + if self.opts.generate_genres: + aTag['href'] = "Genre_%s.html" % re.sub("\W","",tag.lower()) + aTag.insert(0,escape(NavigableString(tag))) + genresTag.insert(gtc, aTag) + gtc += 1 + if i < len(book['tags'])-1: + genresTag.insert(gtc, NavigableString(' %s ' % MIDDOT)) + gtc += 1 + genres = genresTag.renderContents() + + # Formats + formats = [] + if 'formats' in book: + for format in sorted(book['formats']): + formats.append(format.rpartition('.')[2].upper()) + formats = ' %s ' % MIDDOT.join(formats) + + pubdate = book['date'] + pubmonth, pubyear = pubdate.split(' ') + + ''' + # Thumb + # This doesn't make it through the etree.fromstring parsing + _soup = BeautifulSoup('') + imgTag = Tag(_soup,"img") + if 'cover' in book: + imgTag['src'] = "../images/thumbnail_%d.jpg" % int(book['id']) + else: + imgTag['src'] = "../images/thumbnail_default.jpg" + imgTag['alt'] = "cover thumbnail" + thumb = imgTag.renderContents() + ''' + + # Publisher + publisher = NBSP + if 'publisher' in book: + publisher = book['publisher'] + + # Rating + stars = int(book['rating']) / 2 + rating = NBSP + if stars: + star_string = self.FULL_RATING_SYMBOL * stars + empty_stars = self.EMPTY_RATING_SYMBOL * (5 - stars) + rating = '%s%s
' % (star_string,empty_stars) + + # Notes + note_source = NBSP + note_content = NBSP + if 'notes' in book: + note_source = book['notes']['source'] + note_content = book['notes']['content'] + + # >>>> Populate the template <<<< + root = etree.fromstring(generate_html(), parser=RECOVER_PARSER) + header = etree.tostring(root, pretty_print=True, encoding='utf-8') soup = BeautifulSoup(header, selfClosingTags=['mbp:pagebreak']) - titleTag = soup.find('title') - titleTag.insert(0,NavigableString(escape(title))) + + + # >>>> Post-process the template <<<< + body = soup.find('body') + btc = 0 + # Insert the title anchor for inbound links + aTag = Tag(soup, "a") + aTag['name'] = "book%d" % int(book['id']) + body.insert(btc, aTag) + btc += 1 + + # Insert the link to the series or remove + aTag = body.find('a', attrs={'class':'series_id'}) + if book['series']: + if self.opts.generate_series: + aTag['href'] = "%s.html#%s_series" % ('BySeries', + re.sub('\W','',book['series']).lower()) + else: + aTag.extract() + + # Insert the author link (always) + aTag = body.find('a', attrs={'class':'author'}) + aTag['href'] = "%s.html#%s" % ("ByAlphaAuthor", + self.generateAuthorAnchor(book['author'])) + + if not genres: + genresTag = body.find('p',attrs={'class':'genres'}) + genresTag.extract() + + if not formats: + formatsTag = body.find('p',attrs={'class':'formats'}) + formatsTag.extract() + + if note_content == NBSP: + tdTag = body.find('td', attrs={'class':'notes'}) + tdTag.contents[0].replaceWith(NBSP) + + # Cover thumb + tdTag = body.find('td', attrs={'class':'thumbnail'}) + imgTag = Tag(soup,"img") + if 'cover' in book: + imgTag['src'] = "../images/thumbnail_%d.jpg" % int(book['id']) + else: + imgTag['src'] = "../images/thumbnail_default.jpg" + imgTag['alt'] = "cover thumbnail" + tdTag.insert(0,imgTag) + + # The Blurb + if 'description' in book and book['description'] > '': + blurbTag = body.find(attrs={'class':'description'}) + blurbTag.insert(0,NavigableString(book['description'])) + + if False: + print soup.prettify() return soup def generateHTMLEmptyHeader(self, title): @@ -4348,7 +4498,6 @@ class EPUB_MOBI(CatalogPlugin):

-
@@ -4460,7 +4609,9 @@ class EPUB_MOBI(CatalogPlugin): # Leading numbers optionally translated to text equivalent # Capitalize leading sort word if i==0: - if self.opts.numbers_as_text and re.match('[0-9]+',word[0]): + # *** Keep this code in case we need to restore numbers_as_text *** + if False: + #if self.opts.numbers_as_text and re.match('[0-9]+',word[0]): translated.append(EPUB_MOBI.NumberToText(word).text.capitalize()) else: if re.match('[0-9]+',word[0]): @@ -4540,7 +4691,6 @@ class EPUB_MOBI(CatalogPlugin): ''' Return a list of special marker tags to be excluded from genre list ''' markerTags = [] markerTags.extend(self.opts.exclude_tags.split(',')) - markerTags.extend(self.opts.note_tag.split(',')) return markerTags def letter_or_symbol(self,char): @@ -4663,13 +4813,63 @@ class EPUB_MOBI(CatalogPlugin): return result.renderContents(encoding=None) + def mergeComments(self, record): + ''' + merge ['description'] with custom field contents to be displayed in Descriptions + ''' + merged = '' + if record['description']: + addendum = self.__db.get_field(record['id'], + self.__merge_comments['field'], + index_is_id=True) + include_hr = eval(self.__merge_comments['hr']) + if self.__merge_comments['position'] == 'before': + merged = addendum + if include_hr: + merged += '
' + else: + merged += '\n' + merged += record['description'] + else: + merged = record['description'] + if include_hr: + merged += '
' + else: + merged += '\n' + merged += addendum + else: + # Return the custom field contents + merged = self.__db.get_field(record['id'], + self.__merge_comments['field'], + index_is_id=True) + + return merged + + def processExclusions(self, data_set): + ''' + Remove excluded entries + ''' + field, pat = self.opts.exclude_book_marker.split(':') + if pat == '': + return data_set + filtered_data_set = [] + for record in data_set: + field_contents = self.__db.get_field(record['id'], + field, + index_is_id=True) + if field_contents: + if re.search(pat, unicode(field_contents), + re.IGNORECASE) is not None: + continue + filtered_data_set.append(record) + + return filtered_data_set + def processSpecialTags(self, tags, this_title, opts): tag_list = [] for tag in tags: tag = self.convertHTMLEntities(tag) - if tag.startswith(opts.note_tag): - this_title['notes'] = tag[len(self.opts.note_tag):] - elif re.search(opts.exclude_genre, tag): + if re.search(opts.exclude_genre, tag): continue elif self.__read_book_marker['field'] == 'tag' and \ tag == self.__read_book_marker['pattern']: @@ -4767,26 +4967,43 @@ class EPUB_MOBI(CatalogPlugin): if opts_dict['ids']: build_log.append(" book count: %d" % len(opts_dict['ids'])) - sections_list = ['Descriptions','Authors'] + sections_list = ['Authors'] if opts.generate_titles: sections_list.append('Titles') if opts.generate_recently_added: sections_list.append('Recently Added') - if not opts.exclude_genre.strip() == '.': + if opts.generate_genres: sections_list.append('Genres') + if opts.generate_descriptions: + sections_list.append('Descriptions') + build_log.append(u" Sections: %s" % ', '.join(sections_list)) + # Limit thumb_width to 1.0" - 2.0" + try: + if float(opts.thumb_width) < float(self.THUMB_SMALLEST): + log.warning("coercing thumb_width from '%s' to '%s'" % (opts.thumb_width,self.THUMB_SMALLEST)) + opts.thumb_width = self.THUMB_SMALLEST + if float(opts.thumb_width) > float(self.THUMB_LARGEST): + log.warning("coercing thumb_width from '%s' to '%s'" % (opts.thumb_width,self.THUMB_LARGEST)) + opts.thumb_width = self.THUMB_LARGEST + opts.thumb_width = "%.2f" % float(opts.thumb_width) + except: + log.error("coercing thumb_width from '%s' to '%s'" % (opts.thumb_width,self.THUMB_SMALLEST)) + opts.thumb_width = "1.0" + # Display opts keys = opts_dict.keys() keys.sort() build_log.append(" opts:") for key in keys: if key in ['catalog_title','authorClip','connected_kindle','descriptionClip', - 'exclude_genre','exclude_tags','note_tag','numbers_as_text', + 'exclude_book_marker','exclude_genre','exclude_tags', + 'header_note_source_field','merge_comments', 'output_profile','read_book_marker', 'search_text','sort_by','sort_descriptions_by_author','sync', - 'wishlist_tag']: - build_log.append(" %s: %s" % (key, opts_dict[key])) + 'thumb_width','wishlist_tag']: + build_log.append(" %s: %s" % (key, repr(opts_dict[key]))) if opts.verbose: log('\n'.join(line for line in build_log)) @@ -4801,6 +5018,7 @@ class EPUB_MOBI(CatalogPlugin): catalog.copyResources() catalog.calculateThumbnailSize() catalog_source_built = catalog.buildSources() + if opts.verbose: if catalog_source_built: log.info(" Completed catalog source generation\n") diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index c50d1669e5..cbda615677 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -6,7 +6,7 @@ __docformat__ = 'restructuredtext en' ''' The database used to store ebook metadata ''' -import os, sys, shutil, cStringIO, glob, time, functools, traceback, re +import os, sys, shutil, cStringIO, glob, time, functools, traceback, re, json from itertools import repeat from math import ceil from Queue import Queue @@ -32,7 +32,7 @@ from calibre.customize.ui import run_plugins_on_import from calibre import isbytestring from calibre.utils.filenames import ascii_filename from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp -from calibre.utils.config import prefs, tweaks +from calibre.utils.config import prefs, tweaks, from_json, to_json from calibre.utils.icu import sort_key from calibre.utils.search_query_parser import saved_searches, set_saved_searches from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format @@ -1243,7 +1243,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): else: icon = icon_map[':custom'] icon_map[category] = icon - tooltip = self.custom_column_label_map[label]['name'] datatype = cat['datatype'] avgr = lambda x: 0.0 if x.rc == 0 else x.rt/x.rc @@ -2700,6 +2699,38 @@ books_series_link feeds return duplicates + def add_custom_book_data(self, book_id, name, val): + x = self.conn.get('SELECT id FROM books WHERE ID=?', (book_id,), all=False) + if x is None: + raise ValueError('add_custom_book_data: no such book_id %d'%book_id) + # Do the json encode first, in case it throws an exception + s = json.dumps(val, default=to_json) + self.conn.execute('DELETE FROM books_plugin_data WHERE book=? AND name=?', + (book_id, name)) + self.conn.execute('''INSERT INTO books_plugin_data(book, name, val) + VALUES(?, ?, ?)''', (book_id, name, s)) + self.commit() + + def get_custom_book_data(self, book_id, name, default=None): + try: + s = self.conn.get('''select val FROM books_plugin_data + WHERE book=? AND name=?''', (book_id, name), all=False) + if s is None: + return default + return json.loads(s, object_hook=from_json) + except: + pass + return default + + def delete_custom_book_data(self, book_id, name): + self.conn.execute('DELETE FROM books_plugin_data WHERE book=? AND name=?', + (book_id, name)) + self.commit() + + def get_ids_for_custom_book_data(self, name): + s = self.conn.get('''SELECT book FROM books_plugin_data WHERE name=?''', (name,)) + return [x[0] for x in s] + def get_custom_recipes(self): for id, title, script in self.conn.get('SELECT id,title,script FROM feeds'): yield id, title, script diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py index 1483743e4a..0b7a3f5350 100644 --- a/src/calibre/library/schema_upgrades.py +++ b/src/calibre/library/schema_upgrades.py @@ -441,3 +441,31 @@ class SchemaUpgrade(object): WHERE id=NEW.id AND OLD.title <> NEW.title; END; ''') + + def upgrade_version_17(self): + 'custom book data table (for plugins)' + script = ''' + DROP TABLE IF EXISTS books_plugin_data; + CREATE TABLE books_plugin_data(id INTEGER PRIMARY KEY, + book INTEGER NON NULL, + name TEXT NON NULL, + val TEXT NON NULL, + UNIQUE(book,name)); + DROP TRIGGER IF EXISTS books_delete_trg; + CREATE TRIGGER books_delete_trg + AFTER DELETE ON books + BEGIN + DELETE FROM books_authors_link WHERE book=OLD.id; + DELETE FROM books_publishers_link WHERE book=OLD.id; + DELETE FROM books_ratings_link WHERE book=OLD.id; + DELETE FROM books_series_link WHERE book=OLD.id; + DELETE FROM books_tags_link WHERE book=OLD.id; + DELETE FROM data WHERE book=OLD.id; + DELETE FROM comments WHERE book=OLD.id; + DELETE FROM conversion_options WHERE book=OLD.id; + DELETE FROM books_plugin_data WHERE book=OLD.id; + END; + ''' + self.conn.executescript(script) + + diff --git a/src/calibre/manual/customize.rst b/src/calibre/manual/customize.rst index d7b4e931d9..6218bf8112 100644 --- a/src/calibre/manual/customize.rst +++ b/src/calibre/manual/customize.rst @@ -21,6 +21,7 @@ Environment variables ----------------------- * ``CALIBRE_CONFIG_DIRECTORY`` - sets the directory where configuration files are stored/read. + * ``CALIBRE_TEMP_DIR`` - sets the temporary directory used by calibre * ``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) diff --git a/src/calibre/manual/template_lang.rst b/src/calibre/manual/template_lang.rst index 0f3e543bee..8a3bd854b1 100644 --- a/src/calibre/manual/template_lang.rst +++ b/src/calibre/manual/template_lang.rst @@ -137,8 +137,8 @@ Note that you can use the prefix and suffix as well. If you want the number to a {#myint:0>3s:ifempty(0)|[|]} -Using functions in templates - program mode -------------------------------------------- +Using functions in templates - template program mode +---------------------------------------------------- The template language program mode differs from single-function mode in that it permits you to write template expressions that refer to other metadata fields, modify values, and do arithmetic. It is a reasonably complete programming language. @@ -161,10 +161,13 @@ The syntax of the language is shown by the following grammar:: constant ::= " string " | ' string ' | number identifier ::= sequence of letters or ``_`` characters function ::= identifier ( statement [ , statement ]* ) - expression ::= identifier | constant | function + expression ::= identifier | constant | function | assignment + assignment ::= identifier '=' expression statement ::= expression [ ; expression ]* program ::= statement +Comments are lines with a '#' character at the beginning of the line. + An ``expression`` always has a value, either the value of the constant, the value contained in the identifier, or the value returned by a function. The value of a ``statement`` is the value of the last expression in the sequence of statements. As such, the value of the program (statement):: 1; 2; 'foobar'; 3 @@ -208,13 +211,102 @@ The following functions are available in addition to those described in single-f * ``cmp(x, y, lt, eq, gt)`` -- compares x and y after converting both to numbers. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``. * ``divide(x, y)`` -- returns x / y. Throws an exception if either x or y are not numbers. * ``field(name)`` -- returns the metadata field named by ``name``. + * ``eval(string)`` -- evaluates the string as a program, passing the local variables (those ``assign`` ed to). This permits using the template processor to construct complex results from local variables. * ``multiply(x, y)`` -- returns x * y. Throws an exception if either x or y are not numbers. + * ``print(a, b, ...)`` -- prints the arguments to standard output. Unless you start calibre from the command line (``calibre-debug -g``), the output will go to a black hole. * ``strcat(a, b, ...)`` -- can take any number of arguments. Returns a string formed by concatenating all the arguments. * ``strcmp(x, y, lt, eq, gt)`` -- does a case-insensitive comparison x and y as strings. Returns ``lt`` if x < y. Returns ``eq`` if x == y. Otherwise returns ``gt``. * ``substr(str, start, end)`` -- returns the ``start``'th through the ``end``'th characters of ``str``. The first character in ``str`` is the zero'th character. If end is negative, then it indicates that many characters counting from the right. If end is zero, then it indicates the last character. For example, ``substr('12345', 1, 0)`` returns ``'2345'``, and ``substr('12345', 1, -1)`` returns ``'234'``. * ``subtract(x, y)`` -- returns x - y. Throws an exception if either x or y are not numbers. * ``template(x)`` -- evaluates x as a template. The evaluation is done in its own context, meaning that variables are not shared between the caller and the template evaluation. Because the `{` and `}` characters are special, you must use `[[` for the `{` character and `]]` for the '}' character; they are converted automatically. For example, ``template('[[title_sort]]') will evaluate the template ``{title_sort}`` and return its value. - + +Using general program mode +----------------------------------- + +For more complicated template programs, it is sometimes easier to avoid template syntax (all the `{` and `}` characters), instead writing a more classical-looking program. You can do this in |app| by beginning the template with `program:`. In this case, no template processing is done. The special variable `$` is not set. It is up to your program to produce the correct results. + +One advantage of `program:` mode is that the brackets are no longer special. For example, it is not necessary to use `[[` and `]]` when using the `template()` function. + +The following example is a `program:` mode implementation of a recipe on the MobileRead forum: "Put series into the title, using either initials or a shortened form. Strip leading articles from the series name (any)." For example, for the book The Two Towers in the Lord of the Rings series, the recipe gives `LotR [02] The Two Towers`. Using standard templates, the recipe requires three custom columns and a plugboard, as explained in the following: + +The solution requires creating three composite columns. The first column is used to remove the leading articles. The second is used to compute the 'shorten' form. The third is to compute the 'initials' form. Once you have these columns, the plugboard selects between them. You can hide any or all of the three columns on the library view. + + First column: + Name: #stripped_series. + Template: {series:re(^(A|The|An)\s+,)||} + + Second column (the shortened form): + Name: #shortened. + Template: {#stripped_series:shorten(4,-,4)} + + Third column (the initials form): + Name: #initials. + Template: {#stripped_series:re(([^\s])[^\s]+(\s|$),\1)} + + Plugboard expression: + Template:{#stripped_series:lookup(.\s,#initials,.,#shortened,series)}{series_index:0>2.0f| [|] }{title} + Destination field: title + + This set of fields and plugboard produces: + Series: The Lord of the Rings + Series index: 2 + Title: The Two Towers + Output: LotR [02] The Two Towers + + Series: Dahak + Series index: 1 + Title: Mutineers Moon + Output: Dahak [01] Mutineers Moon + + Series: Berserkers + Series Index: 4 + Title: Berserker Throne + Output: Bers-kers [04] Berserker Throne + + Series: Meg Langslow Mysteries + Series Index: 3 + Title: Revenge of the Wrought-Iron Flamingos + Output: MLM [03] Revenge of the Wrought-Iron Flamingos + +The following program produces the same results as the original recipe, using only one custom column to hold the results of a program that computes the special title value:: + + Custom column: + Name: #special_title + Template: (the following with all leading spaces removed) + program: + # compute the equivalent of the composite fields and store them in local variables + stripped = re(field('series'), '^(A|The|An)\s+', ''); + shortened = shorten(stripped, 4, '-' ,4); + initials = re(stripped, '[^\w]*(\w?)[^\s]+(\s|$)', '\1'); + + # Format the series index. Ends up as empty if there is no series index. + # Note that leading and trailing spaces will be removed by the formatter, + # so we cannot add them here. We will do that in the strcat below. + # Also note that because we are in 'program' mode, we can freely use + # curly brackets in strings, something we cannot do in template mode. + s_index = template('{series_index:0>2.0f}'); + + # print(stripped, shortened, initials, s_index); + + # Now concatenate all the bits together. The switch picks between + # initials and shortened, depending on whether there is a space + # in stripped. We then add the brackets around s_index if it is + # not empty. Finally, add the title. As this is the last function in + # the program, its value will be returned. + strcat( + switch( stripped, + '.\s', initials, + '.', shortened, + field('series')), + test(s_index, strcat(' [', s_index, '] '), ''), + field('title')); + + Plugboard expression: + Template:{#special_title} + Destination field: title + +It would be possible to do the above with no custom columns by putting the program into the template box of the plugboard. However, to do so, all comments must be removed because the plugboard text box does not support multi-line editing. It is debatable whether the gain of not having the custom column is worth the vast increase in difficulty caused by the program being one giant line. + Special notes for save/send templates ------------------------------------- @@ -257,4 +349,4 @@ You might find the following tips useful. * Templates can use other templates by referencing a composite custom column. * In a plugboard, you can set a field to empty (or whatever is equivalent to empty) by using the special template ``{null}``. This template will always evaluate to an empty string. * The technique described above to show numbers even if they have a zero value works with the standard field series_index. - \ No newline at end of file + diff --git a/src/calibre/ptempfile.py b/src/calibre/ptempfile.py index 71ae9b0789..ac7df1c4e3 100644 --- a/src/calibre/ptempfile.py +++ b/src/calibre/ptempfile.py @@ -40,7 +40,7 @@ def base_dir(): _base_dir = td else: _base_dir = tempfile.mkdtemp(prefix='%s_%s_tmp_'%(__appname__, - __version__)) + __version__), dir=os.environ.get('CALIBRE_TEMP_DIR', None)) atexit.register(remove_dir, _base_dir) return _base_dir diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 8936befa95..4fe8ad2e4f 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -66,6 +66,10 @@ class _Parser(object): template = template.replace('[[', '{').replace(']]', '}') return eval_formatter.safe_format(template, self.variables, 'EVAL', None) + def _print(self, *args): + print args + return None + local_functions = { 'add' : (2, partial(_math, op='+')), 'assign' : (2, _assign), @@ -74,6 +78,7 @@ class _Parser(object): 'eval' : (1, _eval), 'field' : (1, lambda s, x: s.parent.get_value(x, [], s.parent.kwargs)), 'multiply' : (2, partial(_math, op='*')), + 'print' : (-1, _print), 'strcat' : (-1, _concat), 'strcmp' : (5, _strcmp), 'substr' : (3, lambda s, x, y, z: x[int(y): len(x) if int(z) == 0 else int(z)]), @@ -143,12 +148,18 @@ class _Parser(object): if not self.token_op_is_a(';'): return val self.consume() + if self.token_is_eof(): + return val def expr(self): if self.token_is_id(): # We have an identifier. Determine if it is a function id = self.token() if not self.token_op_is_a('('): + if self.token_op_is_a('='): + # classic assignment statement + self.consume() + return self._assign(id, self.expr()) return self.variables.get(id, _('unknown id ') + id) # We have a function. # Check if it is a known one. We do this here so error reporting is @@ -339,6 +350,7 @@ class TemplateFormatter(string.Formatter): (r'\w+', lambda x,t: (2, t)), (r'".*?((?