diff --git a/icons/library.icns b/icons/library.icns index 39813eb8e6..1b796e2fe0 100644 Binary files a/icons/library.icns and b/icons/library.icns differ diff --git a/icons/library.ico b/icons/library.ico index 433b4f2d51..32ce8b5d0d 100644 Binary files a/icons/library.ico and b/icons/library.ico differ diff --git a/resources/images/devices/folder.svg b/resources/images/devices/folder.svg index 74c1d628e4..e0d6f6b8be 100644 --- a/resources/images/devices/folder.svg +++ b/resources/images/devices/folder.svg @@ -9,544 +9,700 @@ xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - version="1.0" - x="0.0000000" - y="0.0000000" - width="48.000000px" - height="48.000000px" - id="svg1" + width="128" + height="128" + id="svg2811" sodipodi:version="0.32" - inkscape:version="0.44" - sodipodi:docname="folder.svg" - sodipodi:docbase="/home/lapo/Icone/Crux/crux-icon-theme/scalable/places" - inkscape:export-filename="/home/lapo/Icone/Crux/folderx-daritaliare.png" - inkscape:export-xdpi="90" - inkscape:export-ydpi="90" - inkscape:output_extension="org.inkscape.output.svg.inkscape"> + inkscape:version="0.45.1" + version="1.0" + sodipodi:docname="folder-downloads.svgz" + inkscape:output_extension="org.inkscape.output.svgz.inkscape" + inkscape:export-filename="folder-downloads.png" + inkscape:export-xdpi="11.25" + inkscape:export-ydpi="11.25" + sodipodi:docbase="/home/david/oxygen/trunk/scalable/places"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id="metadata2816"> image/svg+xml - Folder - - - Lapo Calamandrei - - - 2006-06-26 - - - - - folder - directory - storage - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="layer1"> + - + id="g17" + style="opacity:0.6;filter:url(#filter2807)" + transform="matrix(1.0033404,0,0,1,-8.2374684,8)"> - + d="M 132,96 C 132,98.2 128.4,100 124,100 L 20,100 C 15.6,100 12,98.2 12,96 C 12,93.8 15.6,92 20,92 L 124,92 C 128.4,92 132,93.8 132,96 z" + id="path19" /> + + + + + + id="g2450" + inkscape:label="Livello 1" + transform="translate(-2.4797995e-7,16)"> - - - - - - - - - + d="M 88,103.99999 C 88,106.20914 77.254827,108 63.999997,108 C 50.745166,108 40,106.20914 40,103.99999 C 40,101.79086 50.745166,100 63.999997,100 C 77.254827,100 88,101.79086 88,103.99999 L 88,103.99999 z " + style="opacity:0.3;fill:url(#radialGradient4545);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:4;stroke-opacity:1" + id="path4543" /> + transform="matrix(1,0,0,0.9756098,-72.426501,80.585366)" + id="g9589"> + style="fill:url(#linearGradient9614)" + d="M 122.40803,-58 C 121.11995,-58 120.07225,-56.801009 120.07225,-55.327765 C 120.07225,-55.327765 120.07225,-20.243475 120.07225,-17.752469 C 118.07565,-17.752469 105.76289,-17.752469 105.76289,-17.752469 C 104.82364,-17.752469 103.98018,-17.113701 103.61327,-16.125567 C 103.61266,-16.122801 103.4265,-15.080231 103.4265,-15.080231 C 103.4265,-14.384954 103.65845,-13.726198 104.08019,-13.22662 L 134.74308,23.18138 C 135.18048,23.70094 135.7944,24 136.42579,24 C 137.05778,24 137.6705,23.70094 138.1085,23.18138 L 168.772,-13.22662 C 169.42327,-14.000449 169.60703,-15.136741 169.23953,-16.125567 C 168.87081,-17.115074 168.02735,-17.751777 167.08929,-17.751777 C 167.08929,-17.751777 154.77654,-17.751777 152.77994,-17.751777 C 152.77994,-20.243475 152.77994,-55.327076 152.77994,-55.327076 C 152.77933,-56.801009 151.73164,-58 150.44355,-58 L 122.40803,-58 z " + id="path49" /> + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + style="opacity:0.5;fill:#ffffff" + enable-background="new " + d="M 152.77933,-15.331057 C 154.77534,-15.331057 167.08869,-15.331057 167.08869,-15.331057 C 168.02794,-15.331057 168.87021,-14.692971 169.23832,-13.704155 C 169.2594,-13.646273 169.27387,-13.585625 169.29193,-13.527062 C 169.48051,-14.144468 169.47147,-14.830788 169.23832,-15.457162 C 168.87021,-16.445977 168.02614,-17.084064 167.08869,-17.084064 C 167.08869,-17.084064 154.77534,-17.084064 152.77933,-17.084064 C 152.77933,-16.201364 152.77933,-15.589458 152.77933,-15.331057 z " + id="path74" /> + + + + + + + + diff --git a/resources/images/library.png b/resources/images/library.png index bd3b90bfb1..cd2c9075b6 100644 Binary files a/resources/images/library.png and b/resources/images/library.png differ diff --git a/resources/images/news/akter.png b/resources/images/news/akter.png new file mode 100644 index 0000000000..60c352849e Binary files /dev/null and b/resources/images/news/akter.png differ diff --git a/resources/images/user_profile.svg b/resources/images/user_profile.svg index 2fc0eea150..0aecc0c1f7 100644 --- a/resources/images/user_profile.svg +++ b/resources/images/user_profile.svg @@ -1,6 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + inkscape:version="0.46" + version="1.0" + sodipodi:docname="im-user.svgz" + inkscape:output_extension="org.inkscape.output.svgz.inkscape" + sodipodi:docbase="/home/pinheiro/pics/oxygen/scalable/mimetypes" + inkscape:export-filename="/home/pinheiro/pics/oxygen/scalable/actions/im-user.png" + inkscape:export-xdpi="180" + inkscape:export-ydpi="180"> + inkscape:current-layer="layer1" + width="128px" + height="128px" + showgrid="false" + inkscape:grid-points="true" + showguides="true" + inkscape:guide-bbox="true" + inkscape:window-width="1016" + inkscape:window-height="692" + inkscape:window-x="20" + inkscape:window-y="356"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id="metadata2611"> image/svg+xml - - - - Jakub Steiner - - - http://jimmac.musichall.cz - - - user - person - - - - - - - - - - - - - - - - - - - - - + inkscape:label="Livello 1"> + style="fill:#493a3a;fill-opacity:1;stroke:none;stroke-width:3.1559999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter3766)" + id="path3716" + sodipodi:cx="63.865829" + sodipodi:cy="108.4109" + sodipodi:rx="41.861637" + sodipodi:ry="13.953878" + d="M 105.72747,108.4109 A 41.861637,13.953878 0 1 1 22.004192,108.4109 A 41.861637,13.953878 0 1 1 105.72747,108.4109 z" + transform="matrix(0.8610583,0,0,1.1808092,8.9931858,-19.441249)" /> + + + + + + + + + style="fill:url(#radialGradient4322);fill-opacity:1;stroke:none;stroke-width:3.40000010000000019;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" + id="path2563" + sodipodi:cx="58.041332" + sodipodi:cy="37.27911" + sodipodi:rx="29.958668" + sodipodi:ry="29.958668" + d="M 88,37.27911 A 29.958668,29.958668 0 1 1 28.082664,37.27911 A 29.958668,29.958668 0 1 1 88,37.27911 z" + transform="matrix(0.844028,0,0,0.844028,14.927656,-4.6758122)" /> + + + + + + diff --git a/resources/recipes/akter.recipe b/resources/recipes/akter.recipe new file mode 100644 index 0000000000..3959fff717 --- /dev/null +++ b/resources/recipes/akter.recipe @@ -0,0 +1,78 @@ +__license__ = 'GPL v3' +__copyright__ = '2010, Darko Miletic ' +''' +akter.co.rs +''' + +import re +from calibre.web.feeds.news import BasicNewsRecipe + +class Akter(BasicNewsRecipe): + title = 'AKTER' + __author__ = 'Darko Miletic' + description = 'AKTER - nedeljni politicki magazin savremene Srbije' + publisher = 'Akter Media Group d.o.o.' + category = 'vesti, online vesti, najnovije vesti, politika, sport, ekonomija, biznis, finansije, berza, kultura, zivot, putovanja, auto, automobili, tehnologija, politicki magazin, dogadjaji, desavanja, lifestyle, zdravlje, zdravstvo, vest, novine, nedeljnik, srbija, novi sad, vojvodina, svet, drustvo, zabava, republika srpska, beograd, intervju, komentar, reportaza, arhiva vesti, news, serbia, politics' + oldest_article = 8 + max_articles_per_feed = 100 + no_stylesheets = False + use_embedded_content = False + encoding = 'utf-8' + masthead_url = 'http://www.akter.co.rs/templates/gk_thenews2/images/style2/logo.png' + language = 'sr' + publication_type = 'magazine' + remove_empty_feeds = True + PREFIX = 'http://www.akter.co.rs' + extra_css = """ @font-face {font-family: "serif1";src:url(res:///opt/sony/ebook/FONT/tt0011m_.ttf)} + @font-face {font-family: "sans1";src:url(res:///opt/sony/ebook/FONT/tt0003m_.ttf)} + .article_description,body,.lokacija{font-family: Arial,Helvetica,sans1,sans-serif} + .color-2{display:block; margin-bottom: 10px; padding: 5px, 10px; + border-left: 1px solid #D00000; color: #D00000} + img{margin-bottom: 0.8em} """ + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language + , 'linearize_tables' : True + } + + preprocess_regexps = [(re.compile(u'\u0110'), lambda match: u'\u00D0')] + + feeds = [ + (u'Politika' , u'http://www.akter.co.rs/index.php/politikaprint.html' ) + ,(u'Ekonomija' , u'http://www.akter.co.rs/index.php/ekonomijaprint.html') + ,(u'Life&Style' , u'http://www.akter.co.rs/index.php/lsprint.html' ) + ,(u'Sport' , u'http://www.akter.co.rs/index.php/sportprint.html' ) + ] + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + return self.adeify_images(soup) + + def print_version(self, url): + return url + '?tmpl=component&print=1&page=' + + 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(attrs={'class':['sectiontableentry1','sectiontableentry2']}): + link = item.find('a') + url = self.PREFIX + link['href'] + title = self.tag_to_string(link) + articles.append({ + 'title' :title + ,'date' :'' + ,'url' :url + ,'description':'' + }) + totalfeeds.append((feedtitle, articles)) + return totalfeeds + diff --git a/resources/recipes/publico.recipe b/resources/recipes/publico.recipe index c5fbcde53b..7d913cbbe0 100644 --- a/resources/recipes/publico.recipe +++ b/resources/recipes/publico.recipe @@ -1,41 +1,43 @@ -""" -publico.py - v1.0 +#!/usr/bin/env python +__author__ = u'Jordi Balcells' +__license__ = 'GPL v3' +description = u'Jornal portugu\xeas - v1.03 (16 June 2010)' +__docformat__ = 'restructuredtext en' -Copyright (c) 2009, David Rodrigues - http://sixhat.net -All rights reserved. -""" - -__license__ = 'GPL 3' +''' +publico.pt +''' from calibre.web.feeds.news import BasicNewsRecipe -import re -class Publico(BasicNewsRecipe): - title = u'P\xfablico' - __author__ = 'David Rodrigues' - oldest_article = 1 - max_articles_per_feed = 30 - encoding='utf-8' - no_stylesheets = True - language = 'pt' +class PublicoPT(BasicNewsRecipe): + description = u'Jornal portugu\xeas' + cover_url = 'http://static.publico.pt/files/header/img/publico.gif' + title = u'Publico.PT' + category = 'News, politics, culture, economy, general interest' + oldest_article = 2 + no_stylesheets = True + encoding = 'utf8' + use_embedded_content = False + language = 'pt' + remove_empty_feeds = True + extra_css = ' body{font-family: Arial,Helvetica,sans-serif } img{margin-bottom: 0.4em} ' - preprocess_regexps = [(re.compile(u"\uFFFD", re.DOTALL|re.IGNORECASE), lambda match: ''),] + keep_only_tags = [dict(attrs={'class':['content-noticia-title','artigoHeader','ECOSFERA_MANCHETE','noticia','textoPrincipal','ECOSFERA_texto_01']})] + remove_tags = [dict(attrs={'class':['options','subcoluna']})] - feeds = [ - (u'Geral', u'http://feeds.feedburner.com/PublicoUltimaHora'), - (u'Internacional', u'http://www.publico.clix.pt/rss.ashx?idCanal=11'), - (u'Pol\xedtica', u'http://www.publico.clix.pt/rss.ashx?idCanal=12'), - (u'Ci\xcencias', u'http://www.publico.clix.pt/rss.ashx?idCanal=13'), - (u'Desporto', u'http://desporto.publico.pt/rss.ashx'), - (u'Economia', u'http://www.publico.clix.pt/rss.ashx?idCanal=57'), - (u'Educa\xe7\xe3o', u'http://www.publico.clix.pt/rss.ashx?idCanal=58'), - (u'Local', u'http://www.publico.clix.pt/rss.ashx?idCanal=59'), - (u'Media e Tecnologia', u'http://www.publico.clix.pt/rss.ashx?idCanal=61'), - (u'Sociedade', u'http://www.publico.clix.pt/rss.ashx?idCanal=62') - ] - remove_tags = [dict(name='script'), dict(id='linhaTitulosHeader')] - keep_only_tags = [dict(name='div')] + feeds = [ + (u'Geral', u'http://feeds.feedburner.com/publicoRSS'), + (u'Mundo', u'http://feeds.feedburner.com/PublicoMundo'), + (u'Pol\xedtica', u'http://feeds.feedburner.com/PublicoPolitica'), + (u'Economia', u'http://feeds.feedburner.com/PublicoEconomia'), + (u'Desporto', u'http://feeds.feedburner.com/PublicoDesporto'), + (u'Sociedade', u'http://feeds.feedburner.com/PublicoSociedade'), + (u'Educa\xe7\xe3o', u'http://feeds.feedburner.com/PublicoEducacao'), + (u'Ci\xeancias', u'http://feeds.feedburner.com/PublicoCiencias'), + (u'Ecosfera', u'http://feeds.feedburner.com/PublicoEcosfera'), + (u'Cultura', u'http://feeds.feedburner.com/PublicoCultura'), + (u'Local', u'http://feeds.feedburner.com/PublicoLocal'), + (u'Tecnologia', u'http://feeds.feedburner.com/PublicoTecnologia') + ] - def print_version(self,url): - s=re.findall("id=[0-9]+",url); - return "http://ww2.publico.clix.pt/print.aspx?"+s[0] diff --git a/resources/recipes/slashdot.recipe b/resources/recipes/slashdot.recipe index dc0067f3ed..c7c68c3f1a 100644 --- a/resources/recipes/slashdot.recipe +++ b/resources/recipes/slashdot.recipe @@ -10,8 +10,10 @@ from calibre.web.feeds.news import BasicNewsRecipe class Slashdot(BasicNewsRecipe): title = u'Slashdot.org' description = '''Tech news. WARNING: This recipe downloads a lot - of content and can result in your IP being banned from slashdot.org''' + of content and may result in your IP being banned from slashdot.org''' oldest_article = 7 + simultaneous_downloads = 1 + delay = 3 max_articles_per_feed = 100 language = 'en' diff --git a/resources/recipes/wsj.recipe b/resources/recipes/wsj.recipe index 25f175f78b..2e99a690f4 100644 --- a/resources/recipes/wsj.recipe +++ b/resources/recipes/wsj.recipe @@ -3,126 +3,141 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' __docformat__ = 'restructuredtext en' - from calibre.web.feeds.news import BasicNewsRecipe -from calibre import strftime # http://online.wsj.com/page/us_in_todays_paper.html class WallStreetJournal(BasicNewsRecipe): - title = 'The Wall Street Journal (US)' - __author__ = 'Kovid Goyal and Sujata Raman' - description = 'News and current affairs' - needs_subscription = True - language = 'en' + title = 'The Wall Street Journal (US)' + __author__ = 'Kovid Goyal and Sujata Raman' + description = 'News and current affairs' + needs_subscription = True + language = 'en' - max_articles_per_feed = 1000 - timefmt = ' [%a, %b %d, %Y]' - no_stylesheets = True + max_articles_per_feed = 1000 + timefmt = ' [%a, %b %d, %Y]' + no_stylesheets = True - extra_css = '''h1{color:#093D72 ; font-size:large ; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; } - h2{color:#474537; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} - .subhead{color:gray; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} - .insettipUnit {color:#666666; font-family:Arial,Sans-serif;font-size:xx-small } - .targetCaption{ font-size:x-small; color:#333333; font-family:Arial,Helvetica,sans-serif} - .article{font-family :Arial,Helvetica,sans-serif; font-size:x-small} - .tagline {color:#333333; font-size:xx-small} - .dateStamp {color:#666666; font-family:Arial,Helvetica,sans-serif} - h3{color:blue ;font-family:Arial,Helvetica,sans-serif; font-size:xx-small} - .byline{color:blue;font-family:Arial,Helvetica,sans-serif; font-size:xx-small} - h6{color:#333333; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small;font-style:italic; } - .paperLocation{color:#666666; font-size:xx-small}''' + extra_css = '''h1{color:#093D72 ; font-size:large ; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; } + h2{color:#474537; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} + .subhead{color:gray; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} + .insettipUnit {color:#666666; font-family:Arial,Sans-serif;font-size:xx-small } + .targetCaption{ font-size:x-small; color:#333333; font-family:Arial,Helvetica,sans-serif} + .article{font-family :Arial,Helvetica,sans-serif; font-size:x-small} + .tagline {color:#333333; font-size:xx-small} + .dateStamp {color:#666666; font-family:Arial,Helvetica,sans-serif} + h3{color:blue ;font-family:Arial,Helvetica,sans-serif; font-size:xx-small} + .byline{color:blue;font-family:Arial,Helvetica,sans-serif; font-size:xx-small} + h6{color:#333333; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small;font-style:italic; } + .paperLocation{color:#666666; font-size:xx-small}''' - remove_tags_before = dict(name='h1') - remove_tags = [ - dict(id=["articleTabs_tab_article", "articleTabs_tab_comments", "articleTabs_tab_interactive","articleTabs_tab_video","articleTabs_tab_map","articleTabs_tab_slideshow"]), - {'class':['footer_columns','network','insetCol3wide','interactive','video','slideshow','map','insettip','insetClose','more_in', "insetContent", 'articleTools_bottom', 'aTools', "tooltip", "adSummary", "nav-inline"]}, - dict(rel='shortcut icon'), - ] - remove_tags_after = [dict(id="article_story_body"), {'class':"article story"},] + remove_tags_before = dict(name='h1') + remove_tags = [ + dict(id=["articleTabs_tab_article", "articleTabs_tab_comments", "articleTabs_tab_interactive","articleTabs_tab_video","articleTabs_tab_map","articleTabs_tab_slideshow"]), + {'class':['footer_columns','network','insetCol3wide','interactive','video','slideshow','map','insettip','insetClose','more_in', "insetContent", 'articleTools_bottom', 'aTools', "tooltip", "adSummary", "nav-inline"]}, + dict(rel='shortcut icon'), + ] + remove_tags_after = [dict(id="article_story_body"), {'class':"article story"},] - def get_browser(self): - br = BasicNewsRecipe.get_browser() - if self.username is not None and self.password is not None: - br.open('http://commerce.wsj.com/auth/login') - br.select_form(nr=0) - br['user'] = self.username - br['password'] = self.password - res = br.submit() - raw = res.read() - if 'Welcome,' not in raw: - raise ValueError('Failed to log in to wsj.com, check your ' - 'username and password') - return br + def get_browser(self): + br = BasicNewsRecipe.get_browser() + if self.username is not None and self.password is not None: + br.open('http://commerce.wsj.com/auth/login') + br.select_form(nr=0) + br['user'] = self.username + br['password'] = self.password + res = br.submit() + raw = res.read() + if 'Welcome,' not in raw: + raise ValueError('Failed to log in to wsj.com, check your ' + 'username and password') + return br - def postprocess_html(self, soup, first): - for tag in soup.findAll(name=['table', 'tr', 'td']): - tag.name = 'div' + def postprocess_html(self, soup, first): + for tag in soup.findAll(name=['table', 'tr', 'td']): + tag.name = 'div' - for tag in soup.findAll('div', dict(id=["articleThumbnail_1", "articleThumbnail_2", "articleThumbnail_3", "articleThumbnail_4", "articleThumbnail_5", "articleThumbnail_6", "articleThumbnail_7"])): - tag.extract() + for tag in soup.findAll('div', dict(id=["articleThumbnail_1", "articleThumbnail_2", "articleThumbnail_3", "articleThumbnail_4", "articleThumbnail_5", "articleThumbnail_6", "articleThumbnail_7"])): + tag.extract() - return soup + return soup - def wsj_get_index(self): - return self.index_to_soup('http://online.wsj.com/page/us_in_todays_paper.html') + def wsj_get_index(self): + return self.index_to_soup('http://online.wsj.com/itp') - def parse_index(self): - soup = self.wsj_get_index() + def parse_index(self): + soup = self.wsj_get_index() - year = strftime('%Y') - for x in soup.findAll('td', height='25', attrs={'class':'b14'}): - txt = self.tag_to_string(x).strip() - txt = txt.replace(u'\xa0', ' ') - txt = txt.encode('ascii', 'ignore') - if year in txt: - self.timefmt = ' [%s]'%txt - break + date = soup.find('span', attrs={'class':'date-date'}) + if date is not None: + self.timefmt = ' [%s]'%self.tag_to_string(date) - left_column = soup.find( - text=lambda t: 'begin ITP Left Column' in str(t)) + cov = soup.find('a', attrs={'class':'icon pdf'}, href=True) + if cov is not None: + self.cover_url = cov['href'] - table = left_column.findNext('table') + feeds = [] + div = soup.find('div', attrs={'class':'itpHeader'}) + div = div.find('ul', attrs={'class':'tab'}) + for a in div.findAll('a', href=lambda x: x and '/itp/' in x): + title = self.tag_to_string(a) + url = 'http://online.wsj.com' + a['href'] + self.log('Found section:', title) + articles = self.wsj_find_articles(url) + if articles: + feeds.append((title, articles)) - current_section = None - current_articles = [] - feeds = [] - for x in table.findAllNext(True): - if x.name == 'td' and x.get('class', None) == 'b13': - if current_articles and current_section: - feeds.append((current_section, current_articles)) - current_section = self.tag_to_string(x.a).strip() - current_articles = [] - self.log('\tProcessing section:', current_section) - if current_section is not None and x.name == 'a' and \ - x.get('class', None) == 'bold80': - title = self.tag_to_string(x) - url = x.get('href', False) - if not url or not title: - continue - url = url.partition('#')[0] - desc = '' - d = x.findNextSibling(True) - if d is not None and d.get('class', None) == 'arialResize': - desc = self.tag_to_string(d) - desc = desc.partition(u'\u2022')[0] - self.log('\t\tFound article:', title) - self.log('\t\t\t', url) - if url.startswith('/'): - url = 'http://online.wsj.com'+url - if desc: - self.log('\t\t\t', desc) - current_articles.append({'title': title, 'url':url, - 'description':desc, 'date':''}) + return feeds - if current_articles and current_section: - feeds.append((current_section, current_articles)) + def wsj_find_articles(self, url): + soup = self.index_to_soup(url) - return feeds + whats_news = soup.find('div', attrs={'class':lambda x: x and + 'whatsNews-simple' in x}) + if whats_news is not None: + whats_news.extract() - def cleanup(self): - self.browser.open('http://online.wsj.com/logout?url=http://online.wsj.com') + articles = [] + + for a in soup.findAll('a', attrs={'class':'mjLinkItem'}, href=True): + container = a.findParent(['li', 'div']) + meta = a.find(attrs={'class':'meta_sectionName'}) + if meta is not None: + meta.extract() + title = self.tag_to_string(a).strip() + ' [%s]'%self.tag_to_string(meta) + url = 'http://online.wsj.com'+a['href'] + desc = '' + p = container.find('p') + if p is not None: + desc = self.tag_to_string(p) + + articles.append({'title':title, 'url':url, + 'description':desc, 'date':''}) + + self.log('\tFound article:', title) + + ''' + # Find related articles + a.extract() + for a in container.findAll('a', href=lambda x: x and '/article/' + in x and 'articleTabs' not in x): + url = a['href'] + if not url.startswith('http:'): + url = 'http://online.wsj.com'+url + title = self.tag_to_string(a).strip() + if not title or title.startswith('['): continue + if title: + articles.append({'title':self.tag_to_string(a), + 'url':url, 'description':'', 'date':''}) + self.log('\t\tFound related:', title) + ''' + + return articles + + + def cleanup(self): + self.browser.open('http://online.wsj.com/logout?url=http://online.wsj.com') diff --git a/resources/tracer.epub b/resources/tracer.epub new file mode 100644 index 0000000000..28f40c07d0 Binary files /dev/null and b/resources/tracer.epub differ diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index ca93990420..93344f4616 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -436,7 +436,7 @@ from calibre.devices.blackberry.driver import BLACKBERRY from calibre.devices.cybook.driver import CYBOOK from calibre.devices.eb600.driver import EB600, COOL_ER, SHINEBOOK, \ POCKETBOOK360, GER2, ITALICA, ECLICTO, DBOOK, INVESBOOK, \ - BOOQ, ELONEX + BOOQ, ELONEX, POCKETBOOK301 from calibre.devices.iliad.driver import ILIAD from calibre.devices.irexdr.driver import IREXDR1000, IREXDR800 from calibre.devices.jetbook.driver import JETBOOK @@ -457,9 +457,12 @@ from calibre.devices.misc import PALMPRE, AVANT from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG from calibre.devices.kobo.driver import KOBO -from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon +from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon, \ + LibraryThing +from calibre.ebooks.metadata.douban import DoubanBooks from calibre.library.catalog import CSV_XML, EPUB_MOBI -plugins = [HTML2ZIP, PML2PMLZ, ArchiveExtract, GoogleBooks, ISBNDB, Amazon, CSV_XML, EPUB_MOBI] +plugins = [HTML2ZIP, PML2PMLZ, ArchiveExtract, GoogleBooks, ISBNDB, Amazon, + LibraryThing, DoubanBooks, CSV_XML, EPUB_MOBI] plugins += [ ComicInput, EPUBInput, @@ -507,6 +510,7 @@ plugins += [ JETBOOK, SHINEBOOK, POCKETBOOK360, + POCKETBOOK301, KINDLE, KINDLE2, KINDLE_DX, diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index a2521b0023..8397827fbb 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -21,7 +21,7 @@ from calibre.utils.config import make_config_dir, Config, ConfigProxy, \ platform = 'linux' if iswindows: platform = 'windows' -if isosx: +elif isosx: platform = 'osx' from zipfile import ZipFile @@ -32,19 +32,25 @@ def _config(): c.add_opt('filetype_mapping', default={}, help=_('Mapping for filetype plugins')) c.add_opt('plugin_customization', default={}, help=_('Local plugin customization')) c.add_opt('disabled_plugins', default=set([]), help=_('Disabled plugins')) + c.add_opt('enabled_plugins', default=set([]), help=_('Enabled plugins')) return ConfigProxy(c) config = _config() - class InvalidPlugin(ValueError): pass class PluginNotFound(ValueError): pass -def load_plugin(path_to_zip_file): +def find_plugin(name): + for plugin in _initialized_plugins: + if plugin.name == name: + return plugin + + +def load_plugin(path_to_zip_file): # {{{ ''' Load plugin from zip file or raise InvalidPlugin error @@ -76,11 +82,120 @@ def load_plugin(path_to_zip_file): raise InvalidPlugin(_('No valid plugin found in ')+path_to_zip_file) -_initialized_plugins = [] +# }}} + +# Enable/disable plugins {{{ + +def disable_plugin(plugin_or_name): + x = getattr(plugin_or_name, 'name', plugin_or_name) + plugin = find_plugin(x) + if not plugin.can_be_disabled: + raise ValueError('Plugin %s cannot be disabled'%x) + dp = config['disabled_plugins'] + dp.add(x) + config['disabled_plugins'] = dp + ep = config['enabled_plugins'] + if x in ep: + ep.remove(x) + config['enabled_plugins'] = ep + +def enable_plugin(plugin_or_name): + x = getattr(plugin_or_name, 'name', plugin_or_name) + dp = config['disabled_plugins'] + if x in dp: + dp.remove(x) + config['disabled_plugins'] = dp + ep = config['enabled_plugins'] + ep.add(x) + config['enabled_plugins'] = ep + +default_disabled_plugins = set([ + 'Douban Books', +]) + +def is_disabled(plugin): + if plugin.name in config['enabled_plugins']: return False + return plugin.name in config['disabled_plugins'] or \ + plugin.name in default_disabled_plugins +# }}} + +# File type plugins {{{ + _on_import = {} _on_preprocess = {} _on_postprocess = {} +def reread_filetype_plugins(): + global _on_import + global _on_preprocess + global _on_postprocess + _on_import = {} + _on_preprocess = {} + _on_postprocess = {} + + for plugin in _initialized_plugins: + if isinstance(plugin, FileTypePlugin): + for ft in plugin.file_types: + if plugin.on_import: + if not _on_import.has_key(ft): + _on_import[ft] = [] + _on_import[ft].append(plugin) + if plugin.on_preprocess: + if not _on_preprocess.has_key(ft): + _on_preprocess[ft] = [] + _on_preprocess[ft].append(plugin) + if plugin.on_postprocess: + if not _on_postprocess.has_key(ft): + _on_postprocess[ft] = [] + _on_postprocess[ft].append(plugin) + + +def _run_filetype_plugins(path_to_file, ft=None, occasion='preprocess'): + occasion = {'import':_on_import, 'preprocess':_on_preprocess, + 'postprocess':_on_postprocess}[occasion] + customization = config['plugin_customization'] + if ft is None: + ft = os.path.splitext(path_to_file)[-1].lower().replace('.', '') + nfp = path_to_file + for plugin in occasion.get(ft, []): + if is_disabled(plugin): + continue + plugin.site_customization = customization.get(plugin.name, '') + with plugin: + try: + nfp = plugin.run(path_to_file) + if not nfp: + nfp = path_to_file + except: + print 'Running file type plugin %s failed with traceback:'%plugin.name + traceback.print_exc() + x = lambda j : os.path.normpath(os.path.normcase(j)) + if occasion == 'postprocess' and x(nfp) != x(path_to_file): + shutil.copyfile(nfp, path_to_file) + nfp = path_to_file + return nfp + +run_plugins_on_import = functools.partial(_run_filetype_plugins, + occasion='import') +run_plugins_on_preprocess = functools.partial(_run_filetype_plugins, + occasion='preprocess') +run_plugins_on_postprocess = functools.partial(_run_filetype_plugins, + occasion='postprocess') +# }}} + +# Plugin customization {{{ +def customize_plugin(plugin, custom): + d = config['plugin_customization'] + d[plugin.name] = custom.strip() + config['plugin_customization'] = d + +def plugin_customization(plugin): + return config['plugin_customization'].get(plugin.name, '') + +# }}} + + +# Input/Output profiles {{{ def input_profiles(): for plugin in _initialized_plugins: if isinstance(plugin, InputProfile): @@ -90,7 +205,9 @@ def output_profiles(): for plugin in _initialized_plugins: if isinstance(plugin, OutputProfile): yield plugin +# }}} +# Metadata sources {{{ def metadata_sources(metadata_type='basic', customize=True, isbndb_key=None): for plugin in _initialized_plugins: if isinstance(plugin, MetadataSource) and \ @@ -117,31 +234,9 @@ def migrate_isbndb_key(): if key: prefs.set('isbndb_com_key', '') set_isbndb_key(key) +# }}} -def reread_filetype_plugins(): - global _on_import - global _on_preprocess - global _on_postprocess - _on_import = {} - _on_preprocess = {} - _on_postprocess = {} - - for plugin in _initialized_plugins: - if isinstance(plugin, FileTypePlugin): - for ft in plugin.file_types: - if plugin.on_import: - if not _on_import.has_key(ft): - _on_import[ft] = [] - _on_import[ft].append(plugin) - if plugin.on_preprocess: - if not _on_preprocess.has_key(ft): - _on_preprocess[ft] = [] - _on_preprocess[ft].append(plugin) - if plugin.on_postprocess: - if not _on_postprocess.has_key(ft): - _on_postprocess[ft] = [] - _on_postprocess[ft].append(plugin) - +# Metadata read/write {{{ _metadata_readers = {} _metadata_writers = {} def reread_metadata_plugins(): @@ -233,51 +328,9 @@ def set_file_type_metadata(stream, mi, ftype): print 'Failed to set metadata for', repr(getattr(mi, 'title', '')) traceback.print_exc() +# }}} -def _run_filetype_plugins(path_to_file, ft=None, occasion='preprocess'): - occasion = {'import':_on_import, 'preprocess':_on_preprocess, - 'postprocess':_on_postprocess}[occasion] - customization = config['plugin_customization'] - if ft is None: - ft = os.path.splitext(path_to_file)[-1].lower().replace('.', '') - nfp = path_to_file - for plugin in occasion.get(ft, []): - if is_disabled(plugin): - continue - plugin.site_customization = customization.get(plugin.name, '') - with plugin: - try: - nfp = plugin.run(path_to_file) - if not nfp: - nfp = path_to_file - except: - print 'Running file type plugin %s failed with traceback:'%plugin.name - traceback.print_exc() - x = lambda j : os.path.normpath(os.path.normcase(j)) - if occasion == 'postprocess' and x(nfp) != x(path_to_file): - shutil.copyfile(nfp, path_to_file) - nfp = path_to_file - return nfp - -run_plugins_on_import = functools.partial(_run_filetype_plugins, - occasion='import') -run_plugins_on_preprocess = functools.partial(_run_filetype_plugins, - occasion='preprocess') -run_plugins_on_postprocess = functools.partial(_run_filetype_plugins, - occasion='postprocess') - - -def initialize_plugin(plugin, path_to_zip_file): - try: - p = plugin(path_to_zip_file) - p.initialize() - return p - except Exception: - print 'Failed to initialize plugin:', plugin.name, plugin.version - tb = traceback.format_exc() - raise InvalidPlugin((_('Initialization of plugin %s failed with traceback:') - %tb) + '\n'+tb) - +# Add/remove plugins {{{ def add_plugin(path_to_zip_file): make_config_dir() @@ -307,14 +360,9 @@ def remove_plugin(plugin_or_name): initialize_plugins() return removed -def is_disabled(plugin): - return plugin.name in config['disabled_plugins'] - -def find_plugin(name): - for plugin in _initialized_plugins: - if plugin.name == name: - return plugin +# }}} +# Input/Output format plugins {{{ def input_format_plugins(): for plugin in _initialized_plugins: @@ -364,6 +412,9 @@ def available_output_formats(): formats.add(plugin.file_type) return formats +# }}} + +# Catalog plugins {{{ def catalog_plugins(): for plugin in _initialized_plugins: @@ -383,27 +434,32 @@ def plugin_for_catalog_format(fmt): if fmt.lower() in plugin.file_types: return plugin -def device_plugins(): +# }}} + +def device_plugins(): # {{{ for plugin in _initialized_plugins: if isinstance(plugin, DevicePlugin): if not is_disabled(plugin): - yield plugin + if platform in plugin.supported_platforms: + yield plugin +# }}} -def disable_plugin(plugin_or_name): - x = getattr(plugin_or_name, 'name', plugin_or_name) - plugin = find_plugin(x) - if not plugin.can_be_disabled: - raise ValueError('Plugin %s cannot be disabled'%x) - dp = config['disabled_plugins'] - dp.add(x) - config['disabled_plugins'] = dp -def enable_plugin(plugin_or_name): - x = getattr(plugin_or_name, 'name', plugin_or_name) - dp = config['disabled_plugins'] - if x in dp: - dp.remove(x) - config['disabled_plugins'] = dp +# Initialize plugins {{{ + +_initialized_plugins = [] + +def initialize_plugin(plugin, path_to_zip_file): + try: + p = plugin(path_to_zip_file) + p.initialize() + return p + except Exception: + print 'Failed to initialize plugin:', plugin.name, plugin.version + tb = traceback.format_exc() + raise InvalidPlugin((_('Initialization of plugin %s failed with traceback:') + %tb) + '\n'+tb) + def initialize_plugins(): global _initialized_plugins @@ -425,10 +481,14 @@ def initialize_plugins(): initialize_plugins() -def intialized_plugins(): +def initialized_plugins(): for plugin in _initialized_plugins: yield plugin +# }}} + +# CLI {{{ + def option_parser(): parser = OptionParser(usage=_('''\ %prog options @@ -449,17 +509,6 @@ def option_parser(): help=_('Disable the named plugin')) return parser -def initialized_plugins(): - return _initialized_plugins - -def customize_plugin(plugin, custom): - d = config['plugin_customization'] - d[plugin.name] = custom.strip() - config['plugin_customization'] = d - -def plugin_customization(plugin): - return config['plugin_customization'].get(plugin.name, '') - def main(args=sys.argv): parser = option_parser() if len(args) < 2: @@ -504,3 +553,5 @@ def main(args=sys.argv): if __name__ == '__main__': sys.exit(main()) +# }}} + diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index ae440a359e..7a90343c29 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -5,19 +5,21 @@ __copyright__ = '2010, Gregory Riker' __docformat__ = 'restructuredtext en' -import cStringIO, ctypes, os, re, shutil, subprocess, sys, tempfile, time, zipfile +import cStringIO, ctypes, datetime, os, re, shutil, subprocess, sys, tempfile, time from calibre.constants import DEBUG from calibre import fit_image from calibre.constants import isosx, iswindows +from calibre.devices.errors import UserFeedback from calibre.devices.interface import DevicePlugin from calibre.ebooks.BeautifulSoup import BeautifulSoup from calibre.ebooks.metadata import MetaInformation +from calibre.ebooks.metadata.epub import set_metadata from calibre.library.server.utils import strftime from calibre.utils.config import Config, config_dir -from calibre.utils.date import parse_date +from calibre.utils.date import isoformat, now, parse_date from calibre.utils.logging import Log -from calibre.devices.errors import UserFeedback +from calibre.utils.zipfile import ZipFile from PIL import Image as PILImage @@ -32,6 +34,7 @@ if isosx: if iswindows: import pythoncom, win32com.client + class ITUNES(DevicePlugin): ''' Calling sequences: @@ -175,54 +178,67 @@ class ITUNES(DevicePlugin): # Delete any obsolete copies of the book from the booklist if self.update_list: - if isosx: - if DEBUG: - self.log.info( "ITUNES.add_books_to_metadata()") - self._dump_update_list('add_books_to_metadata()') - for (j,p_book) in enumerate(self.update_list): - self.log.info("ITUNES.add_books_to_metadata():\n looking for %s" % - str(p_book['lib_book'])[-9:]) - for i,bl_book in enumerate(booklists[0]): - if bl_book.library_id == p_book['lib_book']: - booklists[0].pop(i) - self.log.info("ITUNES.add_books_to_metadata():\n removing %s %s" % - (p_book['title'], str(p_book['lib_book'])[-9:])) + if True: + self.log.info("ITUNES.add_books_to_metadata()") + #self._dump_booklist(booklists[0], header='before',indent=2) + #self._dump_update_list(header='before',indent=2) + #self._dump_cached_books(header='before',indent=2) + + for (j,p_book) in enumerate(self.update_list): + if False: + if isosx: + self.log.info(" looking for %s" % + str(p_book['lib_book'])[-9:]) + elif iswindows: + self.log.info(" looking for '%s' by %s (%s)" % + (p_book['title'],p_book['author'], p_book['uuid'])) + + # Purge the booklist, self.cached_books + for i,bl_book in enumerate(booklists[0]): + if bl_book.uuid == p_book['uuid']: + # Remove from booklists[0] + booklists[0].pop(i) + if False: + if isosx: + self.log.info(" removing old %s %s from booklists[0]" % + (p_book['title'], str(p_book['lib_book'])[-9:])) + elif iswindows: + self.log.info(" removing old '%s' from booklists[0]" % + (p_book['title'])) + + # If >1 matching uuid, remove old title + matching_uuids = 0 + for cb in self.cached_books: + if self.cached_books[cb]['uuid'] == p_book['uuid']: + matching_uuids += 1 + + if matching_uuids > 1: + for cb in self.cached_books: + if self.cached_books[cb]['uuid'] == p_book['uuid']: + if self.cached_books[cb]['title'] == p_book['title'] and \ + self.cached_books[cb]['author'] == p_book['author']: + if DEBUG: + self._dump_cached_book(self.cached_books[cb],header="removing from self.cached_books:", indent=2) + self.cached_books.pop(cb) + break break - else: - self.log.error(" update_list item '%s' by %s %s not found in booklists[0]" % - (p_book['title'], p_book['author'],str(p_book['lib_book'])[-9:])) - - if self.report_progress is not None: - self.report_progress(j+1/task_count, _('Updating device metadata listing...')) - - elif iswindows: - if DEBUG: - self.log.info("ITUNES.add_books_to_metadata()") - for (j,p_book) in enumerate(self.update_list): - #self.log.info(" looking for '%s' by %s" % (p_book['title'],p_book['author'])) - for i,bl_book in enumerate(booklists[0]): - #self.log.info(" evaluating '%s' by %s" % (bl_book.title,bl_book.author[0])) - if bl_book.title == p_book['title'] and \ - bl_book.author[0] == p_book['author']: - booklists[0].pop(i) - self.log.info(" removing outdated version of '%s'" % p_book['title']) - break - else: - self.log.error(" update_list item '%s' not found in booklists[0]" % p_book['title']) - - if self.report_progress is not None: - self.report_progress(j+1/task_count, _('Updating device metadata listing...')) + if self.report_progress is not None: + self.report_progress(j+1/task_count, _('Updating device metadata listing...')) if self.report_progress is not None: self.report_progress(1.0, _('Updating device metadata listing...')) # Add new books to booklists[0] for new_book in locations[0]: - if DEBUG: - self.log.info(" adding '%s' by '%s' to booklists[0]" % + if False: + self.log.info(" adding '%s' by '%s' to booklists[0]" % (new_book.title, new_book.author)) booklists[0].append(new_book) + if False: + self._dump_booklist(booklists[0],header='after',indent=2) + self._dump_cached_books(header='after',indent=2) + def books(self, oncard=None, end_session=True): """ Return a list of ebooks on the device. @@ -264,6 +280,7 @@ class ITUNES(DevicePlugin): this_book.device_collections = [] this_book.library_id = library_books[this_book.path] if this_book.path in library_books else None this_book.size = book.size() + this_book.uuid = book.album() # Hack to discover if we're running in GUI environment if self.report_progress is not None: this_book.thumbnail = self._generate_thumbnail(this_book.path, book) @@ -275,7 +292,8 @@ class ITUNES(DevicePlugin): 'title':book.name(), 'author':[book.artist()], 'lib_book':library_books[this_book.path] if this_book.path in library_books else None, - 'dev_book':book + 'dev_book':book, + 'uuid': book.album() } if self.report_progress is not None: @@ -310,7 +328,8 @@ class ITUNES(DevicePlugin): cached_books[this_book.path] = { 'title':book.Name, 'author':book.Artist, - 'lib_book':library_books[this_book.path] if this_book.path in library_books else None + 'lib_book':library_books[this_book.path] if this_book.path in library_books else None, + 'uuid': book.Album } if self.report_progress is not None: @@ -391,7 +410,7 @@ class ITUNES(DevicePlugin): self.ejected = True return False - self._discover_manual_sync_mode() + self._discover_manual_sync_mode(wait = 2 if self.initial_status == 'launched' else 0) return True def can_handle_windows(self, device_id, debug=False): @@ -525,8 +544,24 @@ class ITUNES(DevicePlugin): else: self.log.info(" skipping sync phase, manual_sync_mode: True") else: - self.problem_titles.append("'%s' by %s" % - (self.cached_books[path]['title'],self.cached_books[path]['author'])) + if self.manual_sync_mode: + metadata = MetaInformation(self.cached_books[path]['title'], + [self.cached_books[path]['author']]) + metadata.uuid = self.cached_books[path]['uuid'] + + if isosx: + self._remove_existing_copy(self.cached_books[path],metadata) + elif iswindows: + try: + pythoncom.CoInitialize() + self.iTunes = win32com.client.Dispatch("iTunes.Application") + self._remove_existing_copy(self.cached_books[path],metadata) + finally: + pythoncom.CoUninitialize() + + else: + self.problem_titles.append("'%s' by %s" % + (self.cached_books[path]['title'],self.cached_books[path]['author'])) def eject(self): ''' @@ -622,6 +657,8 @@ class ITUNES(DevicePlugin): Note that most of the initialization is necessarily performed in can_handle(), as we need to talk to iTunes to discover if there's a connected iPod ''' + if DEBUG: + self.log.info("ITUNES.open()") # Confirm/create thumbs archive archive_path = os.path.join(self.cache_dir, "thumbs.zip") @@ -633,7 +670,7 @@ class ITUNES(DevicePlugin): if not os.path.exists(archive_path): self.log.info(" creating zip archive") - zfw = zipfile.ZipFile(archive_path, mode='w') + zfw = ZipFile(archive_path, mode='w') zfw.writestr("iTunes Thumbs Archive",'') zfw.close() else: @@ -652,24 +689,19 @@ class ITUNES(DevicePlugin): if DEBUG: self.log.info("ITUNES.remove_books_from_metadata()") for path in paths: - self._dump_cached_book(self.cached_books[path]) - if self.cached_books[path]['lib_book']: - # Remove from the booklist - for i,book in enumerate(booklists[0]): - if book.path == path: - self.log.info(" removing '%s' from calibre booklist, index: %d" % (path, i)) - booklists[0].pop(i) - break - else: - self.log.error(" '%s' not found in self.cached_book" % path) + self._dump_cached_book(self.cached_books[path], indent=2) - # Remove from cached_books - self.cached_books.pop(path) - if DEBUG: - self.log.info(" removing '%s' from self.cached_books" % path) -# self._dump_cached_books('remove_books_from_metadata()') - else: - self.log.warning(" skipping purchased book, can't remove via automation interface") + # Purge the booklist, self.cached_books + for i,bl_book in enumerate(booklists[0]): + if bl_book.uuid == self.cached_books[path]['uuid']: + # Remove from booklists[0] + booklists[0].pop(i) + + for cb in self.cached_books: + if self.cached_books[cb]['uuid'] == self.cached_books[path]['uuid']: + self.cached_books.pop(cb) + break + break def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None) : @@ -712,13 +744,10 @@ class ITUNES(DevicePlugin): (L{books}(oncard=None), L{books}(oncard='carda'), L{books}(oncard='cardb')). ''' - if DEBUG: - self.log.info("ITUNES:sync_booklists()") if self.update_needed: if DEBUG: self.log.info(' calling _update_device') self._update_device(msg=self.update_msg, wait=False) - self.update_list = [] self.update_needed = False # Inform user of any problem books @@ -727,6 +756,7 @@ class ITUNES(DevicePlugin): details='\n'.join(self.problem_titles), level=UserFeedback.WARN) self.problem_titles = [] self.problem_msg = None + self.update_list = [] def total_space(self, end_session=True): """ @@ -776,30 +806,29 @@ class ITUNES(DevicePlugin): self.problem_msg = _("Some cover art could not be converted.\n" "Click 'Show Details' for a list.") - if DEBUG: + if False: self.log.info("ITUNES.upload_books()") - self._dump_files(files, header='upload_books()') -# self._dump_cached_books('upload_books()') - self._dump_update_list('upload_books()') + self._dump_files(files, header='upload_books()',indent=2) + self._dump_update_list(header='upload_books()',indent=2) if isosx: for (i,file) in enumerate(files): path = self.path_template % (metadata[i].title, metadata[i].author[0]) - self._remove_existing_copies(path,file,metadata[i]) - fpath = self._get_fpath(file) + self._remove_existing_copy(path, metadata[i]) + fpath = self._get_fpath(file, metadata[i], update_md=True) db_added, lb_added = self._add_new_copy(fpath, metadata[i]) - thumb = self._cover_to_thumb(path, metadata[i], lb_added, db_added) + thumb = self._cover_to_thumb(path, metadata[i], db_added, lb_added) this_book = self._create_new_book(fpath, metadata[i], path, db_added, lb_added, thumb) new_booklist.append(this_book) self._update_iTunes_metadata(metadata[i], db_added, lb_added, this_book) # Add new_book to self.cached_paths self.cached_books[this_book.path] = { - 'title': metadata[i].title, - 'author': metadata[i].author[0], + 'title': metadata[i].title, + 'author': metadata[i].author, 'lib_book': lb_added, - 'dev_book': db_added } - self._dump_cached_books(header="after upload_books()") + 'dev_book': db_added, + 'uuid': metadata[i].uuid} # Report progress if self.report_progress is not None: @@ -812,9 +841,16 @@ class ITUNES(DevicePlugin): for (i,file) in enumerate(files): path = self.path_template % (metadata[i].title, metadata[i].author[0]) - self._remove_existing_copies(path,file,metadata[i]) - fpath = self._get_fpath(file) + self._remove_existing_copy(path, metadata[i]) + fpath = self._get_fpath(file, metadata[i], update_md=True) db_added, lb_added = self._add_new_copy(fpath, metadata[i]) + + if self.manual_sync_mode and not db_added: + # Problem finding added book, probably title/author change needing to be written to metadata + self.problem_msg = ("Title and/or author metadata mismatch with uploaded books.\n" + "Click 'Show Details...' for affected books.") + self.problem_titles.append("'%s' by %s" % (metadata[i].title, metadata[i].author[0])) + thumb = self._cover_to_thumb(path, metadata[i], lb_added, db_added) this_book = self._create_new_book(fpath, metadata[i], path, db_added, lb_added, thumb) new_booklist.append(this_book) @@ -822,10 +858,11 @@ class ITUNES(DevicePlugin): # Add new_book to self.cached_paths self.cached_books[this_book.path] = { - 'title': metadata[i].title, - 'author': metadata[i].author[0], + 'title': metadata[i].title, + 'author': metadata[i].author[0], 'lib_book': lb_added, - 'dev_book': db_added } + 'dev_book': db_added, + 'uuid': metadata[i].uuid} # Report progress if self.report_progress is not None: @@ -841,11 +878,16 @@ class ITUNES(DevicePlugin): self.update_needed = True self.update_msg = "Added books to device" + if False: + self._dump_booklist(new_booklist,header="after upload_books()",indent=2) + self._dump_cached_books(header="after upload_books()",indent=2) return (new_booklist, [], []) + # Private methods def _add_device_book(self,fpath, metadata): ''' + assumes pythoncom wrapper for windows ''' self.log.info(" ITUNES._add_device_book()") if isosx: @@ -857,79 +899,72 @@ class ITUNES(DevicePlugin): break else: if DEBUG: - self.log.error(" Device|Books playlist not found") + self.log.error(" Device|Books playlist not found") # Add the passed book to the Device|Books playlist added = pl.add(appscript.mactypes.File(fpath),to=pl) - if DEBUG: - self.log.info(" adding '%s' to device" % fpath) + if False: + self.log.info(" '%s' added to Device|Books" % metadata.title) return added elif iswindows: if 'iPod' in self.sources: - try: - pythoncom.CoInitialize() - connected_device = self.sources['iPod'] - device = self.iTunes.sources.ItemByName(connected_device) + connected_device = self.sources['iPod'] + device = self.iTunes.sources.ItemByName(connected_device) - added = None - for pl in device.Playlists: - if pl.Kind == self.PlaylistKind.index('User') and \ - pl.SpecialKind == self.PlaylistSpecialKind.index('Books'): - break - else: - if DEBUG: - self.log.info(" no Books playlist found") + db_added = None + for pl in device.Playlists: + if pl.Kind == self.PlaylistKind.index('User') and \ + pl.SpecialKind == self.PlaylistSpecialKind.index('Books'): + break + else: + if DEBUG: + self.log.info(" no Books playlist found") - # Add the passed book to the Device|Books playlist - if pl: - ''' - added = pl.AddFile(fpath) - if DEBUG: - self.log.info(" adding '%s' to device" % fpath) - ''' - file_s = ctypes.c_char_p(fpath) - FileArray = ctypes.c_char_p * 1 - fa = FileArray(file_s) - op_status = pl.AddFiles(fa) + # Add the passed book to the Device|Books playlist + if pl: + file_s = ctypes.c_char_p(fpath) + FileArray = ctypes.c_char_p * 1 + fa = FileArray(file_s) + op_status = pl.AddFiles(fa) + if DEBUG: + sys.stdout.write(" uploading '%s' to Device|Books ..." % metadata.title) + sys.stdout.flush() + + while op_status.InProgress: + time.sleep(0.5) if DEBUG: - sys.stdout.write(" uploading '%s' to device ..." % metadata.title) + sys.stdout.write('.') sys.stdout.flush() + if DEBUG: + sys.stdout.write("\n") + sys.stdout.flush() - while op_status.InProgress: + # This doesn't seem to work with Device, just Library + if False: + if DEBUG: + sys.stdout.write(" waiting for handle to added '%s' ..." % metadata.title) + sys.stdout.flush() + while not op_status.Tracks: time.sleep(0.5) if DEBUG: sys.stdout.write('.') sys.stdout.flush() + if DEBUG: - sys.stdout.write("\n") - sys.stdout.flush() + print + added = op_status.Tracks[0] + else: + # This approach simply scans Library|Books for the book we just added - # This doesn't seem to work with device, just Library - if False: - if DEBUG: - sys.stdout.write(" waiting for handle to added '%s' ..." % metadata.title) - sys.stdout.flush() - while op_status.Tracks is None: - time.sleep(0.5) - if DEBUG: - sys.stdout.write('.') - sys.stdout.flush() - if DEBUG: - print - added = op_status.Tracks[0] - else: - # This approach simply scans Library|Books for the book we just added - added = self._find_device_book( - {'title': metadata.title, - 'author': metadata.author[0]}) - return added + # Try the calibre metadata first + db_added = self._find_device_book( + {'title': metadata.title, + 'author': metadata.authors[0], + 'uuid': metadata.uuid}) - finally: - pythoncom.CoUninitialize() - - return added + return db_added def _add_library_book(self,file, metadata): ''' @@ -963,7 +998,7 @@ class ITUNES(DevicePlugin): sys.stdout.write("\n") sys.stdout.flush() - if True: + if False: if DEBUG: sys.stdout.write(" waiting for handle to added '%s' ..." % metadata.title) sys.stdout.flush() @@ -978,8 +1013,9 @@ class ITUNES(DevicePlugin): else: # This approach simply scans Library|Books for the book we just added added = self._find_library_book( - {'title': metadata.title, - 'author': metadata.author[0]}) + { 'title': metadata.title, + 'author': metadata.author[0], + 'uuid': metadata.uuid}) return added def _add_new_copy(self, fpath, metadata): @@ -993,12 +1029,11 @@ class ITUNES(DevicePlugin): if self.manual_sync_mode: db_added = self._add_device_book(fpath, metadata) - if DEBUG: - self.log.info(" file uploaded to Device|Books") if not getattr(fpath, 'deleted_after_upload', False): lb_added = self._add_library_book(fpath, metadata) - if DEBUG: - self.log.info(" file added to Library|Books for iTunes:iBooks tracking") + if lb_added: + if DEBUG: + self.log.info(" file added to Library|Books for iTunes<->iBooks tracking") else: lb_added = self._add_library_book(fpath, metadata) if DEBUG: @@ -1006,7 +1041,7 @@ class ITUNES(DevicePlugin): return db_added, lb_added - def _cover_to_thumb(self, path, metadata, lb_added, db_added): + def _cover_to_thumb(self, path, metadata, db_added, lb_added): ''' assumes pythoncom wrapper for db_added ''' @@ -1025,7 +1060,7 @@ class ITUNES(DevicePlugin): db_added.artworks[1].data_.set(cover_data.read()) except: if DEBUG: - self.log.warning(" iTunes automation interface generated an error" + self.log.warning(" iTunes automation interface reported an error" " when adding artwork to '%s'" % metadata.title) #import traceback #traceback.print_exc() @@ -1061,7 +1096,7 @@ class ITUNES(DevicePlugin): if DEBUG: self.log.info( " refreshing cached thumb for '%s'" % metadata.title) archive_path = os.path.join(self.cache_dir, "thumbs.zip") - zfw = zipfile.ZipFile(archive_path, mode='a') + zfw = ZipFile(archive_path, mode='a') thumb_path = path.rpartition('.')[0] + '.jpg' zfw.writestr(thumb_path, thumb) zfw.close() @@ -1085,6 +1120,7 @@ class ITUNES(DevicePlugin): this_book.path = path this_book.thumbnail = thumb this_book.iTunes_id = lb_added + this_book.uuid = metadata.uuid if isosx: if lb_added: @@ -1116,14 +1152,32 @@ class ITUNES(DevicePlugin): return this_book + def _delete_iTunesMetadata_plist(self,fpath): + ''' + Delete the plist file from the file to force recache + ''' + zf = ZipFile(fpath,'a') + fnames = zf.namelist() + pl_name = 'iTunesMetadata.plist' + try: + plist = [x for x in fnames if pl_name in x][0] + except: + plist = None + if plist: + if DEBUG: + self.log.info(" deleting %s from %s" % (pl_name,fpath)) + zf.delete(pl_name) + zf.close() + def _discover_manual_sync_mode(self, wait=0): ''' Assumes pythoncom for windows wait is passed when launching iTunes, as it seems to need a moment to come to its senses - ''' if DEBUG: self.log.info(" ITUNES._discover_manual_sync_mode()") + if wait: + time.sleep(wait) if isosx: connected_device = self.sources['iPod'] dev_books = None @@ -1133,22 +1187,29 @@ class ITUNES(DevicePlugin): dev_books = pl.file_tracks() break else: - self.log.error(" book_playlist not found") + self.log.error(" book_playlist not found") if len(dev_books): first_book = dev_books[0] - #if DEBUG: - #self.log.info(" determing manual mode by modifying '%s' by %s" % (first_book.name(), first_book.artist())) + if False: + self.log.info(" determing manual mode by modifying '%s' by %s" % (first_book.name(), first_book.artist())) try: first_book.bpm.set(0) self.manual_sync_mode = True except: self.manual_sync_mode = False - self.log.info(" iTunes.manual_sync_mode: %s" % self.manual_sync_mode) + else: + if DEBUG: + self.log.info(" adding tracer to empty Books|Playlist") + try: + added = pl.add(appscript.mactypes.File(P('tracer.epub')),to=pl) + time.sleep(0.5) + added.delete() + self.manual_sync_mode = True + except: + self.manual_sync_mode = False elif iswindows: - if wait: - time.sleep(wait) connected_device = self.sources['iPod'] device = self.iTunes.sources.ItemByName(connected_device) @@ -1168,76 +1229,137 @@ class ITUNES(DevicePlugin): self.manual_sync_mode = True except: self.manual_sync_mode = False - self.log.info(" iTunes.manual_sync_mode: %s" % self.manual_sync_mode) + else: + if DEBUG: + self.log.info(" sending tracer to empty Books|Playlist") + fpath = P('tracer.epub') + mi = MetaInformation('Tracer',['calibre']) + try: + added = self._add_device_book(fpath,mi) + time.sleep(0.5) + added.Delete() + self.manual_sync_mode = True + except: + self.manual_sync_mode = False - def _dump_booklist(self, booklist, header=None): + self.log.info(" iTunes.manual_sync_mode: %s" % self.manual_sync_mode) + + def _dump_booklist(self, booklist, header=None,indent=0): ''' ''' if header: - msg = '\nbooklist, %s' % header + msg = '\n%sbooklist %s:' % (' '*indent,header) self.log.info(msg) - self.log.info('%s' % ('-' * len(msg))) + self.log.info('%s%s' % (' '*indent,'-' * len(msg))) for book in booklist: if isosx: - self.log.info("%-40.40s %-30.30s %-10.10s" % - (book.title, book.author, str(book.library_id)[-9:])) + self.log.info("%s%-40.40s %-30.30s %-10.10s" % + (' '*indent,book.title, book.author, str(book.library_id)[-9:])) elif iswindows: - self.log.info("%-40.40s %-30.30s" % - (book.title, book.author)) + self.log.info("%s%-40.40s %-30.30s" % + (' '*indent,book.title, book.author)) + self.log.info() - def _dump_cached_book(self, cached_book, header=None): + def _dump_cached_book(self, cached_book, header=None,indent=0): ''' ''' if header: - msg = '%s' % header + msg = '%s%s' % (' '*indent,header) self.log.info(msg) - self.log.info( "%s" % ('-' * len(msg))) + self.log.info( "%s%s" % (' '*indent, '-' * len(msg))) if isosx: - self.log.info("%-40.40s %-30.30s %-10.10s %-10.10s" % - ('title', + self.log.info("%s%-40.40s %-30.30s %-10.10s %-10.10s %s" % + (' '*indent, + 'title', 'author', 'lib_book', - 'dev_book')) - self.log.info("%-40.40s %-30.30s %-10.10s %-10.10s" % - (cached_book['title'], + 'dev_book', + 'uuid')) + self.log.info("%s%-40.40s %-30.30s %-10.10s %-10.10s %s" % + (' '*indent, + cached_book['title'], cached_book['author'], str(cached_book['lib_book'])[-9:], - str(cached_book['dev_book'])[-9:])) + str(cached_book['dev_book'])[-9:], + cached_book['uuid'])) elif iswindows: - self.log.info("%-40.40s %-30.30s" % - (cached_book['title'], - cached_book['author'])) + self.log.info("%s%-40.40s %-30.30s %s" % + (' '*indent, + cached_book['title'], + cached_book['author'], + cached_book['uuid'])) self.log.info() - def _dump_cached_books(self, header=None): + def _dump_cached_books(self, header=None, indent=0): ''' ''' if header: - msg = '\nself.cached_books, %s' % header + msg = '\n%sself.cached_books %s:' % (' '*indent,header) self.log.info(msg) - self.log.info( "%s" % ('-' * len(msg))) + self.log.info( "%s%s" % (' '*indent,'-' * len(msg))) if isosx: - self.log.info("%-40.40s %-30.30s %-10.10s %-10.10s" % - ('title', - 'author', - 'lib_book', - 'dev_book')) for cb in self.cached_books.keys(): - self.log.info("%-40.40s %-30.30s %-10.10s %-10.10s" % - (self.cached_books[cb]['title'], + self.log.info("%s%-40.40s %-30.30s %-10.10s %-10.10s %s" % + (' '*indent, + self.cached_books[cb]['title'], self.cached_books[cb]['author'], str(self.cached_books[cb]['lib_book'])[-9:], - str(self.cached_books[cb]['dev_book'])[-9:])) + str(self.cached_books[cb]['dev_book'])[-9:], + self.cached_books[cb]['uuid'])) elif iswindows: for cb in self.cached_books.keys(): - self.log.info("%-40.40s %-30.30s" % - (self.cached_books[cb]['title'], - self.cached_books[cb]['author'])) + self.log.info("%s%-40.40s %-30.30s %s" % + (' '*indent, + self.cached_books[cb]['title'], + self.cached_books[cb]['author'], + self.cached_books[cb]['uuid'])) self.log.info() + def _dump_epub_metadata(self, fpath): + ''' + ''' + self.log.info(" ITUNES.__get_epub_metadata()") + title = None + author = None + timestamp = None + zf = ZipFile(fpath,'r') + fnames = zf.namelist() + opf = [x for x in fnames if '.opf' in x][0] + if opf: + opf_raw = cStringIO.StringIO(zf.read(opf)).getvalue() + soup = BeautifulSoup(opf_raw) + title = soup.find('dc:title').renderContents() + author = soup.find('dc:creator').renderContents() + ts = soup.find('meta',attrs={'name':'calibre:timestamp'}) + if ts: + # Touch existing calibre timestamp + timestamp = ts['content'] + + if not title or not author: + if DEBUG: + self.log.error(" couldn't extract title/author from %s in %s" % (opf,fpath)) + self.log.error(" title: %s author: %s timestamp: %s" % (title, author, timestamp)) + else: + if DEBUG: + self.log.error(" can't find .opf in %s" % fpath) + zf.close() + return (title, author, timestamp) + + def _dump_files(self, files, header=None,indent=0): + if header: + msg = '\n%sfiles passed to %s:' % (' '*indent,header) + self.log.info(msg) + self.log.info( "%s%s" % (' '*indent,'-' * len(msg))) + for file in files: + if getattr(file, 'orig_file_path', None) is not None: + self.log.info(" %s%s" % (' '*indent,file.orig_file_path)) + elif getattr(file, 'name', None) is not None: + self.log.info(" %s%s" % (' '*indent,file.name)) + self.log.info() + def _dump_hex(self, src, length=16): ''' ''' @@ -1251,18 +1373,6 @@ class ITUNES(DevicePlugin): N+=length print result - def _dump_files(self, files, header=None): - if header: - msg = '\nfiles passed to %s:' % header - self.log.info(msg) - self.log.info( "%s" % ('-' * len(msg))) - for file in files: - if getattr(file, 'orig_file_path', None) is not None: - self.log.info(" %s" % file.orig_file_path) - elif getattr(file, 'name', None) is not None: - self.log.info(" %s" % file.name) - self.log.info() - def _dump_library_books(self, library_books): ''' ''' @@ -1272,52 +1382,60 @@ class ITUNES(DevicePlugin): self.log.info(" %s" % book) self.log.info() - def _dump_update_list(self,header=None): + def _dump_update_list(self,header=None,indent=0): if header: - msg = '\nself.update_list called from %s' % header + msg = '\n%sself.update_list %s' % (' '*indent,header) self.log.info(msg) - self.log.info( "%s" % ('-' * len(msg))) + self.log.info( "%s%s" % (' '*indent,'-' * len(msg))) if isosx: for ub in self.update_list: - self.log.info("%-40.40s %-30.30s %-10.10s" % - (ub['title'], + self.log.info("%s%-40.40s %-30.30s %-10.10s" % + (' '*indent, + ub['title'], ub['author'], str(ub['lib_book'])[-9:])) elif iswindows: for ub in self.update_list: - self.log.info("%-40.40s %-30.30s" % - (ub['title'], + self.log.info("%s%-40.40s %-30.30s" % + (' '*indent, + ub['title'], ub['author'])) self.log.info() - def _find_device_book(self, cached_book): + def _find_device_book(self, search): ''' Windows-only method to get a handle to device book in the current pythoncom session ''' if iswindows: - if DEBUG: - self.log.info(" ITUNES._find_device_book()") - self.log.info(" looking for '%s' by %s" % (cached_book['title'], cached_book['author'])) - dev_books = self._get_device_books_playlist() + if DEBUG: + self.log.info(" ITUNES._find_device_book(uuid)") + self.log.info(" searching for %s ('%s' by %s)" % + (search['uuid'], search['title'], search['author'])) attempts = 9 while attempts: - # Find book whose Artist field = cached_book['author'] - hits = dev_books.Search(cached_book['author'],self.SearchField.index('Artists')) + # Try by uuid + hits = dev_books.Search(search['uuid'],self.SearchField.index('Albums')) if hits: - for hit in hits: - self.log.info(" evaluating '%s' by %s" % (hit.Name, hit.Artist)) - if hit.Name == cached_book['title']: - self.log.info(" matched '%s' by %s" % (hit.Name, hit.Artist)) - return hit + hit = hits[0] + self.log.info(" found '%s' by %s (%s)" % (hit.Name, hit.Artist, hit.Album)) + return hit + + # Try by author + hits = dev_books.Search(search['author'],self.SearchField.index('Artists')) + if hits: + hit = hits[0] + self.log.info(" found '%s' by %s" % (hit.Name, hit.Artist)) + return hit + attempts -= 1 time.sleep(0.5) if DEBUG: self.log.warning(" attempt #%d" % (10 - attempts)) if DEBUG: - self.log.error(" search for '%s' yielded no hits" % cached_book['title']) + self.log.error(" no hits") return None def _find_library_book(self, cached_book): @@ -1327,7 +1445,12 @@ class ITUNES(DevicePlugin): if iswindows: if DEBUG: self.log.info(" ITUNES._find_library_book()") - self.log.info(" looking for '%s' by %s" % (cached_book['title'], cached_book['author'])) + if 'uuid' in cached_book: + self.log.info(" looking for '%s' by %s (%s)" % + (cached_book['title'], cached_book['author'], cached_book['uuid'])) + else: + self.log.info(" looking for '%s' by %s" % + (cached_book['title'], cached_book['author'])) for source in self.iTunes.sources: if source.Kind == self.Sources.index('Library'): @@ -1354,18 +1477,27 @@ class ITUNES(DevicePlugin): attempts = 9 while attempts: - # Find book whose Artist field = cached_book['author'] + # Find book whose Album field = cached_book['uuid'] + if 'uuid' in cached_book: + hits = lib_books.Search(cached_book['uuid'],self.SearchField.index('Albums')) + if hits: + hit = hits[0] + if DEBUG: + self.log.info(" found '%s' by %s (%s)" % (hit.Name, hit.Artist, hit.Album)) + return hit + hits = lib_books.Search(cached_book['author'],self.SearchField.index('Artists')) if hits: - for hit in hits: - self.log.info(" evaluating '%s' by %s" % (hit.Name, hit.Artist)) - if hit.Name == cached_book['title']: - self.log.info(" matched '%s' by %s" % (hit.Name, hit.Artist)) - return hit + hit = hits[0] + if hit.Name == cached_book['title']: + if DEBUG: + self.log.info(" found '%s' by %s (%s)" % (hit.Name, hit.Artist, hit.Album)) + return hit + attempts -= 1 time.sleep(0.5) if DEBUG: - self.log.warning(" attempt #%d" % (10 - attempts)) + self.log.warning(" attempt #%d" % (10 - attempts)) if DEBUG: self.log.error(" search for '%s' yielded no hits" % cached_book['title']) @@ -1382,11 +1514,11 @@ class ITUNES(DevicePlugin): thumb_path = book_path.rpartition('.')[0] + '.jpg' try: - zfr = zipfile.ZipFile(archive_path) + zfr = ZipFile(archive_path) thumb_data = zfr.read(thumb_path) zfr.close() except: - zfw = zipfile.ZipFile(archive_path, mode='a') + zfw = ZipFile(archive_path, mode='a') else: return thumb_data @@ -1420,9 +1552,9 @@ class ITUNES(DevicePlugin): return None # Save the cover from iTunes - tmp_thumb = os.path.join(tempfile.gettempdir(), "thumb.%s" % self.ArtworkFormat[book.Artwork.Item(1).Format]) - book.Artwork.Item(1).SaveArtworkToFile(tmp_thumb) try: + tmp_thumb = os.path.join(tempfile.gettempdir(), "thumb.%s" % self.ArtworkFormat[book.Artwork.Item(1).Format]) + book.Artwork.Item(1).SaveArtworkToFile(tmp_thumb) # Resize the cover im = PILImage.open(tmp_thumb) scaled, width, height = fit_image(im.size[0],im.size[1], 60, 80) @@ -1445,15 +1577,16 @@ class ITUNES(DevicePlugin): ''' Calculate the exploded size of file ''' - myZip = zipfile.ZipFile(file,'r') + myZip = ZipFile(file,'r') myZipList = myZip.infolist() exploded_file_size = 0 for file in myZipList: exploded_file_size += file.file_size - if DEBUG: + if False: self.log.info(" ITUNES._get_device_book_size()") self.log.info(" %d items in archive" % len(myZipList)) self.log.info(" compressed: %d exploded: %d" % (compressed_size, exploded_file_size)) + myZip.close() return exploded_file_size def _get_device_books(self): @@ -1484,8 +1617,11 @@ class ITUNES(DevicePlugin): self.log.info(" ignoring '%s' of type '%s'" % (book.name(), book.kind())) else: if DEBUG: - self.log.info(" adding %-30.30s %-30.30s [%s]" % (book.name(), book.artist(), book.kind())) + self.log.info(" %-30.30s %-30.30s %s [%s]" % + (book.name(), book.artist(), book.album(), book.kind())) device_books.append(book) + if DEBUG: + self.log.info() elif iswindows: if 'iPod' in self.sources: @@ -1513,8 +1649,10 @@ class ITUNES(DevicePlugin): self.log.info(" ignoring '%s' of type '%s'" % (book.Name, book.KindAsString)) else: if DEBUG: - self.log.info(" adding %-30.30s %-30.30s [%s]" % (book.Name, book.Artist, book.KindAsString)) + self.log.info(" %-30.30s %-30.30s %s [%s]" % (book.Name, book.Artist, book.Album, book.KindAsString)) device_books.append(book) + if DEBUG: + self.log.info() finally: pythoncom.CoUninitialize() @@ -1525,8 +1663,8 @@ class ITUNES(DevicePlugin): ''' assumes pythoncom wrapper ''' - if DEBUG: - self.log.info(" ITUNES._get_device_books_playlist()") +# if DEBUG: +# self.log.info(" ITUNES._get_device_books_playlist()") if iswindows: if 'iPod' in self.sources: pl = None @@ -1542,11 +1680,12 @@ class ITUNES(DevicePlugin): self.log.error(" no iPad|Books playlist found") return pl - def _get_fpath(self,file): + def _get_fpath(self,file, metadata, update_md=False): ''' If the database copy will be deleted after upload, we have to use file (the PersistentTemporaryFile), which will be around until calibre exits. + If we're using the database copy, delete the plist ''' if DEBUG: self.log.info(" ITUNES._get_fpath()") @@ -1554,12 +1693,25 @@ class ITUNES(DevicePlugin): fpath = file if not getattr(fpath, 'deleted_after_upload', False): if getattr(file, 'orig_file_path', None) is not None: + # Database copy fpath = file.orig_file_path + self._delete_iTunesMetadata_plist(fpath) elif getattr(file, 'name', None) is not None: + # PTF fpath = file.name else: + # Recipe - PTF if DEBUG: self.log.info(" file will be deleted after upload") + + if update_md: + self._update_epub_metadata(fpath, metadata) + +# if DEBUG: +# self.log.info(" metadata before rewrite: '{0[0]}' '{0[1]}' '{0[2]}'".format(self._dump_epub_metadata(fpath))) +# self._update_epub_metadata(fpath, metadata) +# if DEBUG: +# self.log.info(" metadata after rewrite: '{0[0]}' '{0[1]}' '{0[2]}'".format(self._dump_epub_metadata(fpath))) return fpath def _get_library_books(self): @@ -1609,12 +1761,12 @@ class ITUNES(DevicePlugin): if str(book.description()).startswith(self.description_prefix): if book.location() == appscript.k.missing_value: library_orphans[path] = book - if DEBUG: + if False: self.log.info(" found iTunes PTF '%s' in Library|Books" % book.name()) library_books[path] = book if DEBUG: - self.log.info(" adding %-30.30s %-30.30s [%s]" % (book.name(), book.artist(), book.kind())) + self.log.info(" %-30.30s %-30.30s %s [%s]" % (book.name(), book.artist(), book.album(), book.kind())) else: if DEBUG: self.log.info(' no Library playlists') @@ -1627,10 +1779,10 @@ class ITUNES(DevicePlugin): for source in self.iTunes.sources: if source.Kind == self.Sources.index('Library'): lib = source - self.log.info(" Library source: '%s' kind: %s" % (lib.Name, self.Sources[lib.Kind])) + self.log.info(" Library source: '%s' kind: %s" % (lib.Name, self.Sources[lib.Kind])) break else: - self.log.error(" Library source not found") + self.log.error(" Library source not found") if lib is not None: lib_books = None @@ -1639,22 +1791,22 @@ class ITUNES(DevicePlugin): if pl.Kind == self.PlaylistKind.index('User') and \ pl.SpecialKind == self.PlaylistSpecialKind.index('Books'): if DEBUG: - self.log.info(" Books playlist: '%s'" % (pl.Name)) + self.log.info(" Books playlist: '%s'" % (pl.Name)) lib_books = pl.Tracks break else: if DEBUG: - self.log.error(" no Library|Books playlist found") + self.log.error(" no Library|Books playlist found") else: if DEBUG: - self.log.error(" no Library playlists found") + self.log.error(" no Library playlists found") try: for book in lib_books: # This may need additional entries for international iTunes users if book.KindAsString in ['MPEG audio file']: if DEBUG: - self.log.info(" ignoring %-30.30s of type '%s'" % (book.Name, book.KindAsString)) + self.log.info(" ignoring %-30.30s of type '%s'" % (book.Name, book.KindAsString)) else: path = self.path_template % (book.Name, book.Artist) @@ -1662,12 +1814,12 @@ class ITUNES(DevicePlugin): if book.Description.startswith(self.description_prefix): if not book.Location: library_orphans[path] = book - if DEBUG: + if False: self.log.info(" found iTunes PTF '%s' in Library|Books" % book.Name) library_books[path] = book if DEBUG: - self.log.info(" adding %-30.30s %-30.30s [%s]" % (book.Name, book.Artist, book.KindAsString)) + self.log.info(" %-30.30s %-30.30s %s [%s]" % (book.Name, book.Artist, book.Album, book.KindAsString)) except: if DEBUG: self.log.info(" no books in library") @@ -1744,24 +1896,24 @@ class ITUNES(DevicePlugin): self.log.info( "ITUNES:open(): Launching iTunes" ) self.iTunes = iTunes= appscript.app('iTunes', hide=True) iTunes.run() - initial_status = 'launched' + self.initial_status = 'launched' else: self.iTunes = appscript.app('iTunes') - initial_status = 'already running' + self.initial_status = 'already running' # Read the current storage path for iTunes media cmd = "defaults read com.apple.itunes NSNavLastRootDirectory" proc = subprocess.Popen( cmd, shell=True, cwd=os.curdir, stdout=subprocess.PIPE) proc.wait() - media_dir = os.path.abspath(proc.communicate()[0].strip()) + media_dir = os.path.expanduser(proc.communicate()[0].strip()) if os.path.exists(media_dir): self.iTunes_media = media_dir else: self.log.error(" could not confirm valid iTunes.media_dir from %s" % 'com.apple.itunes') - + self.log.error(" media_dir: %s" % media_dir) if DEBUG: - self.log.info(" [%s - %s (%s), driver version %d.%d.%d]" % - (self.iTunes.name(), self.iTunes.version(), initial_status, + self.log.info(" [OSX %s - %s (%s), driver version %d.%d.%d]" % + (self.iTunes.name(), self.iTunes.version(), self.initial_status, self.version[0],self.version[1],self.version[2])) self.log.info(" iTunes_media: %s" % self.iTunes_media) if iswindows: @@ -1773,7 +1925,7 @@ class ITUNES(DevicePlugin): self.iTunes = win32com.client.Dispatch("iTunes.Application") if not DEBUG: self.iTunes.Windows[0].Minimized = True - initial_status = 'launched' + self.initial_status = 'launched' # Read the current storage path for iTunes media from the XML file with open(self.iTunes.LibraryXMLPath, 'r') as xml: @@ -1789,8 +1941,8 @@ class ITUNES(DevicePlugin): self.log.error(" '%s' not found" % media_dir) if DEBUG: - self.log.info(" [%s - %s (%s), driver version %d.%d.%d]" % - (self.iTunes.Windows[0].name, self.iTunes.Version, initial_status, + self.log.info(" [Windows %s - %s (%s), driver version %d.%d.%d]" % + (self.iTunes.Windows[0].name, self.iTunes.Version, self.initial_status, self.version[0],self.version[1],self.version[2])) self.log.info(" iTunes_media: %s" % self.iTunes_media) @@ -1801,7 +1953,7 @@ class ITUNES(DevicePlugin): This occurs when the user deletes a book in iBooks while disconnected ''' if DEBUG: - self.log.info("\n ITUNES._purge_orphans") + self.log.info(" ITUNES._purge_orphans()") #self._dump_library_books(library_books) #self.log.info(" cached_books:\n %s" % "\n ".join(cached_books.keys())) @@ -1824,39 +1976,45 @@ class ITUNES(DevicePlugin): 'author':library_books[book].Artist, 'lib_book':library_books[book]} self._remove_from_iTunes(btr) + if DEBUG: + self.log.info() - def _remove_existing_copies(self,path,file,metadata): + def _remove_existing_copy(self, path, metadata): ''' ''' if DEBUG: - self.log.info(" ITUNES._remove_existing_copies()") + self.log.info(" ITUNES._remove_existing_copy()") if self.manual_sync_mode: # Delete existing from Device|Books, add to self.update_list # for deletion from booklist[0] during add_books_to_metadata - if path in self.cached_books: - self.update_list.append(self.cached_books[path]) - self._remove_from_device(self.cached_books[path]) - if DEBUG: - self.log.info( " deleting device book '%s'" % (path)) - if not getattr(file, 'deleted_after_upload', False): - self._remove_from_iTunes(self.cached_books[path]) + for book in self.cached_books: + if self.cached_books[book]['uuid'] == metadata.uuid: + self.update_list.append(self.cached_books[book]) + self._remove_from_device(self.cached_books[book]) if DEBUG: - self.log.info(" deleting library book '%s'" % path) + self.log.info( " deleting device book '%s'" % (metadata.title)) + if not getattr(file, 'deleted_after_upload', False): + self._remove_from_iTunes(self.cached_books[book]) + if DEBUG: + self.log.info(" deleting library book '%s'" % metadata.title) + break else: if DEBUG: self.log.info(" '%s' not in cached_books" % metadata.title) else: # Delete existing from Library|Books, add to self.update_list # for deletion from booklist[0] during add_books_to_metadata - if path in self.cached_books: - self.update_list.append(self.cached_books[path]) - self._remove_from_iTunes(self.cached_books[path]) - if DEBUG: - self.log.info( " deleting library book '%s'" % path) - else: - if DEBUG: - self.log.info(" '%s' not in cached_books" % metadata.title) + for book in self.cached_books: + if self.cached_books[book]['uuid'] == metadata.uuid: + self.update_list.append(self.cached_books[book]) + self._remove_from_iTunes(self.cached_books[book]) + if DEBUG: + self.log.info( " deleting library book '%s'" % metadata.title) + break + else: + if DEBUG: + self.log.info(" '%s' not in cached_books" % metadata.title) def _remove_from_device(self, cached_book): ''' @@ -1864,22 +2022,18 @@ class ITUNES(DevicePlugin): ''' self.log.info(" ITUNES._remove_from_device()") if isosx: - if DEBUG: + if False: self.log.info(" deleting %s" % cached_book['dev_book']) cached_book['dev_book'].delete() elif iswindows: dev_pl = self._get_device_books_playlist() - hits = dev_pl.Search(cached_book['author'],self.SearchField.index('Artists')) + hits = dev_pl.Search(cached_book['uuid'],self.SearchField.index('Albums')) if hits: - for hit in hits: - if DEBUG: - self.log.info(" evaluating '%s' by %s" % (hit.Name, hit.Artist)) - if hit.Name == cached_book['title']: - if DEBUG: - self.log.info(" deleting '%s' by %s" % (hit.Name, hit.Artist)) - hit.Delete() - break + hit = hits[0] + if False: + self.log.info(" deleting '%s' by %s (%s)" % (hit.Name, hit.Artist, hit.Album)) + hit.Delete() def _remove_from_iTunes(self, cached_book): ''' @@ -1917,13 +2071,18 @@ class ITUNES(DevicePlugin): self.log.info(" author_storage_path not empty (%d objects):" % len(author_files)) self.log.info(" %s" % '\n'.join(author_files)) else: - self.log.info(" '%s' stored external to iTunes, no files deleted" % cached_book['title']) + self.log.info(" '%s' (stored external to iTunes, no files deleted)" % cached_book['title']) except: # We get here if there was an error with .location().path - self.log.info(" removing orphan '%s' from iTunes" % cached_book['title']) + if DEBUG: + self.log.info(" '%s' not found in iTunes" % cached_book['title']) - self.iTunes.delete(cached_book['lib_book']) + try: + self.iTunes.delete(cached_book['lib_book']) + except: + if DEBUG: + self.log.info(" '%s' not found in iTunes" % cached_book['title']) elif iswindows: ''' @@ -1935,28 +2094,97 @@ class ITUNES(DevicePlugin): path = book.Location except: book = self._find_library_book(cached_book) - path = book.Location - storage_path = os.path.split(book.Location) - if book.Location.startswith(self.iTunes_media): - if DEBUG: - self.log.info(" removing '%s' at %s" % - (cached_book['title'], path)) - try: - os.remove(path) - except: - self.log.warning(" could not find '%s' in iTunes storage" % path) - try: - os.rmdir(storage_path[0]) - self.log.info(" removed folder '%s'" % storage_path[0]) - except: - self.log.info(" folder '%s' not found or not empty" % storage_path[0]) + if book: + storage_path = os.path.split(book.Location) + if book.Location.startswith(self.iTunes_media): + if DEBUG: + self.log.info(" removing '%s' at %s" % + (cached_book['title'], book.Location)) + try: + os.remove(path) + except: + self.log.warning(" could not find '%s' in iTunes storage" % path) + try: + os.rmdir(storage_path[0]) + self.log.info(" removed folder '%s'" % storage_path[0]) + except: + self.log.info(" folder '%s' not found or not empty" % storage_path[0]) - # Delete from iTunes database + # Delete from iTunes database + else: + self.log.info(" '%s' (stored external to iTunes, no files deleted)" % cached_book['title']) else: - self.log.info(" '%s' stored external to iTunes, no files deleted" % cached_book['title']) + if DEBUG: + self.log.info(" '%s' not found in iTunes" % cached_book['title']) + try: + book.Delete() + except: + if DEBUG: + self.log.info(" '%s' not found in iTunes" % cached_book['title']) - book.Delete() + def _update_epub_metadata(self, fpath, metadata): + ''' + ''' + self.log.info(" ITUNES._update_epub_metadata()") + + # Refresh epub metadata + with open(fpath,'r+b') as zfo: + ''' + # Touch the timestamp to force a recache + if metadata.timestamp: + if DEBUG: + self.log.info(" old timestamp: %s" % metadata.timestamp) + old_ts = metadata.timestamp + metadata.timestamp = datetime.datetime(old_ts.year, old_ts.month, old_ts.day, old_ts.hour, + old_ts.minute, old_ts.second, old_ts.microsecond+1, old_ts.tzinfo) + if DEBUG: + self.log.info(" new timestamp: %s" % metadata.timestamp) + else: + metadata.timestamp = isoformat(now()) + if DEBUG: + self.log.info(" add timestamp: %s" % metadata.timestamp) + ''' + # Touch the OPF timestamp + zf_opf = ZipFile(fpath,'r') + fnames = zf_opf.namelist() + opf = [x for x in fnames if '.opf' in x][0] + if opf: + opf_raw = cStringIO.StringIO(zf_opf.read(opf)).getvalue() + soup = BeautifulSoup(opf_raw) + md = soup.find('metadata') + ts = md.find('meta',attrs={'name':'calibre:timestamp'}) + if ts: + # Touch existing calibre timestamp + timestamp = ts['content'] + old_ts = parse_date(timestamp) + metadata.timestamp = datetime.datetime(old_ts.year, old_ts.month, old_ts.day, old_ts.hour, + old_ts.minute, old_ts.second, old_ts.microsecond+1, old_ts.tzinfo) + else: + metadata.timestamp = isoformat(now()) + if DEBUG: + self.log.info(" add timestamp: %s" % metadata.timestamp) + zf_opf.close() + + # If 'News' in tags, tweak the title/author for friendlier display in iBooks + if _('News') in metadata.tags: + if metadata.title.find('[') > 0: + metadata.title = metadata.title[:metadata.title.find('[')-1] + date_as_author = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y')) + metadata.author = metadata.authors = [date_as_author] + sort_author = re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', metadata.title).rstrip() + metadata.author_sort = '%s %s' % (sort_author, strftime('%Y-%m-%d')) + + # Remove any non-alpha category tags + for tag in metadata.tags: + if not self._is_alpha(tag[0]): + metadata.tags.remove(tag) + + # If windows & series, nuke tags so series used as Category during _update_iTunes_metadata() + if iswindows and metadata.series: + metadata.tags = None + + set_metadata(zfo, metadata, update_timestamp=True) def _update_device(self, msg='', wait=True): ''' @@ -2014,6 +2242,20 @@ class ITUNES(DevicePlugin): strip_tags = re.compile(r'<[^<]*?/?>') if isosx: + if lb_added: + lb_added.album.set(metadata.uuid) + lb_added.description.set("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) + lb_added.enabled.set(True) + lb_added.sort_artist.set(metadata.author_sort.title()) + lb_added.sort_name.set(this_book.title_sorter) + + if db_added: + db_added.album.set(metadata.uuid) + db_added.description.set("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) + db_added.enabled.set(True) + db_added.sort_artist.set(metadata.author_sort.title()) + db_added.sort_name.set(this_book.title_sorter) + if metadata.comments: if lb_added: lb_added.comment.set(strip_tags.sub('',metadata.comments)) @@ -2030,31 +2272,41 @@ class ITUNES(DevicePlugin): except: pass - if lb_added: - lb_added.description.set("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) - lb_added.enabled.set(True) - lb_added.sort_artist.set(metadata.author_sort.title()) - lb_added.sort_name.set(this_book.title_sorter) - - if db_added: - db_added.description.set("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) - db_added.enabled.set(True) - db_added.sort_artist.set(metadata.author_sort.title()) - db_added.sort_name.set(this_book.title_sorter) - - # Set genre from metadata - # iTunes grabs the first dc:subject from the opf metadata, - # But we can manually override with first tag starting with alpha - for tag in metadata.tags: - if self._is_alpha(tag[0]): - if lb_added: - lb_added.genre.set(tag) - if db_added: - db_added.genre.set(tag) - break - + # Set genre from series if available, else first alpha tag + # Otherwise iTunes grabs the first dc:subject from the opf metadata, + if metadata.series: + if lb_added: + lb_added.genre.set(metadata.series) + lb_added.episode_ID.set(metadata.series) + lb_added.episode_number.set(metadata.series_index) + if db_added: + db_added.genre.set(metadata.series) + db_added.episode_ID.set(metadata.series) + db_added.episode_number.set(metadata.series_index) + elif metadata.tags: + for tag in metadata.tags: + if self._is_alpha(tag[0]): + if lb_added: + lb_added.genre.set(tag) + if db_added: + db_added.genre.set(tag) + break elif iswindows: + if lb_added: + lb_added.Album = metadata.uuid + lb_added.Description = ("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) + lb_added.Enabled = True + lb_added.SortArtist = (metadata.author_sort.title()) + lb_added.SortName = (this_book.title_sorter) + + if db_added: + db_added.Album = metadata.uuid + db_added.Description = ("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) + db_added.Enabled = True + db_added.SortArtist = (metadata.author_sort.title()) + db_added.SortName = (this_book.title_sorter) + if metadata.comments: if lb_added: lb_added.Comment = (strip_tags.sub('',metadata.comments)) @@ -2069,29 +2321,40 @@ class ITUNES(DevicePlugin): if db_added: db_added.AlbumRating = (metadata.rating*10) except: - pass + if DEBUG: + self.log.warning(" iTunes automation interface reported an error" + " setting AlbumRating") - if lb_added: - lb_added.Description = ("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) - lb_added.Enabled = True - lb_added.SortArtist = (metadata.author_sort.title()) - lb_added.SortName = (this_book.title_sorter) + # Set Category from first alpha tag, overwrite with series if available + # Otherwise iBooks uses first from opf + # iTunes balks on setting EpisodeNumber, but it sticks (9.1.1.12) - if db_added: - db_added.Description = ("%s %s" % (self.description_prefix,strftime('%Y-%m-%d %H:%M:%S'))) - db_added.SortArtist = (metadata.author_sort.title()) - db_added.SortName = (this_book.title_sorter) + if metadata.series: + if lb_added: + lb_added.Category = metadata.series + lb_added.EpisodeID = metadata.series + try: + lb_added.EpisodeNumber = metadata.series_index + except: + pass + if db_added: + db_added.Category = metadata.series + db_added.EpisodeID = metadata.series + try: + db_added.EpisodeNumber = metadata.series_index + except: + if DEBUG: + self.log.warning(" iTunes automation interface reported an error" + " setting EpisodeNumber") + elif metadata.tags: + for tag in metadata.tags: + if self._is_alpha(tag[0]): + if lb_added: + lb_added.Category = tag + if db_added: + db_added.Category = tag + break - # Set genre from metadata - # iTunes grabs the first dc:subject from the opf metadata, - # But we can manually override with first tag starting with alpha - for tag in metadata.tags: - if self._is_alpha(tag[0]): - if lb_added: - lb_added.Category = (tag) - if db_added: - db_added.Category = (tag) - break class BookList(list): ''' diff --git a/src/calibre/devices/eb600/driver.py b/src/calibre/devices/eb600/driver.py index 307531c357..9b7a21a3bb 100644 --- a/src/calibre/devices/eb600/driver.py +++ b/src/calibre/devices/eb600/driver.py @@ -201,4 +201,21 @@ class ELONEX(EB600): def can_handle(cls, dev, debug=False): return dev[3] == 'Elonex' and dev[4] == 'eBook' +class POCKETBOOK301(USBMS): + + name = 'PocketBook 301 Device Interface' + description = _('Communicate with the PocketBook 301 reader.') + author = 'Kovid Goyal' + supported_platforms = ['windows', 'osx', 'linux'] + FORMATS = ['epub', 'fb2', 'prc', 'mobi', 'pdf', 'djvu', 'rtf', 'chm', 'txt'] + + SUPPORTS_SUB_DIRS = True + + MAIN_MEMORY_VOLUME_LABEL = 'PocketBook 301 Main Memory' + STORAGE_CARD_VOLUME_LABEL = 'PocketBook 301 Storage Card' + + VENDOR_ID = [0x1] + PRODUCT_ID = [0x301] + BCD = [0x132] + diff --git a/src/calibre/devices/hanlin/driver.py b/src/calibre/devices/hanlin/driver.py index adb4b353f3..0d972afc76 100644 --- a/src/calibre/devices/hanlin/driver.py +++ b/src/calibre/devices/hanlin/driver.py @@ -81,9 +81,6 @@ class HANLINV3(USBMS): return drives - - - class HANLINV5(HANLINV3): name = 'Hanlin V5 driver' gui_name = 'Hanlin V5' @@ -120,8 +117,22 @@ class BOOX(HANLINV3): MAIN_MEMORY_VOLUME_LABEL = 'BOOX Internal Memory' STORAGE_CARD_VOLUME_LABEL = 'BOOX Storage Card' - EBOOK_DIR_MAIN = 'MyBooks' - EBOOK_DIR_CARD_A = 'MyBooks' + EBOOK_DIR_MAIN = ['MyBooks'] + EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to ' + 'send e-books to on the device. The first one that exists will ' + 'be used.') + EXTRA_CUSTOMIZATION_DEFAULT = ', '.join(EBOOK_DIR_MAIN) + + # EBOOK_DIR_CARD_A = 'MyBooks' ## Am quite sure we need this. + + def post_open_callback(self): + opts = self.settings() + dirs = opts.extra_customization + if not dirs: + dirs = self.EBOOK_DIR_MAIN + else: + dirs = [x.strip() for x in dirs.split(',')] + self.EBOOK_DIR_MAIN = dirs def windows_sort_drives(self, drives): return drives diff --git a/src/calibre/devices/prs505/driver.py b/src/calibre/devices/prs505/driver.py index 38fac8b266..5860826778 100644 --- a/src/calibre/devices/prs505/driver.py +++ b/src/calibre/devices/prs505/driver.py @@ -55,6 +55,7 @@ class PRS505(USBMS): SUPPORTS_SUB_DIRS = True MUST_READ_METADATA = True + SUPPORTS_USE_AUTHOR_SORT = True EBOOK_DIR_MAIN = 'database/media/books' EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of metadata fields ' @@ -125,7 +126,7 @@ class PRS505(USBMS): d = os.path.dirname(paths[source_id]) if not os.path.exists(d): os.makedirs(d) - return XMLCache(paths, prefixes) + return XMLCache(paths, prefixes, self.settings().use_author_sort) def books(self, oncard=None, end_session=True): debug_print('PRS505: starting fetching books for card', oncard) diff --git a/src/calibre/devices/prs505/sony_cache.py b/src/calibre/devices/prs505/sony_cache.py index 727bdf68b2..e7d0e4686c 100644 --- a/src/calibre/devices/prs505/sony_cache.py +++ b/src/calibre/devices/prs505/sony_cache.py @@ -60,12 +60,13 @@ def uuid(): class XMLCache(object): - def __init__(self, paths, prefixes): + def __init__(self, paths, prefixes, use_author_sort): if DEBUG: debug_print('Building XMLCache...') pprint(paths) self.paths = paths self.prefixes = prefixes + self.use_author_sort = use_author_sort # Parse XML files {{{ parser = etree.XMLParser(recover=True) @@ -434,7 +435,10 @@ class XMLCache(object): if not ts: ts = title_sort(title) record.set('titleSorter', ts) - record.set('author', authors_to_string(book.authors)) + if self.use_author_sort and book.author_sort is not None: + record.set('author', book.author_sort) + else: + record.set('author', authors_to_string(book.authors)) ext = os.path.splitext(path)[1] if ext: ext = ext[1:].lower() diff --git a/src/calibre/devices/usbms/device.py b/src/calibre/devices/usbms/device.py index 2f01b8dd41..d899c8e995 100644 --- a/src/calibre/devices/usbms/device.py +++ b/src/calibre/devices/usbms/device.py @@ -80,6 +80,7 @@ class Device(DeviceConfig, DevicePlugin): SUPPORTS_SUB_DIRS = False MUST_READ_METADATA = False + SUPPORTS_USE_AUTHOR_SORT = False EBOOK_DIR_MAIN = '' EBOOK_DIR_CARD_A = '' diff --git a/src/calibre/devices/usbms/deviceconfig.py b/src/calibre/devices/usbms/deviceconfig.py index a8220261f3..5edefff743 100644 --- a/src/calibre/devices/usbms/deviceconfig.py +++ b/src/calibre/devices/usbms/deviceconfig.py @@ -32,6 +32,8 @@ class DeviceConfig(object): help=_('Place files in sub directories if the device supports them')) c.add_opt('read_metadata', default=True, help=_('Read metadata from files on device')) + c.add_opt('use_author_sort', default=False, + help=_('Use author sort instead of author')) c.add_opt('save_template', default=cls._default_save_template(), help=_('Template to control how books are saved')) c.add_opt('extra_customization', @@ -47,7 +49,8 @@ class DeviceConfig(object): def config_widget(cls): from calibre.gui2.device_drivers.configwidget import ConfigWidget cw = ConfigWidget(cls.settings(), cls.FORMATS, cls.SUPPORTS_SUB_DIRS, - cls.MUST_READ_METADATA, cls.EXTRA_CUSTOMIZATION_MESSAGE) + cls.MUST_READ_METADATA, cls.SUPPORTS_USE_AUTHOR_SORT, + cls.EXTRA_CUSTOMIZATION_MESSAGE) return cw @classmethod @@ -58,6 +61,8 @@ class DeviceConfig(object): proxy['use_subdirs'] = config_widget.use_subdirs() if not cls.MUST_READ_METADATA: proxy['read_metadata'] = config_widget.read_metadata() + if cls.SUPPORTS_USE_AUTHOR_SORT: + proxy['use_author_sort'] = config_widget.use_author_sort() if cls.EXTRA_CUSTOMIZATION_MESSAGE: ec = unicode(config_widget.opt_extra_customization.text()).strip() if not ec: diff --git a/src/calibre/devices/usbms/driver.py b/src/calibre/devices/usbms/driver.py index 6f558b9b34..2fc8b0d814 100644 --- a/src/calibre/devices/usbms/driver.py +++ b/src/calibre/devices/usbms/driver.py @@ -299,7 +299,7 @@ class USBMS(CLI, Device): def replfunc(match): if match.group(1) in ['title', 'series', 'series_index', 'isbn']: return '(?P<' + match.group(1) + '>.+?)' - elif match.group(1) == 'authors': + elif match.group(1) in ['authors', 'author_sort']: return '(?P.+?)' else: return '(.+?)' diff --git a/src/calibre/ebooks/chm/reader.py b/src/calibre/ebooks/chm/reader.py index bbb43af567..d0a81e8e7f 100644 --- a/src/calibre/ebooks/chm/reader.py +++ b/src/calibre/ebooks/chm/reader.py @@ -8,7 +8,7 @@ import os, re from mimetypes import guess_type as guess_mimetype from calibre.ebooks.BeautifulSoup import BeautifulSoup, NavigableString - +from calibre.constants import iswindows from calibre.utils.chm.chm import CHMFile from calibre.utils.chm.chmlib import ( CHM_RESOLVE_SUCCESS, CHM_ENUMERATE_NORMAL, @@ -135,10 +135,16 @@ class CHMReader(CHMFile): if lpath.find(';') != -1: # fix file names with ";" at the end, see _reformat() lpath = lpath.split(';')[0] - with open(lpath, 'wb') as f: - if guess_mimetype(path)[0] == ('text/html'): - data = self._reformat(data) - f.write(data) + try: + with open(lpath, 'wb') as f: + if guess_mimetype(path)[0] == ('text/html'): + data = self._reformat(data) + f.write(data) + except: + if iswindows and len(lpath) > 250: + self.log.warn('%r filename too long, skipping'%path) + continue + raise self._extracted = True files = os.listdir(output_dir) if self.hhc_path not in files: diff --git a/src/calibre/ebooks/epub/output.py b/src/calibre/ebooks/epub/output.py index ee779aaefa..8708b98d97 100644 --- a/src/calibre/ebooks/epub/output.py +++ b/src/calibre/ebooks/epub/output.py @@ -385,14 +385,6 @@ class EPUBOutput(OutputFormatPlugin): if val and not pval: rule.style.setProperty('padding-left', val) - if stylesheet is not None: - stylesheet.data.add('a { color: inherit; text-decoration: inherit; ' - 'cursor: default; }') - stylesheet.data.add('a[href] { color: blue; ' - 'text-decoration: underline; cursor:pointer; }') - else: - self.oeb.log.warn('No stylesheet found') - # }}} def workaround_sony_quirks(self): # {{{ diff --git a/src/calibre/ebooks/metadata/__init__.py b/src/calibre/ebooks/metadata/__init__.py index 8caca1f261..690cca511a 100644 --- a/src/calibre/ebooks/metadata/__init__.py +++ b/src/calibre/ebooks/metadata/__init__.py @@ -28,10 +28,14 @@ def authors_to_string(authors): else: return '' +_bracket_pat = re.compile(r'[\[({].*?[})\]]') def author_to_author_sort(author): + if not author: + return '' method = tweaks['author_sort_copy_method'] if method == 'copy' or (method == 'comma' and ',' in author): return author + author = _bracket_pat.sub('', author).strip() tokens = author.split() tokens = tokens[-1:] + tokens[:-1] if len(tokens) > 1: @@ -256,7 +260,7 @@ class MetaInformation(object): setattr(self, x, getattr(mi, x, None)) def print_all_attributes(self): - for x in ('author', 'author_sort', 'title_sort', 'comments', 'category', 'publisher', + for x in ('title','author', 'author_sort', 'title_sort', 'comments', 'category', 'publisher', 'series', 'series_index', 'tags', 'rating', 'isbn', 'language', 'application_id', 'manifest', 'toc', 'spine', 'guide', 'cover', 'book_producer', 'timestamp', 'lccn', 'lcc', 'ddc', 'pubdate', diff --git a/src/calibre/ebooks/metadata/douban.py b/src/calibre/ebooks/metadata/douban.py new file mode 100644 index 0000000000..c881721fcc --- /dev/null +++ b/src/calibre/ebooks/metadata/douban.py @@ -0,0 +1,258 @@ +from __future__ import with_statement +__license__ = 'GPL 3' +__copyright__ = '2009, Kovid Goyal ; 2010, Li Fanxi ' +__docformat__ = 'restructuredtext en' + +import sys, textwrap +import traceback +from urllib import urlencode +from functools import partial +from lxml import etree + +from calibre import browser, preferred_encoding +from calibre.ebooks.metadata import MetaInformation +from calibre.utils.config import OptionParser +from calibre.ebooks.metadata.fetch import MetadataSource +from calibre.utils.date import parse_date, utcnow + +DOUBAN_API_KEY = None +NAMESPACES = { + 'openSearch':'http://a9.com/-/spec/opensearchrss/1.0/', + 'atom' : 'http://www.w3.org/2005/Atom', + 'db': 'http://www.douban.com/xmlns/' + } +XPath = partial(etree.XPath, namespaces=NAMESPACES) +total_results = XPath('//openSearch:totalResults') +start_index = XPath('//openSearch:startIndex') +items_per_page = XPath('//openSearch:itemsPerPage') +entry = XPath('//atom:entry') +entry_id = XPath('descendant::atom:id') +title = XPath('descendant::atom:title') +description = XPath('descendant::atom:summary') +publisher = XPath("descendant::db:attribute[@name='publisher']") +isbn = XPath("descendant::db:attribute[@name='isbn13']") +date = XPath("descendant::db:attribute[@name='pubdate']") +creator = XPath("descendant::db:attribute[@name='author']") +tag = XPath("descendant::db:tag") + +class DoubanBooks(MetadataSource): + + name = 'Douban Books' + description = _('Downloads metadata from Douban.com') + supported_platforms = ['windows', 'osx', 'linux'] # Platforms this plugin will run on + author = 'Li Fanxi ' # The author of this plugin + version = (1, 0, 0) # The version number of this plugin + + def fetch(self): + try: + self.results = search(self.title, self.book_author, self.publisher, + self.isbn, max_results=10, + verbose=self.verbose) + except Exception, e: + self.exception = e + self.tb = traceback.format_exc() + +def report(verbose): + if verbose: + import traceback + traceback.print_exc() + +class Query(object): + + SEARCH_URL = 'http://api.douban.com/book/subjects?' + ISBN_URL = 'http://api.douban.com/book/subject/isbn/' + + type = "search" + + def __init__(self, title=None, author=None, publisher=None, isbn=None, + max_results=20, start_index=1): + assert not(title is None and author is None and publisher is None and \ + isbn is None) + assert (int(max_results) < 21) + q = '' + if isbn is not None: + q = isbn + self.type = 'isbn' + else: + def build_term(parts): + return ' '.join(x for x in parts) + if title is not None: + q += build_term(title.split()) + if author is not None: + q += (' ' if q else '') + build_term(author.split()) + if publisher is not None: + q += (' ' if q else '') + build_term(publisher.split()) + self.type = 'search' + + if isinstance(q, unicode): + q = q.encode('utf-8') + + if self.type == "isbn": + self.url = self.ISBN_URL + q + if DOUBAN_API_KEY is not None: + self.url = self.url + "?apikey=" + DOUBAN_API_KEY + else: + self.url = self.SEARCH_URL+urlencode({ + 'q':q, + 'max-results':max_results, + 'start-index':start_index, + }) + if DOUBAN_API_KEY is not None: + self.url = self.url + "&apikey=" + DOUBAN_API_KEY + + def __call__(self, browser, verbose): + if verbose: + print 'Query:', self.url + if self.type == "search": + feed = etree.fromstring(browser.open(self.url).read()) + total = int(total_results(feed)[0].text) + start = int(start_index(feed)[0].text) + entries = entry(feed) + new_start = start + len(entries) + if new_start > total: + new_start = 0 + return entries, new_start + elif self.type == "isbn": + feed = etree.fromstring(browser.open(self.url).read()) + entries = entry(feed) + return entries, 0 + +class ResultList(list): + + def get_description(self, entry, verbose): + try: + desc = description(entry) + if desc: + return 'SUMMARY:\n'+desc[0].text + except: + report(verbose) + + def get_title(self, entry): + candidates = [x.text for x in title(entry)] + return ': '.join(candidates) + + def get_authors(self, entry): + m = creator(entry) + if not m: + m = [] + m = [x.text for x in m] + return m + + def get_tags(self, entry, verbose): + try: + btags = [x.attrib["name"] for x in tag(entry)] + tags = [] + for t in btags: + tags.extend([y.strip() for y in t.split('/')]) + tags = list(sorted(list(set(tags)))) + except: + report(verbose) + tags = [] + return [x.replace(',', ';') for x in tags] + + def get_publisher(self, entry, verbose): + try: + pub = publisher(entry)[0].text + except: + pub = None + return pub + + def get_isbn(self, entry, verbose): + try: + isbn13 = isbn(entry)[0].text + except Exception: + isbn13 = None + return isbn13 + + def get_date(self, entry, verbose): + try: + d = date(entry) + if d: + default = utcnow().replace(day=15) + d = parse_date(d[0].text, assume_utc=True, default=default) + else: + d = None + except: + report(verbose) + d = None + return d + + def populate(self, entries, browser, verbose=False): + for x in entries: + try: + id_url = entry_id(x)[0].text + title = self.get_title(x) + except: + report(verbose) + mi = MetaInformation(title, self.get_authors(x)) + try: + if DOUBAN_API_KEY is not None: + id_url = id_url + "?apikey=" + DOUBAN_API_KEY + raw = browser.open(id_url).read() + feed = etree.fromstring(raw) + x = entry(feed)[0] + except Exception, e: + if verbose: + print 'Failed to get all details for an entry' + print e + mi.comments = self.get_description(x, verbose) + mi.tags = self.get_tags(x, verbose) + mi.isbn = self.get_isbn(x, verbose) + mi.publisher = self.get_publisher(x, verbose) + mi.pubdate = self.get_date(x, verbose) + self.append(mi) + +def search(title=None, author=None, publisher=None, isbn=None, + verbose=False, max_results=40): + br = browser() + start, entries = 1, [] + while start > 0 and len(entries) <= max_results: + new, start = Query(title=title, author=author, publisher=publisher, + isbn=isbn, max_results=max_results, start_index=start)(br, verbose) + if not new: + break + entries.extend(new) + + entries = entries[:max_results] + + ans = ResultList() + ans.populate(entries, br, verbose) + return ans + +def option_parser(): + parser = OptionParser(textwrap.dedent( + '''\ + %prog [options] + + Fetch book metadata from Douban. You must specify one of title, author, + publisher or ISBN. If you specify ISBN the others are ignored. Will + fetch a maximum of 100 matches, so you should make your query as + specific as possible. + ''' + )) + parser.add_option('-t', '--title', help='Book title') + parser.add_option('-a', '--author', help='Book author(s)') + parser.add_option('-p', '--publisher', help='Book publisher') + parser.add_option('-i', '--isbn', help='Book ISBN') + parser.add_option('-m', '--max-results', default=10, + help='Maximum number of results to fetch') + parser.add_option('-v', '--verbose', default=0, action='count', + help='Be more verbose about errors') + return parser + +def main(args=sys.argv): + parser = option_parser() + opts, args = parser.parse_args(args) + try: + results = search(opts.title, opts.author, opts.publisher, opts.isbn, + verbose=opts.verbose, max_results=int(opts.max_results)) + except AssertionError: + report(True) + parser.print_help() + return 1 + for result in results: + print unicode(result).encode(preferred_encoding) + print + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/calibre/ebooks/metadata/epub.py b/src/calibre/ebooks/metadata/epub.py index d74ed37f66..b3980451bf 100644 --- a/src/calibre/ebooks/metadata/epub.py +++ b/src/calibre/ebooks/metadata/epub.py @@ -182,7 +182,7 @@ def get_metadata(stream, extract_cover=True): def get_quick_metadata(stream): return get_metadata(stream, False) -def set_metadata(stream, mi, apply_null=False): +def set_metadata(stream, mi, apply_null=False, update_timestamp=False): stream.seek(0) reader = OCFZipReader(stream, root=os.getcwdu()) mi = MetaInformation(mi) @@ -196,6 +196,8 @@ def set_metadata(stream, mi, apply_null=False): reader.opf.tags = [] if not getattr(mi, 'isbn', None): reader.opf.isbn = None + if update_timestamp and mi.timestamp is not None: + reader.opf.timestamp = mi.timestamp newopf = StringIO(reader.opf.render()) safe_replace(stream, reader.container[OPF.MIMETYPE], newopf) diff --git a/src/calibre/ebooks/metadata/fetch.py b/src/calibre/ebooks/metadata/fetch.py index a7fd76c661..d12c668e0d 100644 --- a/src/calibre/ebooks/metadata/fetch.py +++ b/src/calibre/ebooks/metadata/fetch.py @@ -198,6 +198,38 @@ class Amazon(MetadataSource): self.exception = e self.tb = traceback.format_exc() +class LibraryThing(MetadataSource): + + name = 'LibraryThing' + metadata_type = 'social' + description = _('Downloads series information from librarything.com') + + def fetch(self): + if not self.isbn: + return + from calibre import browser + from calibre.ebooks.metadata import MetaInformation + import json + br = browser() + try: + raw = br.open( + 'http://status.calibre-ebook.com/library_thing/metadata/'+self.isbn + ).read() + data = json.loads(raw) + if not data: + return + if 'error' in data: + raise Exception(data['error']) + if 'series' in data and 'series_index' in data: + mi = MetaInformation(self.title, []) + mi.series = data['series'] + mi.series_index = data['series_index'] + self.results = mi + except Exception, e: + self.exception = e + self.tb = traceback.format_exc() + + def result_index(source, result): if not result.isbn: return -1 @@ -266,7 +298,7 @@ def get_social_metadata(mi, verbose=0): with MetadataSources(fetchers) as manager: manager(mi.title, mi.authors, mi.publisher, mi.isbn, verbose) manager.join() - ratings, tags, comments = [], set([]), set([]) + ratings, tags, comments, series, series_index = [], set([]), set([]), None, None for fetcher in fetchers: if fetcher.results: dmi = fetcher.results @@ -279,6 +311,10 @@ def get_social_metadata(mi, verbose=0): mi.pubdate = dmi.pubdate if dmi.comments: comments.add(dmi.comments) + if dmi.series is not None: + series = dmi.series + if dmi.series_index is not None: + series_index = dmi.series_index if ratings: rating = sum(ratings)/float(len(ratings)) if mi.rating is None or mi.rating < 0.1: @@ -295,6 +331,9 @@ def get_social_metadata(mi, verbose=0): mi.comments = '' for x in comments: mi.comments += x+'\n\n' + if series and series_index is not None: + mi.series = series + mi.series_index = series_index return [(x.name, x.exception, x.tb) for x in fetchers if x.exception is not None] diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 3367ab14f6..46924cad1f 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -736,7 +736,9 @@ class OPF(object): def fget(self): ans = [] for tag in self.tags_path(self.metadata): - ans.append(self.get_text(tag)) + text = self.get_text(tag) + if text and text.strip(): + ans.extend([x.strip() for x in text.split(',')]) return ans def fset(self, val): diff --git a/src/calibre/ebooks/mobi/mobiml.py b/src/calibre/ebooks/mobi/mobiml.py index 9361d52d31..bfa8758c85 100644 --- a/src/calibre/ebooks/mobi/mobiml.py +++ b/src/calibre/ebooks/mobi/mobiml.py @@ -61,6 +61,7 @@ class FormatState(object): self.italic = False self.bold = False self.strikethrough = False + self.underline = False self.preserve = False self.family = 'serif' self.bgcolor = 'transparent' @@ -79,7 +80,8 @@ class FormatState(object): and self.family == other.family \ and self.bgcolor == other.bgcolor \ and self.fgcolor == other.fgcolor \ - and self.strikethrough == other.strikethrough + and self.strikethrough == other.strikethrough \ + and self.underline == other.underline def __ne__(self, other): return not self.__eq__(other) @@ -251,6 +253,8 @@ class MobiMLizer(object): color=unicode(istate.fgcolor)) if istate.strikethrough: inline = etree.SubElement(inline, XHTML('s')) + if istate.underline: + inline = etree.SubElement(inline, XHTML('u')) bstate.inline = inline bstate.istate = istate inline = bstate.inline @@ -330,6 +334,7 @@ class MobiMLizer(object): istate.bgcolor = style['background-color'] istate.fgcolor = style['color'] istate.strikethrough = style['text-decoration'] == 'line-through' + istate.underline = style['text-decoration'] == 'underline' if 'monospace' in style['font-family']: istate.family = 'monospace' elif 'sans-serif' in style['font-family']: diff --git a/src/calibre/gui2/actions.py b/src/calibre/gui2/actions.py index 6da04a41be..32552dde71 100644 --- a/src/calibre/gui2/actions.py +++ b/src/calibre/gui2/actions.py @@ -28,6 +28,7 @@ from calibre.constants import preferred_encoding, filesystem_encoding, \ from calibre.gui2.dialogs.choose_format import ChooseFormatDialog from calibre.ebooks import BOOK_EXTENSIONS from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.gui2.dialogs.delete_matching_from_device import DeleteMatchingFromDeviceDialog class AnnotationsAction(object): # {{{ @@ -471,6 +472,45 @@ class DeleteAction(object): # {{{ if ids: self.tags_view.recount() + def remove_matching_books_from_device(self, *args): + if not self.device_manager.is_device_connected: + d = error_dialog(self, _('Cannot delete books'), + _('No device is connected')) + d.exec_() + return + ids = self._get_selected_ids() + if not ids: + #_get_selected_ids shows a dialog box if nothing is selected, so we + #do not need to show one here + return + to_delete = {} + some_to_delete = False + for model,name in ((self.memory_view.model(), _('Main memory')), + (self.card_a_view.model(), _('Storage Card A')), + (self.card_b_view.model(), _('Storage Card B'))): + to_delete[name] = (model, model.paths_for_db_ids(ids)) + if len(to_delete[name][1]) > 0: + some_to_delete = True + if not some_to_delete: + d = error_dialog(self, _('No books to delete'), + _('None of the selected books are on the device')) + d.exec_() + return + d = DeleteMatchingFromDeviceDialog(self, to_delete) + if d.exec_(): + paths = {} + ids = {} + for (model, id, path) in d.result: + if model not in paths: + paths[model] = [] + ids[model] = [] + paths[model].append(path) + ids[model].append(id) + for model in paths: + job = self.remove_paths(paths[model]) + self.delete_memory[job] = (paths[model], model) + model.mark_for_deletion(job, ids[model], rows_are_ids=True) + self.status_bar.show_message(_('Deleting books from device.'), 1000) def delete_covers(self, *args): ids = self._get_selected_ids() diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index a397ab903d..8759dd360b 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -62,11 +62,13 @@ def render_rows(data): class CoverView(QWidget): # {{{ - def __init__(self, parent=None): + def __init__(self, vertical, parent=None): QWidget.__init__(self, parent) self.setMaximumSize(QSize(120, 120)) - self.setMinimumSize(QSize(120, 1)) + self.setMinimumSize(QSize(120 if vertical else 20, 120 if vertical else + 20)) self._current_pixmap_size = self.maximumSize() + self.vertical = vertical self.animation = QPropertyAnimation(self, 'current_pixmap_size', self) self.animation.setEasingCurve(QEasingCurve(QEasingCurve.OutExpo)) @@ -74,7 +76,8 @@ class CoverView(QWidget): # {{{ self.animation.setStartValue(QSize(0, 0)) self.animation.valueChanged.connect(self.value_changed) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setSizePolicy(QSizePolicy.Expanding if vertical else + QSizePolicy.Minimum, QSizePolicy.Expanding) self.default_pixmap = QPixmap(I('book.svg')) self.pixmap = self.default_pixmap @@ -98,8 +101,12 @@ class CoverView(QWidget): # {{{ self.animation.setEndValue(self.current_pixmap_size) def relayout(self, parent_size): - self.setMaximumSize(parent_size.width(), - min(int(parent_size.height()/2.),int(4/3. * parent_size.width())+1)) + if self.vertical: + self.setMaximumSize(parent_size.width(), + min(int(parent_size.height()/2.),int(4/3. * parent_size.width())+1)) + else: + self.setMaximumSize(1+int(3/4. * parent_size.height()), + parent_size.height()) self.resize(self.maximumSize()) self.animation.stop() self.do_layout() @@ -109,8 +116,7 @@ class CoverView(QWidget): # {{{ def show_data(self, data): self.animation.stop() - if data.get('id', True) == self.data.get('id', False): - return + same_item = data.get('id', True) == self.data.get('id', False) self.data = {'id':data.get('id', None)} if data.has_key('cover'): self.pixmap = QPixmap.fromImage(data.pop('cover')) @@ -120,7 +126,8 @@ class CoverView(QWidget): # {{{ self.pixmap = self.default_pixmap self.do_layout() self.update() - self.animation.start() + if not same_item: + self.animation.start() def paintEvent(self, event): canvas_size = self.rect() @@ -147,6 +154,7 @@ class CoverView(QWidget): # {{{ # }}} +# Book Info {{{ class Label(QLabel): mr = pyqtSignal(object) @@ -174,8 +182,9 @@ class Label(QLabel): class BookInfo(QScrollArea): - def __init__(self, parent=None): + def __init__(self, vertical, parent=None): QScrollArea.__init__(self, parent) + self.vertical = vertical self.setWidgetResizable(True) self.label = Label() self.setWidget(self.label) @@ -188,13 +197,25 @@ class BookInfo(QScrollArea): rows = render_rows(data) rows = u'\n'.join([u'%s:%s'%(k,t) for k, t in rows]) - if _('Comments') in data and data[_('Comments')]: - comments = comments_to_html(data[_('Comments')]) - rows += u'%s'%comments + if self.vertical: + if _('Comments') in data and data[_('Comments')]: + comments = comments_to_html(data[_('Comments')]) + rows += u'%s'%comments + self.label.setText(u'%s
'%rows) + else: + comments = '' + if _('Comments') in data: + comments = comments_to_html(data[_('Comments')]) + left_pane = u'%s
'%rows + right_pane = u'
%s
'%comments + self.label.setText(u'
%s%s
' + % (left_pane, right_pane)) - self.label.setText(u'%s
'%rows) -class BookDetails(QWidget): +# }}} + +class BookDetails(QWidget): # {{{ resized = pyqtSignal(object) show_book_info = pyqtSignal() @@ -234,20 +255,26 @@ class BookDetails(QWidget): # }}} - def __init__(self, parent=None): + def __init__(self, vertical, parent=None): QWidget.__init__(self, parent) + self.setAcceptDrops(True) self._layout = QVBoxLayout() - + if not vertical: + self._layout.setDirection(self._layout.LeftToRight) self.setLayout(self._layout) - self.cover_view = CoverView(self) + + self.cover_view = CoverView(vertical, self) self.cover_view.relayout(self.size()) self.resized.connect(self.cover_view.relayout, type=Qt.QueuedConnection) - self._layout.addWidget(self.cover_view, alignment=Qt.AlignHCenter) - self.book_info = BookInfo(self) + self._layout.addWidget(self.cover_view) + self.book_info = BookInfo(vertical, self) self._layout.addWidget(self.book_info) self.book_info.link_clicked.connect(self._link_clicked) self.book_info.mr.connect(self.mouseReleaseEvent) - self.setMinimumSize(QSize(190, 200)) + if vertical: + self.setMinimumSize(QSize(190, 200)) + else: + self.setMinimumSize(120, 120) self.setCursor(Qt.PointingHandCursor) def _link_clicked(self, link): @@ -277,5 +304,5 @@ class BookDetails(QWidget): def reset_info(self): self.show_data({}) - +# }}} diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index b3a7196b20..07b5063e6c 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -689,14 +689,28 @@ class DeviceMixin(object): # {{{ self.device_error_dialog.show() # Device connected {{{ - def device_detected(self, connected, is_folder_device): - ''' - Called when a device is connected to the computer. - ''' + + def set_device_menu_items_state(self, connected, is_folder_device): if connected: self._sync_menu.connect_to_folder_action.setEnabled(False) if is_folder_device: self._sync_menu.disconnect_from_folder_action.setEnabled(True) + self._sync_menu.enable_device_actions(True, + self.device_manager.device.card_prefix(), + self.device_manager.device) + self.eject_action.setEnabled(True) + else: + self._sync_menu.connect_to_folder_action.setEnabled(True) + self._sync_menu.disconnect_from_folder_action.setEnabled(False) + self._sync_menu.enable_device_actions(False) + self.eject_action.setEnabled(False) + + def device_detected(self, connected, is_folder_device): + ''' + Called when a device is connected to the computer. + ''' + self.set_device_menu_items_state(connected, is_folder_device) + if connected: self.device_manager.get_device_information(\ Dispatcher(self.info_read)) self.set_default_thumbnail(\ @@ -705,17 +719,10 @@ class DeviceMixin(object): # {{{ self.device_manager.device.__class__.get_gui_name()+\ _(' detected.'), 3000) self.device_connected = 'device' if not is_folder_device else 'folder' - self._sync_menu.enable_device_actions(True, - self.device_manager.device.card_prefix(), - self.device_manager.device) self.location_view.model().device_connected(self.device_manager.device) - self.eject_action.setEnabled(True) self.refresh_ondevice_info (device_connected = True, reset_only = True) else: - self._sync_menu.connect_to_folder_action.setEnabled(True) - self._sync_menu.disconnect_from_folder_action.setEnabled(False) self.device_connected = None - self._sync_menu.enable_device_actions(False) self.location_view.model().update_devices() self.vanity.setText(self.vanity_template%\ dict(version=self.latest_version, device=' ')) @@ -723,7 +730,6 @@ class DeviceMixin(object): # {{{ if self.current_view() != self.library_view: self.book_details.reset_info() self.location_view.setCurrentIndex(self.location_view.model().index(0)) - self.eject_action.setEnabled(False) self.refresh_ondevice_info (device_connected = False) def info_read(self, job): @@ -1347,7 +1353,7 @@ class DeviceMixin(object): # {{{ if reset: # First build a cache of the library, so the search isn't On**2 self.db_book_title_cache = {} - self.db_book_uuid_cache = set() + self.db_book_uuid_cache = {} db = self.library_view.model().db for id in db.data.iterallids(): mi = db.get_metadata(id, index_is_id=True) @@ -1364,7 +1370,7 @@ class DeviceMixin(object): # {{{ aus = re.sub('(?u)\W|[_]', '', aus) self.db_book_title_cache[title]['author_sort'][aus] = mi self.db_book_title_cache[title]['db_ids'][mi.application_id] = mi - self.db_book_uuid_cache.add(mi.uuid) + self.db_book_uuid_cache[mi.uuid] = mi.application_id # Now iterate through all the books on the device, setting the # in_library field Fastest and most accurate key is the uuid. Second is @@ -1376,11 +1382,13 @@ class DeviceMixin(object): # {{{ for book in booklist: if getattr(book, 'uuid', None) in self.db_book_uuid_cache: book.in_library = True + # ensure that the correct application_id is set + book.application_id = self.db_book_uuid_cache[book.uuid] continue book_title = book.title.lower() if book.title else '' book_title = re.sub('(?u)\W|[_]', '', book_title) - book.in_library = False + book.in_library = None d = self.db_book_title_cache.get(book_title, None) if d is not None: if getattr(book, 'application_id', None) in d['db_ids']: diff --git a/src/calibre/gui2/device_drivers/configwidget.py b/src/calibre/gui2/device_drivers/configwidget.py index 585eed30df..3d9c9ab2ee 100644 --- a/src/calibre/gui2/device_drivers/configwidget.py +++ b/src/calibre/gui2/device_drivers/configwidget.py @@ -11,7 +11,8 @@ from calibre.gui2.device_drivers.configwidget_ui import Ui_ConfigWidget class ConfigWidget(QWidget, Ui_ConfigWidget): def __init__(self, settings, all_formats, supports_subdirs, - must_read_metadata, extra_customization_message): + must_read_metadata, supports_use_author_sort, + extra_customization_message): QWidget.__init__(self) Ui_ConfigWidget.__init__(self) @@ -38,6 +39,10 @@ class ConfigWidget(QWidget, Ui_ConfigWidget): self.opt_read_metadata.setChecked(self.settings.read_metadata) else: self.opt_read_metadata.hide() + if supports_use_author_sort: + self.opt_use_author_sort.setChecked(self.settings.use_author_sort) + else: + self.opt_use_author_sort.hide() if extra_customization_message: self.extra_customization_label.setText(extra_customization_message) if settings.extra_customization: @@ -69,3 +74,6 @@ class ConfigWidget(QWidget, Ui_ConfigWidget): def read_metadata(self): return self.opt_read_metadata.isChecked() + + def use_author_sort(self): + return self.opt_use_author_sort.isChecked() diff --git a/src/calibre/gui2/device_drivers/configwidget.ui b/src/calibre/gui2/device_drivers/configwidget.ui index d007599424..497ba43259 100644 --- a/src/calibre/gui2/device_drivers/configwidget.ui +++ b/src/calibre/gui2/device_drivers/configwidget.ui @@ -90,7 +90,14 @@ - + + + + Use author sort for author + + + + Extra customization @@ -103,10 +110,10 @@ - + - + Save &template: @@ -116,7 +123,7 @@ - + diff --git a/src/calibre/gui2/dialogs/config/config.ui b/src/calibre/gui2/dialogs/config/config.ui index ba92c0d301..efda00fc97 100644 --- a/src/calibre/gui2/dialogs/config/config.ui +++ b/src/calibre/gui2/dialogs/config/config.ui @@ -7,7 +7,7 @@ 0 0 - 884 + 1000 730 @@ -89,7 +89,7 @@ 0 0 - 604 + 720 679 @@ -370,7 +370,7 @@ - + Show &average ratings in the tags browser diff --git a/src/calibre/gui2/dialogs/config/social.py b/src/calibre/gui2/dialogs/config/social.py index 6a767e7b3b..ad14ea05b0 100644 --- a/src/calibre/gui2/dialogs/config/social.py +++ b/src/calibre/gui2/dialogs/config/social.py @@ -49,6 +49,9 @@ class SocialMetadata(QDialog): self.mi.tags = self.worker.mi.tags self.mi.rating = self.worker.mi.rating self.mi.comments = self.worker.mi.comments + if self.worker.mi.series: + self.mi.series = self.worker.mi.series + self.mi.series_index = self.worker.mi.series_index QDialog.accept(self) @property diff --git a/src/calibre/gui2/dialogs/delete_matching_from_device.py b/src/calibre/gui2/dialogs/delete_matching_from_device.py new file mode 100644 index 0000000000..dbac2fe4ad --- /dev/null +++ b/src/calibre/gui2/dialogs/delete_matching_from_device.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' +__license__ = 'GPL v3' + +from PyQt4.Qt import Qt, QDialog, QTableWidgetItem, QAbstractItemView + +from calibre import strftime +from calibre.ebooks.metadata import authors_to_string, authors_to_sort_string, \ + title_sort +from calibre.gui2.dialogs.delete_matching_from_device_ui import \ + Ui_DeleteMatchingFromDeviceDialog +from calibre.utils.date import UNDEFINED_DATE + +class tableItem(QTableWidgetItem): + + def __init__(self, text): + QTableWidgetItem.__init__(self, text) + self.setFlags(Qt.ItemIsEnabled) + self.sort = text.lower() + + def __ge__(self, other): + return self.sort >= other.sort + + def __lt__(self, other): + return self.sort < other.sort + +class titleTableItem(tableItem): + + def __init__(self, text): + tableItem.__init__(self, text) + self.sort = title_sort(text.lower()) + +class authorTableItem(tableItem): + + def __init__(self, book): + tableItem.__init__(self, authors_to_string(book.authors)) + if book.author_sort is not None: + self.sort = book.author_sort.lower() + else: + self.sort = authors_to_sort_string(book.authors).lower() + +class dateTableItem(tableItem): + + def __init__(self, date): + if date is not None: + tableItem.__init__(self, strftime('%x', date)) + self.sort = date + else: + tableItem.__init__(self, '') + self.sort = UNDEFINED_DATE + + +class DeleteMatchingFromDeviceDialog(QDialog, Ui_DeleteMatchingFromDeviceDialog): + + def __init__(self, parent, items): + QDialog.__init__(self, parent) + Ui_DeleteMatchingFromDeviceDialog.__init__(self) + self.setupUi(self) + + self.explanation.setText('

'+_('All checked books will be ' + 'permanently deleted from your ' + 'device. Please verify the list.'+'

')) + self.buttonBox.accepted.connect(self.accepted) + self.table.cellClicked.connect(self.cell_clicked) + self.table.setSelectionMode(QAbstractItemView.NoSelection) + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels( + ['', _('Location'), _('Title'), + _('Author'), _('Date'), _('Format')]) + rows = 0 + for card in items: + rows += len(items[card][1]) + self.table.setRowCount(rows) + row = 0 + for card in items: + (model,books) = items[card] + for (id,book) in books: + item = QTableWidgetItem() + item.setFlags(Qt.ItemIsUserCheckable|Qt.ItemIsEnabled) + item.setCheckState(Qt.Checked) + item.setData(Qt.UserRole, (model, id, book.path)) + self.table.setItem(row, 0, item) + self.table.setItem(row, 1, tableItem(card)) + self.table.setItem(row, 2, titleTableItem(book.title)) + self.table.setItem(row, 3, authorTableItem(book)) + self.table.setItem(row, 4, dateTableItem(book.datetime)) + self.table.setItem(row, 5, tableItem(book.path.rpartition('.')[2])) + row += 1 + self.table.setCurrentCell(0, 1) + self.table.resizeColumnsToContents() + self.table.setSortingEnabled(True) + self.table.sortByColumn(2, Qt.AscendingOrder) + self.table.setCurrentCell(0, 1) + + def cell_clicked(self, row, col): + if col == 0: + self.table.setCurrentCell(row, 1) + + def accepted(self): + self.result = [] + for row in range(self.table.rowCount()): + if self.table.item(row, 0).checkState() == Qt.Unchecked: + continue + (model, id, path) = self.table.item(row, 0).data(Qt.UserRole).toPyObject() + path = unicode(path) + self.result.append((model, id, path)) + return + diff --git a/src/calibre/gui2/dialogs/delete_matching_from_device.ui b/src/calibre/gui2/dialogs/delete_matching_from_device.ui new file mode 100644 index 0000000000..fec08e5b00 --- /dev/null +++ b/src/calibre/gui2/dialogs/delete_matching_from_device.ui @@ -0,0 +1,90 @@ + + + DeleteMatchingFromDeviceDialog + + + + 0 + 0 + 730 + 342 + + + + + 0 + 0 + + + + Delete from device + + + + + + + + + + + 0 + 0 + + + + 0 + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + buttonBox + accepted() + DeleteMatchingFromDeviceDialog + accept() + + + 229 + 211 + + + 157 + 234 + + + + + buttonBox + rejected() + DeleteMatchingFromDeviceDialog + reject() + + + 297 + 217 + + + 286 + 234 + + + + + diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index 1277cb06c7..699978a92f 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -8,17 +8,18 @@ __docformat__ = 'restructuredtext en' import functools from PyQt4.Qt import QMenu, Qt, pyqtSignal, QToolButton, QIcon, QStackedWidget, \ - QWidget, QHBoxLayout, QToolBar, QSize, QSizePolicy + QSize, QSizePolicy, QStatusBar from calibre.utils.config import prefs from calibre.ebooks import BOOK_EXTENSIONS -from calibre.constants import isosx, __appname__ +from calibre.constants import isosx, __appname__, preferred_encoding from calibre.gui2 import config, is_widescreen from calibre.gui2.library.views import BooksView, DeviceBooksView from calibre.gui2.widgets import Splitter from calibre.gui2.tag_view import TagBrowserWidget -from calibre.gui2.status import StatusBar, HStatusBar from calibre.gui2.book_details import BookDetails +from calibre.gui2.notify import get_notifier + _keep_refs = [] @@ -130,6 +131,10 @@ class ToolbarMixin(object): # {{{ self.delete_all_but_selected_formats) self.delete_menu.addAction( _('Remove covers from selected books'), self.delete_covers) + self.delete_menu.addSeparator() + self.delete_menu.addAction( + _('Remove matching books from device'), + self.remove_matching_books_from_device) self.action_del.setMenu(self.delete_menu) self.action_open_containing_folder.setShortcut(Qt.Key_O) @@ -158,8 +163,7 @@ class ToolbarMixin(object): # {{{ self.convert_menu = cm pm = QMenu() - ap = self.action_preferences - pm.addAction(ap) + pm.addAction(QIcon(I('config.svg')), _('Preferences'), self.do_config) pm.addAction(QIcon(I('wizard.svg')), _('Run welcome wizard'), self.run_wizard) self.action_preferences.setMenu(pm) @@ -332,26 +336,24 @@ class Stack(QStackedWidget): # {{{ # }}} -class SideBar(QToolBar): # {{{ +class StatusBar(QStatusBar): # {{{ + def initialize(self, systray=None): + self.systray = systray + self.notifier = get_notifier(systray) - def __init__(self, splitters, jobs_button, parent=None): - QToolBar.__init__(self, _('Side bar'), parent) - self.setOrientation(Qt.Vertical) - self.setMovable(False) - self.setFloatable(False) - self.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.setIconSize(QSize(48, 48)) - self.spacer = QWidget(self) - self.spacer.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) - for s in splitters: - self.addWidget(s.button) - self.addWidget(self.spacer) - self.addWidget(jobs_button) + def show_message(self, msg, timeout=0): + QStatusBar.showMessage(self, msg, timeout) + if self.notifier is not None and not config['disable_tray_notification']: + if isosx and isinstance(msg, unicode): + try: + msg = msg.encode(preferred_encoding) + except UnicodeEncodeError: + msg = msg.encode('utf-8') + self.notifier(msg) - for ch in self.children(): - if isinstance(ch, QToolButton): - ch.setCursor(Qt.PointingHandCursor) + def clear_message(self): + QStatusBar.clearMessage(self) # }}} @@ -361,50 +363,52 @@ class LayoutMixin(object): # {{{ self.setupUi(self) self.setWindowTitle(__appname__) - if config['gui_layout'] == 'narrow': - self.status_bar = self.book_details = StatusBar(self) + if config['gui_layout'] == 'narrow': # narrow {{{ + self.book_details = BookDetails(False, self) self.stack = Stack(self) self.bd_splitter = Splitter('book_details_splitter', _('Book Details'), I('book.svg'), orientation=Qt.Vertical, parent=self, side_index=1) - self._layout_mem = [QWidget(self), QHBoxLayout()] - self._layout_mem[0].setLayout(self._layout_mem[1]) - l = self._layout_mem[1] - l.addWidget(self.stack) - self.sidebar = SideBar([getattr(self, x+'_splitter') - for x in ('bd', 'tb', 'cb')], self.jobs_button, parent=self) - l.addWidget(self.sidebar) - self.bd_splitter.addWidget(self._layout_mem[0]) - self.bd_splitter.addWidget(self.status_bar) + self.bd_splitter.addWidget(self.stack) + self.bd_splitter.addWidget(self.book_details) self.bd_splitter.setCollapsible(self.bd_splitter.other_index, False) self.centralwidget.layout().addWidget(self.bd_splitter) - else: - self.status_bar = HStatusBar(self) - self.setStatusBar(self.status_bar) + # }}} + else: # wide {{{ self.bd_splitter = Splitter('book_details_splitter', _('Book Details'), I('book.svg'), initial_side_size=200, orientation=Qt.Horizontal, parent=self, side_index=1) self.stack = Stack(self) self.bd_splitter.addWidget(self.stack) - self.book_details = BookDetails(self) + self.book_details = BookDetails(True, self) self.bd_splitter.addWidget(self.book_details) self.bd_splitter.setCollapsible(self.bd_splitter.other_index, False) self.bd_splitter.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) self.centralwidget.layout().addWidget(self.bd_splitter) + # }}} - for x in ('cb', 'tb', 'bd'): - button = getattr(self, x+'_splitter').button - button.setIconSize(QSize(22, 22)) - self.status_bar.addPermanentWidget(button) - self.status_bar.addPermanentWidget(self.jobs_button) + self.status_bar = StatusBar(self) + for x in ('cb', 'tb', 'bd'): + button = getattr(self, x+'_splitter').button + button.setIconSize(QSize(24, 24)) + self.status_bar.addPermanentWidget(button) + self.status_bar.addPermanentWidget(self.jobs_button) + self.setStatusBar(self.status_bar) def finalize_layout(self): + self.status_bar.initialize(self.system_tray_icon) + self.book_details.show_book_info.connect(self.show_book_info) + self.book_details.files_dropped.connect(self.files_dropped_on_book) + self.book_details.open_containing_folder.connect(self.view_folder_for_id) + self.book_details.view_specific_format.connect(self.view_format_by_id) + m = self.library_view.model() if m.rowCount(None) > 0: self.library_view.set_current_row(0) m.current_changed(self.library_view.currentIndex(), self.library_view.currentIndex()) + self.library_view.setFocus(Qt.OtherFocusReason) def save_layout_state(self): diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 8080769377..ca1afbe6b4 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -769,6 +769,7 @@ class OnDeviceSearch(SearchQueryParser): # {{{ 'format', 'formats', 'title', + 'inlibrary' ] @@ -807,12 +808,23 @@ class OnDeviceSearch(SearchQueryParser): # {{{ 'author': lambda x: ' & '.join(getattr(x, 'authors')).lower(), 'collections':lambda x: ','.join(getattr(x, 'device_collections')).lower(), 'format':lambda x: os.path.splitext(x.path)[1].lower(), + 'inlibrary':lambda x : getattr(x, 'in_library') } for x in ('author', 'format'): q[x+'s'] = q[x] for index, row in enumerate(self.model.db): for locvalue in locations: accessor = q[locvalue] + if query == 'true': + if accessor(row) is not None: + matches.add(index) + continue + if query == 'false': + if accessor(row) is None: + matches.add(index) + continue + if locvalue == 'inlibrary': + continue # this is bool, so can't match below try: ### Can't separate authors because comma is used for name sep and author sep ### Exact match might not get what you want. For that reason, turn author @@ -862,11 +874,15 @@ class DeviceBooksModel(BooksModel): # {{{ self.editable = True self.book_in_library = None - def mark_for_deletion(self, job, rows): - self.marked_for_deletion[job] = self.indices(rows) - for row in rows: - indices = self.row_indices(row) - self.dataChanged.emit(indices[0], indices[-1]) + def mark_for_deletion(self, job, rows, rows_are_ids=False): + if rows_are_ids: + self.marked_for_deletion[job] = rows + self.reset() + else: + self.marked_for_deletion[job] = self.indices(rows) + for row in rows: + indices = self.row_indices(row) + self.dataChanged.emit(indices[0], indices[-1]) def deletion_done(self, job, succeeded=True): if not self.marked_for_deletion.has_key(job): @@ -888,13 +904,13 @@ class DeviceBooksModel(BooksModel): # {{{ ans.extend(v) return ans - def clear_ondevice(self, db_ids): + def clear_ondevice(self, db_ids, to_what=None): for data in self.db: if data is None: continue app_id = getattr(data, 'application_id', None) if app_id is not None and app_id in db_ids: - data.in_library = False + data.in_library = to_what self.reset() def flags(self, index): @@ -1049,6 +1065,13 @@ class DeviceBooksModel(BooksModel): # {{{ def paths(self, rows): return [self.db[self.map[r.row()]].path for r in rows ] + def paths_for_db_ids(self, db_ids): + res = [] + for r,b in enumerate(self.db): + if b.application_id in db_ids: + res.append((r,b)) + return res + def indices(self, rows): ''' Return indices into underlying database from rows @@ -1089,6 +1112,8 @@ class DeviceBooksModel(BooksModel): # {{{ elif role == Qt.DecorationRole and cname == 'inlibrary': if self.db[self.map[row]].in_library: return QVariant(self.bool_yes_icon) + elif self.db[self.map[row]].in_library is not None: + return QVariant(self.bool_no_icon) elif role == Qt.TextAlignmentRole: cname = self.column_map[index.column()] ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname, diff --git a/src/calibre/gui2/metadata.py b/src/calibre/gui2/metadata.py index d63e9648cc..cd4cc1be41 100644 --- a/src/calibre/gui2/metadata.py +++ b/src/calibre/gui2/metadata.py @@ -84,12 +84,12 @@ class DownloadMetadata(Thread): if mi.isbn: args['isbn'] = mi.isbn else: - if not mi.title: + if not mi.title or mi.title == _('Unknown'): self.failures[id] = \ (str(id), _('Book has neither title nor ISBN')) continue args['title'] = mi.title - if mi.authors: + if mi.authors and mi.authors[0] != _('Unknown'): args['author'] = mi.authors[0] if self.key: args['isbndb_key'] = self.key @@ -127,6 +127,10 @@ class DownloadMetadata(Thread): self.db.set_tags(id, mi.tags) if mi.comments: self.db.set_comment(id, mi.comments) + if mi.series: + self.db.set_series(id, mi.series) + if mi.series_index is not None: + self.db.set_series_index(id, mi.series_index) self.updated = set(self.fetched_metadata) diff --git a/src/calibre/gui2/search_box.py b/src/calibre/gui2/search_box.py index f34c52d221..35bf7374a0 100644 --- a/src/calibre/gui2/search_box.py +++ b/src/calibre/gui2/search_box.py @@ -18,17 +18,20 @@ from calibre.utils.config import prefs from calibre.utils.search_query_parser import saved_searches class SearchLineEdit(QLineEdit): + key_pressed = pyqtSignal(object) + mouse_released = pyqtSignal(object) + focus_out = pyqtSignal(object) def keyPressEvent(self, event): - self.emit(SIGNAL('key_pressed(PyQt_PyObject)'), event) + self.key_pressed.emit(event) QLineEdit.keyPressEvent(self, event) def mouseReleaseEvent(self, event): - self.emit(SIGNAL('mouse_released(PyQt_PyObject)'), event) + self.mouse_released.emit(event) QLineEdit.mouseReleaseEvent(self, event) def focusOutEvent(self, event): - self.emit(SIGNAL('focus_out(PyQt_PyObject)'), event) + self.focus_out.emit(event) QLineEdit.focusOutEvent(self, event) def dropEvent(self, ev): @@ -68,10 +71,10 @@ class SearchBox2(QComboBox): self.normal_background = 'rgb(255, 255, 255, 0%)' self.line_edit = SearchLineEdit(self) self.setLineEdit(self.line_edit) - self.connect(self.line_edit, SIGNAL('key_pressed(PyQt_PyObject)'), - self.key_pressed, Qt.DirectConnection) - self.connect(self.line_edit, SIGNAL('mouse_released(PyQt_PyObject)'), - self.mouse_released, Qt.DirectConnection) + self.line_edit.key_pressed.connect(self.key_pressed, + type=Qt.DirectConnection) + self.line_edit.mouse_released.connect(self.mouse_released, + type=Qt.DirectConnection) self.setEditable(True) self.help_state = False self.as_you_type = True @@ -90,14 +93,18 @@ class SearchBox2(QComboBox): self.help_text = help_text self.colorize = colorize self.clear_to_help() - self.connect(self, SIGNAL('editTextChanged(QString)'), self.text_edited_slot) def normalize_state(self): - self.setEditText('') - self.line_edit.setStyleSheet( - 'QLineEdit { color: black; background-color: %s; }' % - self.normal_background) - self.help_state = False + if self.help_state: + self.setEditText('') + self.line_edit.setStyleSheet( + 'QLineEdit { color: black; background-color: %s; }' % + self.normal_background) + self.help_state = False + else: + self.line_edit.setStyleSheet( + 'QLineEdit { color: black; background-color: %s; }' % + self.normal_background) def clear_to_help(self): if self.help_state: @@ -131,17 +138,13 @@ class SearchBox2(QComboBox): self.line_edit.setStyleSheet('QLineEdit { color: black; background-color: %s; }' % col) def key_pressed(self, event): - if self.help_state: - self.normalize_state() - if not self.as_you_type: - if event.key() in (Qt.Key_Return, Qt.Key_Enter): - self.do_search() + self.normalize_state() + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + self.do_search() + self.timer = self.startTimer(self.__class__.INTERVAL) def mouse_released(self, event): - if self.help_state: - self.normalize_state() - - def text_edited_slot(self, text): + self.normalize_state() if self.as_you_type: self.timer = self.startTimer(self.__class__.INTERVAL) @@ -227,14 +230,13 @@ class SavedSearchBox(QComboBox): self.line_edit = SearchLineEdit(self) self.setLineEdit(self.line_edit) - self.connect(self.line_edit, SIGNAL('key_pressed(PyQt_PyObject)'), - self.key_pressed, Qt.DirectConnection) - self.connect(self.line_edit, SIGNAL('mouse_released(PyQt_PyObject)'), - self.mouse_released, Qt.DirectConnection) - self.connect(self.line_edit, SIGNAL('focus_out(PyQt_PyObject)'), - self.focus_out, Qt.DirectConnection) - self.connect(self, SIGNAL('activated(const QString&)'), - self.saved_search_selected) + self.line_edit.key_pressed.connect(self.key_pressed, + type=Qt.DirectConnection) + self.line_edit.mouse_released.connect(self.mouse_released, + type=Qt.DirectConnection) + self.line_edit.focus_out.connect(self.focus_out, + type=Qt.DirectConnection) + self.activated[str].connect(self.saved_search_selected) completer = QCompleter(self) # turn off auto-completion self.setCompleter(completer) @@ -282,7 +284,7 @@ class SavedSearchBox(QComboBox): if self.help_state: self.normalize_state() - def saved_search_selected (self, qname): + def saved_search_selected(self, qname): qname = unicode(qname) if qname is None or not qname.strip(): return diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py deleted file mode 100644 index 9aa9b8262c..0000000000 --- a/src/calibre/gui2/status.py +++ /dev/null @@ -1,253 +0,0 @@ -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal ' - -import os - -from PyQt4.Qt import QStatusBar, QLabel, QWidget, QHBoxLayout, QPixmap, \ - QSizePolicy, QScrollArea, Qt, QSize, pyqtSignal, \ - QPropertyAnimation, QEasingCurve, QDesktopServices, QUrl - - -from calibre import fit_image, preferred_encoding, isosx -from calibre.gui2 import config -from calibre.gui2.widgets import IMAGE_EXTENSIONS -from calibre.gui2.notify import get_notifier -from calibre.ebooks import BOOK_EXTENSIONS -from calibre.library.comments import comments_to_html -from calibre.gui2.book_details import render_rows - -class BookInfoDisplay(QWidget): - - DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS - files_dropped = pyqtSignal(object, object) - - @classmethod - def paths_from_event(cls, event): - ''' - Accept a drop event and return a list of paths that can be read from - and represent files with extensions. - ''' - if event.mimeData().hasFormat('text/uri-list'): - urls = [unicode(u.toLocalFile()) for u in event.mimeData().urls()] - urls = [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)] - return [u for u in urls if os.path.splitext(u)[1][1:].lower() in cls.DROPABBLE_EXTENSIONS] - - def dragEnterEvent(self, event): - if int(event.possibleActions() & Qt.CopyAction) + \ - int(event.possibleActions() & Qt.MoveAction) == 0: - return - paths = self.paths_from_event(event) - if paths: - event.acceptProposedAction() - - def dropEvent(self, event): - paths = self.paths_from_event(event) - event.setDropAction(Qt.CopyAction) - self.files_dropped.emit(event, paths) - - def dragMoveEvent(self, event): - event.acceptProposedAction() - - - class BookCoverDisplay(QLabel): # {{{ - - def __init__(self, coverpath=I('book.svg')): - QLabel.__init__(self) - self.animation = QPropertyAnimation(self, 'size', self) - self.animation.setEasingCurve(QEasingCurve(QEasingCurve.OutExpo)) - self.animation.setDuration(1000) - self.animation.setStartValue(QSize(0, 0)) - self.setMaximumWidth(81) - self.setMaximumHeight(108) - self.default_pixmap = QPixmap(coverpath) - self.setScaledContents(True) - self.statusbar_height = 120 - self.setPixmap(self.default_pixmap) - - def do_layout(self): - self.animation.stop() - pixmap = self.pixmap() - pwidth, pheight = pixmap.width(), pixmap.height() - width, height = fit_image(pwidth, pheight, - pwidth, self.statusbar_height-20)[1:] - self.setMaximumHeight(height) - try: - aspect_ratio = pwidth/float(pheight) - except ZeroDivisionError: - aspect_ratio = 1 - self.setMaximumWidth(int(aspect_ratio*self.maximumHeight())) - self.animation.setEndValue(self.maximumSize()) - - def setPixmap(self, pixmap): - QLabel.setPixmap(self, pixmap) - self.do_layout() - self.animation.start() - - def sizeHint(self): - return QSize(self.maximumWidth(), self.maximumHeight()) - - def relayout(self, statusbar_size): - self.statusbar_height = statusbar_size.height() - self.do_layout() - - # }}} - - class BookDataDisplay(QLabel): - - mr = pyqtSignal(object) - link_clicked = pyqtSignal(object) - - def __init__(self): - QLabel.__init__(self) - self.setText('') - self.setWordWrap(True) - self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) - self.linkActivated.connect(self.link_activated) - self._link_clicked = False - - def mouseReleaseEvent(self, ev): - QLabel.mouseReleaseEvent(self, ev) - if not self._link_clicked: - self.mr.emit(ev) - self._link_clicked = False - - def link_activated(self, link): - self._link_clicked = True - link = unicode(link) - self.link_clicked.emit(link) - - show_book_info = pyqtSignal() - - def __init__(self, clear_message): - QWidget.__init__(self) - self.setCursor(Qt.PointingHandCursor) - self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) - self._layout = QHBoxLayout() - self.setLayout(self._layout) - self.clear_message = clear_message - self.cover_display = BookInfoDisplay.BookCoverDisplay() - self._layout.addWidget(self.cover_display) - self.book_data = BookInfoDisplay.BookDataDisplay() - self.book_data.mr.connect(self.mouseReleaseEvent) - self._layout.addWidget(self.book_data) - self.data = {} - self.setVisible(False) - self._layout.setAlignment(self.cover_display, Qt.AlignTop|Qt.AlignLeft) - - def mouseReleaseEvent(self, ev): - ev.accept() - self.show_book_info.emit() - - def show_data(self, data): - if data.has_key('cover'): - self.cover_display.setPixmap(QPixmap.fromImage(data.pop('cover'))) - else: - self.cover_display.setPixmap(self.cover_display.default_pixmap) - - rows, comments = [], '' - self.book_data.setText('') - self.data = data.copy() - rows = render_rows(self.data) - rows = '\n'.join([u'%s:%s'%(k,t) for - k, t in rows]) - if _('Comments') in self.data: - comments = comments_to_html(self.data[_('Comments')]) - comments = ('%s:'%_('Comments'))+comments - left_pane = u'%s
'%rows - right_pane = u'
%s
'%comments - self.book_data.setText(u'
%s%s
' - % (left_pane, right_pane)) - - self.clear_message() - self.book_data.updateGeometry() - self.updateGeometry() - self.setVisible(True) - self.setToolTip('

'+_('Click to open Book Details window') + - '

' + _('Path') + ': ' + data.get(_('Path'), '')) - - - -class StatusBarInterface(object): - - def initialize(self, systray=None): - self.systray = systray - self.notifier = get_notifier(systray) - - def show_message(self, msg, timeout=0): - QStatusBar.showMessage(self, msg, timeout) - if self.notifier is not None and not config['disable_tray_notification']: - if isosx and isinstance(msg, unicode): - try: - msg = msg.encode(preferred_encoding) - except UnicodeEncodeError: - msg = msg.encode('utf-8') - self.notifier(msg) - - def clear_message(self): - QStatusBar.clearMessage(self) - -class BookDetailsInterface(object): - - # These signals must be defined in the class implementing this interface - files_dropped = None - show_book_info = None - open_containing_folder = None - view_specific_format = None - - def reset_info(self): - raise NotImplementedError() - - def show_data(self, data): - raise NotImplementedError() - -class HStatusBar(QStatusBar, StatusBarInterface): - pass - -class StatusBar(QStatusBar, StatusBarInterface, BookDetailsInterface): - - files_dropped = pyqtSignal(object, object) - show_book_info = pyqtSignal() - open_containing_folder = pyqtSignal(int) - view_specific_format = pyqtSignal(int, object) - - resized = pyqtSignal(object) - - def initialize(self, systray=None): - StatusBarInterface.initialize(self, systray=systray) - self.book_info = BookInfoDisplay(self.clear_message) - self.book_info.setAcceptDrops(True) - self.scroll_area = QScrollArea() - self.scroll_area.setWidget(self.book_info) - self.scroll_area.setWidgetResizable(True) - self.book_info.show_book_info.connect(self.show_book_info.emit, - type=Qt.QueuedConnection) - self.book_info.files_dropped.connect(self.files_dropped.emit, - type=Qt.QueuedConnection) - self.book_info.book_data.link_clicked.connect(self._link_clicked) - self.addWidget(self.scroll_area, 100) - self.setMinimumHeight(120) - self.resized.connect(self.book_info.cover_display.relayout) - self.book_info.cover_display.relayout(self.size()) - - - def _link_clicked(self, link): - typ, _, val = link.partition(':') - if typ == 'path': - self.open_containing_folder.emit(int(val)) - elif typ == 'format': - id_, fmt = val.split(':') - self.view_specific_format.emit(int(id_), fmt) - elif typ == 'devpath': - QDesktopServices.openUrl(QUrl.fromLocalFile(val)) - - - def resizeEvent(self, ev): - self.resized.emit(self.size()) - - def reset_info(self): - self.book_info.show_data({}) - - def show_data(self, data): - self.book_info.show_data(data) - diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 2226520cf2..6452890883 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -126,8 +126,7 @@ class Main(MainWindow, Ui_MainWindow, DeviceMixin, ToolbarMixin, # {{{ # Jobs Button {{{ self.job_manager = JobManager() self.jobs_dialog = JobsDialog(self, self.job_manager) - self.jobs_button = JobsButton(horizontal=config['gui_layout'] != - 'narrow') + self.jobs_button = JobsButton(horizontal=True) self.jobs_button.initialize(self.jobs_dialog, self.job_manager) # }}} @@ -216,12 +215,6 @@ class Main(MainWindow, Ui_MainWindow, DeviceMixin, ToolbarMixin, # {{{ self.vanity.setText(self.vanity_template%dict(version=' ', device=' ')) self.device_info = ' ' UpdateMixin.__init__(self, opts) - ####################### Status Bar ##################### - self.status_bar.initialize(self.system_tray_icon) - self.book_details.show_book_info.connect(self.show_book_info) - self.book_details.files_dropped.connect(self.files_dropped_on_book) - self.book_details.open_containing_folder.connect(self.view_folder_for_id) - self.book_details.view_specific_format.connect(self.view_format_by_id) ####################### Setup Toolbar ##################### ToolbarMixin.__init__(self) @@ -417,6 +410,8 @@ class Main(MainWindow, Ui_MainWindow, DeviceMixin, ToolbarMixin, # {{{ self.tags_view.set_new_model() # in case columns changed self.tags_view.recount() self.create_device_menu() + self.set_device_menu_items_state(bool(self.device_connected), + self.device_connected == 'folder') if not patheq(self.library_path, d.database_location): newloc = d.database_location diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index c7830187df..fe4aac12b5 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -136,6 +136,23 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.initialize_dynamic() def initialize_dynamic(self): + self.conn.executescript(''' + DROP TRIGGER IF EXISTS author_insert_trg; + CREATE TEMP TRIGGER author_insert_trg + AFTER INSERT ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) WHERE id=NEW.id; + END; + DROP TRIGGER IF EXISTS author_update_trg; + CREATE TEMP TRIGGER author_update_trg + BEFORE UPDATE ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) + WHERE id=NEW.id AND name <> NEW.name; + END; + ''') + self.conn.execute( + 'UPDATE authors SET sort=author_to_author_sort(name) WHERE sort IS NULL') self.conn.executescript(u''' CREATE TEMP VIEW IF NOT EXISTS tag_browser_news AS SELECT DISTINCT id, diff --git a/src/calibre/library/schema_upgrades.py b/src/calibre/library/schema_upgrades.py index a8ffd9cde4..1ba650f6fd 100644 --- a/src/calibre/library/schema_upgrades.py +++ b/src/calibre/library/schema_upgrades.py @@ -385,28 +385,5 @@ class SchemaUpgrade(object): if table.startswith('custom_column_') and link_table in tables: create_cust_tag_browser_view(table, link_table) - from calibre.ebooks.metadata import author_to_author_sort + self.conn.execute('UPDATE authors SET sort=author_to_author_sort(name)') - aut = self.conn.get('SELECT id, name FROM authors'); - records = [] - for (id, author) in aut: - records.append((id, author.replace('|', ','))) - for id,author in records: - self.conn.execute('UPDATE authors SET sort=? WHERE id=?', - (author_to_author_sort(author.replace('|', ',')).strip(), id)) - self.conn.commit() - self.conn.executescript(''' - DROP TRIGGER IF EXISTS author_insert_trg; - CREATE TRIGGER author_insert_trg - AFTER INSERT ON authors - BEGIN - UPDATE authors SET sort=author_to_author_sort(NEW.name) WHERE id=NEW.id; - END; - DROP TRIGGER IF EXISTS author_update_trg; - CREATE TRIGGER author_update_trg - BEFORE UPDATE ON authors - BEGIN - UPDATE authors SET sort=author_to_author_sort(NEW.name) - WHERE id=NEW.id AND name <> NEW.name; - END; - ''') diff --git a/src/calibre/library/sqlite.py b/src/calibre/library/sqlite.py index 7e0458fba4..85954f6e0f 100644 --- a/src/calibre/library/sqlite.py +++ b/src/calibre/library/sqlite.py @@ -94,6 +94,9 @@ class Connection(sqlite.Connection): return ans[0] return ans.fetchall() +def _author_to_author_sort(x): + if not x: return '' + return author_to_author_sort(x.replace('|', ',')) class DBThread(Thread): @@ -121,7 +124,7 @@ class DBThread(Thread): else: self.conn.create_function('title_sort', 1, title_sort) self.conn.create_function('author_to_author_sort', 1, - lambda x: author_to_author_sort(x.replace('|', ','))) + _author_to_author_sort) self.conn.create_function('uuid4', 0, lambda : str(uuid.uuid4())) # Dummy functions for dynamically created filters self.conn.create_function('books_list_filter', 1, lambda x: 1) diff --git a/src/calibre/utils/Zeroconf.py b/src/calibre/utils/Zeroconf.py index 8a1e13c23f..f4a7119d16 100755 --- a/src/calibre/utils/Zeroconf.py +++ b/src/calibre/utils/Zeroconf.py @@ -596,10 +596,11 @@ class DNSIncoming(object): next = off + 1 off = ((len & 0x3F) << 8) | ord(self.data[off]) if off >= first: - raise 'Bad domain name (circular) at ' + str(off) + raise ValueError('Bad domain name (circular) at ' + + str(off)) first = off else: - raise 'Bad domain name at ' + str(off) + raise ValueError('Bad domain name at ' + str(off)) if next >= 0: self.offset = next diff --git a/src/calibre/web/feeds/news.py b/src/calibre/web/feeds/news.py index 9e05babecc..73e0fae8e8 100644 --- a/src/calibre/web/feeds/news.py +++ b/src/calibre/web/feeds/news.py @@ -788,6 +788,7 @@ class BasicNewsRecipe(Recipe): } .summary_byline { + text-align:left; font-family:monospace; } @@ -1139,12 +1140,6 @@ class BasicNewsRecipe(Recipe): mi = MetaInformation(self.short_title() + strftime(self.timefmt), [__appname__]) mi.publisher = __appname__ mi.author_sort = __appname__ - if self.output_profile.name == 'iPad': - date_as_author = '%s, %s %s, %s' % (strftime('%A'), strftime('%B'), strftime('%d').lstrip('0'), strftime('%Y')) - mi = MetaInformation(self.short_title(), [date_as_author]) - mi.publisher = __appname__ - sort_author = re.sub('^\s*A\s+|^\s*The\s+|^\s*An\s+', '', self.title).rstrip() - mi.author_sort = '%s %s' % (sort_author, strftime('%Y-%m-%d')) mi.publication_type = 'periodical:'+self.publication_type mi.timestamp = nowf() mi.comments = self.description