This commit is contained in:
GRiker 2013-10-18 04:50:45 -06:00
commit c01a7c7812
56 changed files with 1461 additions and 585 deletions

View File

@ -20,6 +20,73 @@
# new recipes: # new recipes:
# - title: # - title:
- version: 1.7.0
date: 2013-10-18
new features:
- title: "Cover grid: Allow using images as the background for the cover grid. To choose an image, go to Preferences->Look & Feel->Cover Grid."
tickets: [1239194]
- title: "An option to mark newly added books with a temporary mark. Option is in Preferences->Adding books."
tickets: [1238609]
- title: "Edit metadata dialog: Allow turning off the cover size displayed in the bottom right corner of the cover by right clicking the cover and choosing 'Hide cover size'. It can be restored the same way."
bug fixes:
- title: "Conversion: If both embed font family and the filter css option to remove fonts are set, do not remove the font specified by the embed font family option."
- title: "Fix a few remaining situations that could cause formats column to show an error message about SHLock"
- title: "Make deleting books to recycle bin more robust. Ensure that the temporary directory created during the move to recycle bin process is not left behind in case of errors."
- title: "Windows: Check if the books' files are in use before deleting"
- title: "Fix custom device driver swap main and card option not working. Also fix swapping not happening for a few devices on linux"
tickets: [1240504]
- title: "Edit metadata dialog: The Edit metadata dialog currently limits its max size based on the geometry of the smallest attached screen. Change that to use the geometry of the screen on which it will be shown."
tickets: [1239597]
- title: "HTMLZ Output: Fix <style> tag placed inside <body> instead of <head>."
tickets: [1239530]
- title: "HTMLZ Output: Fix inline styles not escaping quotes properly."
tickets: [1239527]
- title: "HTMLZ Output: Fix incorrect handling of some self closing tags like <br>."
tickets: [1239555]
- title: "Content server: Fix single item categories not working with reverse proxy setup."
tickets: [1238987]
- title: "Fix a bug that could cause calibre to crash when switching from a large library to a smaller library with marked books."
tickets: [1239210]
- title: "Get Books: Fix downloading of some books in formats that do not have metadata yielding nonsense titles"
- title: "Allow marked book button to be added to main toolbar when device is connected"
tickets: [1239163]
- title: "Fix error if a marked book is deleted/merged."
tickets: [1239161]
- title: "Template language: Fix formatter function days_between to compute the right value when the answer is negative."
- title: "Windows: Fix spurious file in use by other process error if the book's folder contained multiple hard links pointing to the same file"
tickets: [1240788, 1240194]
- title: "Windows: Fix duplicate files being created in very special circumstances when changing title and/or author. (the title or author had to be between 31 and 35 characters long and the book entry had to have been created by a pre 1.x version of calibre). You can check if your library has any such duplicates and remove them, by using the Check Library tool (Right click the calibre button on the toolbar and select Library Maintenance->Check Library)."
improved recipes:
- Wall Street Journal
- Newsweek Polska
- Wired Magazine
- cracked.com
- Television Without Pity
- Carta
- Diagonales
- version: 1.6.0 - version: 1.6.0
date: 2013-10-11 date: 2013-10-11

View File

@ -12,7 +12,7 @@ class Carta(BasicNewsRecipe):
title = u'Carta' title = u'Carta'
description = 'News about electronic publishing' description = 'News about electronic publishing'
__author__ = 'Oliver Niesner' __author__ = 'Oliver Niesner' # AGe Update 2013-10-13
use_embedded_content = False use_embedded_content = False
timefmt = ' [%a %d %b %Y]' timefmt = ' [%a %d %b %Y]'
oldest_article = 7 oldest_article = 7
@ -25,7 +25,7 @@ class Carta(BasicNewsRecipe):
remove_tags_after = [dict(name='p', attrs={'class':'tags-blog'})] remove_tags_after = [dict(name='div', attrs={'id':'BlogContent'})] # AGe
remove_tags = [dict(name='p', attrs={'class':'print'}), remove_tags = [dict(name='p', attrs={'class':'print'}),
dict(name='p', attrs={'class':'tags-blog'}), dict(name='p', attrs={'class':'tags-blog'}),

View File

@ -22,12 +22,8 @@ class Cracked(BasicNewsRecipe):
'comment': description, 'tags': category, 'publisher': publisher, 'language': language 'comment': description, 'tags': category, 'publisher': publisher, 'language': language
} }
# remove_tags_before = dict(id='PrimaryContent') keep_only_tags = [dict(name='article', attrs={'class': 'module article dropShadowBottomCurved'}),
dict(name='article', attrs={'class': 'module blog dropShadowBottomCurved'})]
keep_only_tags = dict(name='article', attrs={
'class': 'module article dropShadowBottomCurved'})
# remove_tags_after = dict(name='div', attrs={'class':'shareBar'})
remove_tags = [ remove_tags = [
dict(name='section', attrs={'class': ['socialTools', 'quickFixModule']})] dict(name='section', attrs={'class': ['socialTools', 'quickFixModule']})]

View File

@ -1,72 +1,50 @@
#!/usr/bin/env python
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2009, Darko Miletic <darko.miletic at gmail.com>' __copyright__ = '2009-2013, Darko Miletic <darko.miletic at gmail.com>'
''' '''
elargentino.com diagonales.infonews.com
''' '''
from calibre.web.feeds.news import BasicNewsRecipe from calibre.web.feeds.news import BasicNewsRecipe
from calibre.ebooks.BeautifulSoup import Tag
class Diagonales(BasicNewsRecipe): class Diagonales(BasicNewsRecipe):
title = 'Diagonales' title = 'Diagonales'
__author__ = 'Darko Miletic' __author__ = 'Darko Miletic'
description = 'El nuevo diario de La Plata' description = 'Para estar bien informado sobre los temas de actualidad. Conoce sobre pais, economia, deportes, mundo, espectaculos, sociedad, entrevistas y tecnologia.'
publisher = 'ElArgentino.com' publisher = 'INFOFIN S.A.'
category = 'news, politics, Argentina, La Plata' category = 'news, politics, Argentina, La Plata'
oldest_article = 2 oldest_article = 2
max_articles_per_feed = 100 max_articles_per_feed = 100
no_stylesheets = True no_stylesheets = True
use_embedded_content = False use_embedded_content = False
encoding = 'utf-8' encoding = 'utf-8'
language = 'es_AR' language = 'es_AR'
publication_type = 'newspaper'
lang = 'es-AR' delay = 1
direction = 'ltr' remove_empty_feeds = True
INDEX = 'http://www.elargentino.com/medios/122/Diagonales.html'
extra_css = ' .titulo{font-size: x-large; font-weight: bold} .volantaImp{font-size: small; font-weight: bold} ' extra_css = ' .titulo{font-size: x-large; font-weight: bold} .volantaImp{font-size: small; font-weight: bold} '
html2lrf_options = [ conversion_options = {
'--comment' , description 'comment' : description
, '--category' , category , 'tags' : category
, '--publisher', publisher , 'publisher' : publisher
] , 'language' : language
}
html2epub_options = 'publisher="' + publisher + '"\ncomments="' + description + '"\ntags="' + category + '"\noverride_css=" p {text-indent: 0cm; margin-top: 0em; margin-bottom: 0.5em} "'
keep_only_tags = [dict(name='div', attrs={'class':'ContainerPop'})] keep_only_tags = [dict(name='div', attrs={'class':'ContainerPop'})]
remove_tags = [dict(name='link')]
remove_tags = [dict(name='link')] feeds = [
(u'Pais' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=112&Content-Type=text/xml&ChannelDesc=Pa%C3%ADs')
feeds = [(u'Articulos', u'http://www.elargentino.com/Highlights.aspx?ParentType=Section&ParentId=122&Content-Type=text/xml&ChannelDesc=Diagonales')] ,(u'Deportes' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=106&Content-Type=text/xml&ChannelDesc=Deportes')
,(u'Economia' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=107&Content-Type=text/xml&ChannelDesc=Econom%C3%ADa')
,(u'Sociedad' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=109&Content-Type=text/xml&ChannelDesc=Sociedad')
,(u'Mundo' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=113&Content-Type=text/xml&ChannelDesc=Mundo')
,(u'Espectaculos', u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=114&Content-Type=text/xml&ChannelDesc=Espect%C3%A1culos')
,(u'Entrevistas' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=115&Content-Type=text/xml&ChannelDesc=Entrevistas')
,(u'Tecnologia' , u'http://diagonales.infonews.com/Highlights.aspx?ParentType=Section&ParentId=118&Content-Type=text/xml&ChannelDesc=Tecnolog%C3%ADa')
]
def print_version(self, url): def print_version(self, url):
main, sep, article_part = url.partition('/nota-') main, sep, article_part = url.partition('/nota-')
article_id, rsep, rrest = article_part.partition('-') article_id, rsep, rrest = article_part.partition('-')
return u'http://www.elargentino.com/Impresion.aspx?Id=' + article_id return u'http://diagonales.infonews.com/Impresion.aspx?Id=' + article_id
def preprocess_html(self, soup):
for item in soup.findAll(style=True):
del item['style']
soup.html['lang'] = self.lang
soup.html['dir' ] = self.direction
mlang = Tag(soup,'meta',[("http-equiv","Content-Language"),("content",self.lang)])
mcharset = Tag(soup,'meta',[("http-equiv","Content-Type"),("content","text/html; charset=utf-8")])
soup.head.insert(0,mlang)
soup.head.insert(1,mcharset)
return soup
def get_cover_url(self):
cover_url = None
soup = self.index_to_soup(self.INDEX)
cover_item = soup.find('div',attrs={'class':'colder'})
if cover_item:
clean_url = self.image_url_processor(None,cover_item.div.img['src'])
cover_url = 'http://www.elargentino.com' + clean_url + '&height=600'
return cover_url
def image_url_processor(self, baseurl, url):
base, sep, rest = url.rpartition('?Id=')
img, sep2, rrest = rest.partition('&')
return base + sep + img

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -2,173 +2,263 @@
#!/usr/bin/env python #!/usr/bin/env python
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2010, matek09, matek09@gmail.com; 2012, admroz, a.rozewicki@gmail.com' __copyright__ = '2010, matek09, matek09@gmail.com; 2012-2013, admroz, a.rozewicki@gmail.com'
import re
from calibre.web.feeds.news import BasicNewsRecipe from calibre.web.feeds.news import BasicNewsRecipe
from calibre.ptempfile import PersistentTemporaryFile from calibre.ptempfile import PersistentTemporaryFile
from string import capwords from string import capwords
import datetime import datetime
from calibre.ebooks.BeautifulSoup import BeautifulSoup
class Newsweek(BasicNewsRecipe): class Newsweek(BasicNewsRecipe):
# how many issues to go back, 0 means get the most current one # how many issues to go back, 0 means get the most current one
BACK_ISSUES = 2 BACK_ISSUES = 1
EDITION = '0' EDITION = '0'
DATE = None DATE = None
YEAR = datetime.datetime.now().year YEAR = datetime.datetime.now().year
title = u'Newsweek Polska' title = u'Newsweek Polska'
__author__ = 'matek09, admroz' __author__ = 'matek09, admroz'
description = 'Weekly magazine' description = 'Weekly magazine'
encoding = 'utf-8' encoding = 'utf-8'
language = 'pl' language = 'pl'
remove_javascript = True remove_javascript = True
temp_files = [] temp_files = []
articles_are_obfuscated = True articles_are_obfuscated = True
# #
# Parses each article # Parses article contents from one page
# #
def get_obfuscated_article(self, url): def get_article_divs(self, css, main_section):
br = self.get_browser() strs = []
br.open(url)
source = br.response().read()
page = self.index_to_soup(source)
main_section = page.find(id='mainSection') # get all divs with given css class
article_divs = main_section.findAll('div', attrs={'class' : css})
for article_div in article_divs:
title = main_section.find('h1') # remove sections like 'read more...' etc.
info = main_section.find('ul', attrs={'class' : 'articleInfo'}) for p in article_div.findAll('p'):
authors = info.find('li').find('h4')
article = main_section.find('div', attrs={'id' : 'article'})
# remove related articles box if p.find('span', attrs={'style' : 'color: #800000; font-size: medium;'}):
related = article.find('div', attrs={'class' : 'relatedBox'}) p.extract()
if related is not None: continue
related.extract()
# remove div with social networking links and links to if p.find('span', attrs={'style' : 'font-size: medium; color: #800000;'}):
# other articles in web version p.extract()
for div in article.findAll('div'): continue
if div.find('span', attrs={'class' : 'google-plus'}):
div.extract()
for p in div.findAll('p'): if p.find('span', attrs={'style' : 'font-size: medium;'}):
if p.find('span', attrs={'style' : 'color: rgb(255, 0, 0);'}): p.extract()
p.extract() continue
continue
for a in p.findAll('a'): if p.find('span', attrs={'style' : 'color: #800000;'}):
if a.find('span', attrs={'style' : 'font-size: larger;'}): p.extract()
a.extract() continue
obj = p.find('object')
if obj:
obj.extract()
continue
strong = p.find('strong')
if strong:
newest = re.compile("Tekst pochodzi z najnowszego numeru Tygodnika Newsweek")
if newest.search(str(strong)):
strong.extract()
continue
itunes = p.find('a')
if itunes:
reurl = re.compile("itunes.apple.com")
if reurl.search(str(itunes['href'])):
p.extract()
continue
imagedesc = p.find('div', attrs={'class' : 'image-desc'})
if imagedesc:
redesc = re.compile("Okładka numeru")
if (redesc.search(str(imagedesc))):
p.extract()
continue
html = unicode(title) + unicode(authors) + unicode(article)
next = main_section.find('li', attrs={'class' : 'next'})
while next: # get actual contents
url = next.find('a')['href'] for content in article_div.contents:
br.open(url) strs.append("".join(str(content)))
source = br.response().read()
page = self.index_to_soup(source) # return contents as a string
main_section = page.find(id='mainSection') return unicode("".join(strs))
article = main_section.find('div', attrs={'id' : 'article'})
aside = article.find(id='articleAside')
if aside is not None:
aside.extract()
html = html + unicode(article)
next = main_section.find('li', attrs={'class' : 'next'})
self.temp_files.append(PersistentTemporaryFile('_temparse.html')) #
self.temp_files[-1].write(html) # Articles can be divided into several pages, this method parses them recursevely
self.temp_files[-1].close() #
return self.temp_files[-1].name def get_article_page(self, br, url, page):
br.open(url)
source = br.response().read()
html = ''
matches = re.search(r'<article>(.*)</article>', source, re.DOTALL)
if matches is None:
print "no article tag found, returning..."
return
main_section = BeautifulSoup(matches.group(0))
if page == 0:
title = main_section.find('h1')
html = html + unicode(title)
authors = ''
authorBox = main_section.find('div', attrs={'class' : 'AuthorBox'})
if authorBox is not None:
authorH4 = authorBox.find('h4')
if authorH4 is not None:
authors = self.tag_to_string(authorH4)
html = html + unicode(authors)
info = main_section.find('p', attrs={'class' : 'lead'})
html = html + unicode(info)
html = html + self.get_article_divs('3917dc34e07c9c7180df2ea9ef103361845c8af42b71f51b960059226090a1ac articleStart', main_section)
html = html + self.get_article_divs('3917dc34e07c9c7180df2ea9ef103361845c8af42b71f51b960059226090a1ac', main_section)
nextPage = main_section.find('a', attrs={'class' : 'next'})
if nextPage:
html = html + self.get_article_page(br, nextPage['href'], page+1)
return html
#
# Parses each article
#
def get_obfuscated_article(self, url):
br = self.get_browser()
html = self.get_article_page(br, url, 0)
self.temp_files.append(PersistentTemporaryFile('_temparse.html'))
self.temp_files[-1].write(html)
self.temp_files[-1].close()
return self.temp_files[-1].name
# #
# Goes back given number of issues. It also knows how to go back # Goes back given number of issues. It also knows how to go back
# to the previous year if there are not enough issues in the current one # to the previous year if there are not enough issues in the current one
# #
def find_last_issue(self, archive_url): def find_last_issue(self, archive_url):
archive_soup = self.index_to_soup(archive_url) archive_soup = self.index_to_soup(archive_url, True)
select = archive_soup.find('select', attrs={'id' : 'paper_issue_select'})
options = select.findAll(lambda tag: tag.name == 'option' and tag.has_key('value'))
# check if need to go back to previous year # workaround because html is so messed up that find() method on soup returns None
if len(options) > self.BACK_ISSUES: # and therefore we need to extract subhtml that we need
option = options[self.BACK_ISSUES]; matches = re.search(r'<ul class="rightIssueList">(.*?)</ul>', archive_soup, re.DOTALL)
self.EDITION = option['value'].replace('http://www.newsweek.pl/wydania/','') if matches is None:
self.index_to_soup('http://www.newsweek.pl/wydania/' + self.EDITION) return
else:
self.BACK_ISSUES = self.BACK_ISSUES - len(options) subSoup = BeautifulSoup(matches.group(0))
self.YEAR = self.YEAR - 1 issueLinks = subSoup.findAll('a')
self.find_last_issue(archive_url + ',' + str(self.YEAR))
# check if need to go back to previous year
if len(issueLinks) > self.BACK_ISSUES:
link = issueLinks[self.BACK_ISSUES];
self.EDITION = link['href'].replace('http://www.newsweek.pl/wydania/','')
self.index_to_soup('http://www.newsweek.pl/wydania/' + self.EDITION)
else:
self.BACK_ISSUES = self.BACK_ISSUES - len(issueLinks)
self.YEAR = self.YEAR - 1
self.find_last_issue(archive_url + '/' + str(self.YEAR))
# #
# Looks for the last issue which we want to download. Then goes on each # Looks for the last issue which we want to download. Then goes on each
# section and article and stores them (assigning to sections) # section and article and stores them (assigning to sections)
# #
def parse_index(self): def parse_index(self):
archive_url = 'http://www.newsweek.pl/wydania/archiwum' archive_url = 'http://www.newsweek.pl/wydania/archiwum'
self.find_last_issue(archive_url) self.find_last_issue(archive_url)
soup = self.index_to_soup('http://www.newsweek.pl/wydania/' + self.EDITION) soup = self.index_to_soup('http://www.newsweek.pl/wydania/' + self.EDITION)
self.DATE = self.tag_to_string(soup.find('span', attrs={'class' : 'data'}))
main_section = soup.find(id='mainSection')
img = main_section.find(lambda tag: tag.name == 'img' and tag.has_key('alt') and tag.has_key('title'))
self.cover_url = img['src']
feeds = []
articles = {}
sections = []
news_list = main_section.find('ul', attrs={'class' : 'newsList'}) matches = re.search(r'<div class="Issue-Entry">(.*)ARTICLE_BOTTOM', soup.prettify(), re.DOTALL)
section = 'Inne' if matches is None:
return
for li in news_list.findAll('li'): main_section = BeautifulSoup(matches.group(0))
h3 = li.find('h3')
if h3 is not None:
section = capwords(self.tag_to_string(h3))
continue
else:
h2 = li.find('h2')
if h2 is not None:
article = self.create_article(h2)
if article is None :
continue
if articles.has_key(section): # date
articles[section].append(article) matches = re.search(r'(\d{2}-\d{2}-\d{4})', self.tag_to_string(main_section.find('h2')))
else: if matches:
articles[section] = [article] self.DATE = matches.group(0)
sections.append(section)
# cover
img = main_section.find(lambda tag: tag.name == 'img' and tag.has_key('alt') and tag.has_key('title'))
self.cover_url = img['src']
feeds = []
articles = {}
sections = []
# sections
for sectionUl in main_section.findAll('ul', attrs={'class' : 'whatsin'}):
# section header
header = sectionUl.find('li', attrs={'class' : 'header'})
if header is None:
continue
section = capwords(self.tag_to_string(header))
# articles in section
articleUl = sectionUl.find('ul')
if articleUl is None:
continue
for articleLi in articleUl.findAll('li'):
# check if article is closed which should be skipped
closed = articleLi.find('span', attrs={'class' : 'closeart'})
if closed is not None:
continue
article = self.create_article(articleLi)
if article is None :
continue
if articles.has_key(section):
articles[section].append(article)
else:
articles[section] = [article]
sections.append(section)
for section in sections:
# print("%s -> %d" % (section, len(articles[section])))
#
# for article in articles[section]:
# print(" - %s" % article)
feeds.append((section, articles[section]))
return feeds
for section in sections: #
feeds.append((section, articles[section])) # Creates each article metadata (skips locked ones). The content will
return feeds # be extracted later by other method (get_obfuscated_article).
#
def create_article(self, articleLi):
article = {}
a = articleLi.find('a')
if a is None:
return None
# article['title'] = self.tag_to_string(a)
# Creates each article metadata (skips locked ones). The content will article['url'] = a['href']
# be extracted later by other method (get_obfuscated_article). article['date'] = self.DATE
# article['description'] = ''
def create_article(self, h2):
article = {}
a = h2.find('a')
if a is None:
return None
article['title'] = self.tag_to_string(a) return article
article['url'] = a['href']
article['date'] = self.DATE
desc = h2.findNext('p')
if desc is not None:
article['description'] = self.tag_to_string(desc)
else:
article['description'] = ''
return article

View File

@ -1,21 +1,98 @@
from calibre.web.feeds.news import BasicNewsRecipe from calibre.web.feeds.news import BasicNewsRecipe
import re
class HindustanTimes(BasicNewsRecipe): class TelevisionWithoutPity(BasicNewsRecipe):
title = u'Television Without Pity' title = u'Television Without Pity'
language = 'en' language = 'en'
__author__ = 'Krittika Goyal' __author__ = 'Snarkastica'
oldest_article = 1 #days SHOW = 'http://www.televisionwithoutpity.com/show/SHOW-NAME-HERE/recaps/' # Used for pulling down an entire show, not just the RSS feed
oldest_article = 7 # days
max_articles_per_feed = 25 max_articles_per_feed = 25
# reverse_article_order=True # Useful for entire show, to display in episode order
#encoding = 'cp1252' #encoding = 'cp1252'
use_embedded_content = False use_embedded_content = False
preprocess_regexps = [(re.compile(r'<span class="headline_recap_title .*?>', re.DOTALL|re.IGNORECASE), lambda match: '<span class="headline_recap_title">')]
keep_only_tags = [dict(name='span', attrs={'class':'headline_recap_title'}), dict(
name='p', attrs={'class':'byline'}), dict(name='div', attrs={'class':'body_recap'}), dict(name='h1')]
no_stylesheets = True no_stylesheets = True
auto_cleanup = True
#auto_cleanup_keep = '//div[@class="float_right"]'
# Comment this out and configure process_index() to retrieve a single show
feeds = [ feeds = [
('News', ('Ltest Recaps',
'http://www.televisionwithoutpity.com/rss.xml'), 'http://www.televisionwithoutpity.com/rss.xml'),
] ]
'''
This method can be used to grab all recaps for a single show
Set the SHOW constant at the beginning of this file to the URL for a show's recap page
(the page listing all recaps, usually of the form:
http://www.televisionwithoutpity.com/show/SHOW-NAME/recaps/"
Where SHOW-NAME is the hyphenated name of the show.
To use:
1. Comment out feeds = [...] earlier in this file
2. Set the SHOW constant to the show's recap page
3. Uncomment the following function
'''
'''
def parse_index(self):
soup = self.index_to_soup(self.SHOW)
feeds = []
articles = []
showTitle = soup.find('h1').string
recaps = soup.find('table')
for ep in recaps.findAll('tr'):
epData = ep.findAll('td')
epNum = epData[0].find(text=True).strip()
if not epNum == "Ep.":
epT = self.tag_to_string(epData[1].find('em')).strip()
epST = " (or " + self.tag_to_string(epData[1].find('h3')).strip() + ")"
epTitle = epNum + ": " + epT + epST
epData[1].find('em').extract()
epURL = epData[1].find('a', href=True)
epURL = epURL['href']
epSum = self.tag_to_string(epData[1].find('p')).strip()
epDate = epData[2].find(text=True).strip()
epAuthor = self.tag_to_string(epData[4].find('p')).strip()
articles.append({'title':epTitle, 'url':epURL, 'description':epSum, 'date':epDate, 'author':epAuthor})
feeds.append((showTitle, articles))
#self.abort_recipe_processing("test")
return feeds
'''
# This will add subsequent pages of multipage recaps to a single article page
def append_page(self, soup, appendtag, position):
if (soup.find('p',attrs={'class':'pages'})): # If false, will still grab single-page recaplets
pager = soup.find('p',attrs={'class':'pages'}).find(text='Next')
if pager:
nexturl = pager.parent['href']
soup2 = self.index_to_soup(nexturl)
texttag = soup2.find('div', attrs={'class':'body_recap'})
for it in texttag.findAll(style=True):
del it['style']
newpos = len(texttag.contents)
self.append_page(soup2,texttag,newpos)
texttag.extract()
appendtag.insert(position,texttag)
def preprocess_html(self, soup):
self.append_page(soup, soup.body, 3)
return soup
# Remove the multi page links (we had to keep these in for append_page(), but they can go away now
# Could have used CSS to hide, but some readers ignore CSS.
def postprocess_html(self, soup, first_fetch):
paginator = soup.findAll('p', attrs={'class':'pages'})
if paginator:
for p in paginator:
p.extract()
# TODO: Fix this so it converts the headline class into a heading 1
#titleTag = Tag(soup, "h1")
#repTag = soup.find('span', attrs={'class':'headline_recap_title'})
#titleTag.insert(0, repTag.contents[0])
# repTag.extract()
#soup.body.insert(1, titleTag)
return soup

View File

@ -1,9 +1,8 @@
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2012, mkydgr' __copyright__ = '2010-2013, Darko Miletic <darko.miletic at gmail.com>'
''' '''
www.wired.com www.wired.com
based on the (broken) built-in recipe by Darko Miletic <darko.miletic at gmail.com>
''' '''
import re import re
@ -12,12 +11,11 @@ from calibre.web.feeds.news import BasicNewsRecipe
class Wired(BasicNewsRecipe): class Wired(BasicNewsRecipe):
title = 'Wired Magazine' title = 'Wired Magazine'
__author__ = 'mkydgr' __author__ = 'Darko Miletic'
description = 'Technology News' description = 'Gaming news'
publisher = 'Conde Nast Digital' publisher = 'Conde Nast Digital'
category = '' category = 'news, games, IT, gadgets'
oldest_article = 500 oldest_article = 32
delay = 1
max_articles_per_feed = 100 max_articles_per_feed = 100
no_stylesheets = True no_stylesheets = True
encoding = 'utf-8' encoding = 'utf-8'
@ -25,9 +23,15 @@ class Wired(BasicNewsRecipe):
masthead_url = 'http://www.wired.com/images/home/wired_logo.gif' masthead_url = 'http://www.wired.com/images/home/wired_logo.gif'
language = 'en' language = 'en'
publication_type = 'magazine' publication_type = 'magazine'
extra_css = ' body{font-family: Arial,Verdana,sans-serif} .entryDescription li {display: inline; list-style-type: none} ' extra_css = """
index = 'http://www.wired.com/magazine' h1, .entry-header{font-family: brandon-grotesque,anchor-web,Helvetica,Arial,sans-serif}
departments = ['features','start','test','play','found', 'reviews'] .entry-header{display: block;}
.entry-header ul{ list-style-type:disc;}
.author, .entryDate, .entryTime, .entryEdit, .entryCategories{display: inline}
.entry-header li{text-transform: uppercase;}
div#container{font-family: 'Exchange SSm 4r', Georgia, serif}
"""
index = 'http://www.wired.com/magazine/'
preprocess_regexps = [(re.compile(r'<meta name="Title".*<title>', re.DOTALL|re.IGNORECASE),lambda match: '<title>')] preprocess_regexps = [(re.compile(r'<meta name="Title".*<title>', re.DOTALL|re.IGNORECASE),lambda match: '<title>')]
conversion_options = { conversion_options = {
@ -38,56 +42,37 @@ class Wired(BasicNewsRecipe):
} }
keep_only_tags = [dict(name='div', attrs={'class':'post'})] keep_only_tags = [dict(name='div', attrs={'class':'post'})]
remove_tags_after = dict(name='div', attrs={'class':'tweetmeme_button'}) remove_tags_after = dict(name='div', attrs={'id':'container'})
remove_tags = [ remove_tags = [
dict(name=['object','embed','iframe','link']) dict(name=['object','embed','iframe','link','meta','base'])
,dict(name='div', attrs={'class':['podcast_storyboard','tweetmeme_button']}) ,dict(name='div', attrs={'class':['social-top','podcast_storyboard','tweetmeme_button']})
,dict(attrs={'id':'ff_bottom_nav'}) ,dict(attrs={'id':'ff_bottom_nav'})
,dict(name='a',attrs={'href':'http://www.wired.com/app'}) ,dict(name='a',attrs={'href':'http://www.wired.com/app'})
,dict(name='div', attrs={'id':'mag-bug'})
] ]
remove_attributes = ['height','width'] remove_attributes = ['height','width','lang','border','clear']
def parse_index(self): def parse_index(self):
totalfeeds = [] totalfeeds = []
soup = self.index_to_soup(self.index) soup = self.index_to_soup(self.index)
majorf = soup.find('div',attrs={'class':'entry'})
#department feeds if majorf:
depts = soup.find('div',attrs={'id':'department-posts'}) articles = []
checker = []
if depts: for a in majorf.findAll('a', href=True):
for ditem in self.departments: if a['href'].startswith('http://www.wired.com/') and a['href'].endswith('/'):
darticles = [] title = self.tag_to_string(a)
department = depts.find('h3',attrs={'id':'department-'+ditem}) url = a['href']
if department: if title.lower() != 'read more' and url not in checker:
#print '\n###### Found department %s ########'%(ditem) checker.append(url)
articles.append({
el = department.next 'title' :title
while el and (el.__class__.__name__ == 'NavigableString' or el.name != 'h3'): ,'date' :strftime(self.timefmt)
if el.__class__.__name__ != 'NavigableString': ,'url' :a['href']
#print '\t ... element',el.name ,'description':''
if el.name == 'ul': })
for artitem in el.findAll('li'): totalfeeds.append(('Articles', articles))
#print '\t\t ... article',repr(artitem)
feed_link = artitem.find('a')
#print '\t\t\t ... link',repr(feed_link)
if feed_link and feed_link.has_key('href'):
url = self.makeurl(feed_link['href'])
title = self.tag_to_string(feed_link)
date = strftime(self.timefmt)
#print '\t\t ... found "%s" %s'%(title,url)
darticles.append({
'title' :title
,'date' :date
,'url' :url
,'description':''
})
el = None
else:
el = el.next
totalfeeds.append((ditem.capitalize(), darticles))
return totalfeeds return totalfeeds
def get_cover_url(self): def get_cover_url(self):
@ -95,7 +80,7 @@ class Wired(BasicNewsRecipe):
soup = self.index_to_soup(self.index) soup = self.index_to_soup(self.index)
cover_item = soup.find('div',attrs={'class':'spread-image'}) cover_item = soup.find('div',attrs={'class':'spread-image'})
if cover_item: if cover_item:
cover_url = self.makeurl(cover_item.a.img['src']) cover_url = 'http://www.wired.com' + cover_item.a.img['src']
return cover_url return cover_url
def print_version(self, url): def print_version(self, url):
@ -104,10 +89,19 @@ class Wired(BasicNewsRecipe):
def preprocess_html(self, soup): def preprocess_html(self, soup):
for item in soup.findAll(style=True): for item in soup.findAll(style=True):
del item['style'] del item['style']
for item in soup.findAll('a'):
if item.string is not None:
tstr = item.string
item.replaceWith(tstr)
else:
item.name='span'
for atrs in ['href','target','alt','title','name','id']:
if item.has_key(atrs):
del item[atrs]
for item in soup.findAll('img'):
if not item.has_key('alt'):
item['alt'] = 'image'
if item.has_key('data-lazy-src'):
item['src'] = item['data-lazy-src']
del item['data-lazy-src']
return soup return soup
def makeurl(self, addr):
if addr[:4] != 'http' : addr='http://www.wired.com' + addr
while addr[-2:] == '//' : addr=addr[:-1]
return addr

View File

@ -8,17 +8,10 @@ import copy
# http://online.wsj.com/page/us_in_todays_paper.html # http://online.wsj.com/page/us_in_todays_paper.html
def filter_classes(x):
if not x:
return False
bad_classes = {'articleInsetPoll', 'trendingNow', 'sTools', 'printSummary', 'mostPopular', 'relatedCollection'}
classes = frozenset(x.split())
return len(bad_classes.intersection(classes)) > 0
class WallStreetJournal(BasicNewsRecipe): class WallStreetJournal(BasicNewsRecipe):
title = 'The Wall Street Journal' title = 'The Wall Street Journal'
__author__ = 'Kovid Goyal, Sujata Raman, and Joshua Oster-Morris' __author__ = 'Kovid Goyal and Joshua Oster-Morris'
description = 'News and current affairs' description = 'News and current affairs'
needs_subscription = True needs_subscription = True
language = 'en' language = 'en'
@ -26,36 +19,18 @@ class WallStreetJournal(BasicNewsRecipe):
max_articles_per_feed = 1000 max_articles_per_feed = 1000
timefmt = ' [%a, %b %d, %Y]' timefmt = ' [%a, %b %d, %Y]'
no_stylesheets = True no_stylesheets = True
ignore_duplicate_articles = {'url'}
extra_css = '''h1{color:#093D72 ; font-size:large ; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; } keep_only_tags = [
h2{color:#474537; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} dict(name='h1'), dict(name='h2', attrs={'class':['subhead', 'subHed deck']}),
.subhead{color:gray; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} dict(name='span', itemprop='author', rel='author'),
.insettipUnit {color:#666666; font-family:Arial,Sans-serif;font-size:xx-small } dict(name='article', id='articleBody'),
.targetCaption{ font-size:x-small; color:#333333; font-family:Arial,Helvetica,sans-serif} dict(name='div', id='article_story_body'),
.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 = [ remove_tags = [
dict(id=["articleTabs_tab_article", dict(attrs={'class':['insetButton', 'insettipBox']}),
"articleTabs_tab_comments", 'msnLinkback', 'yahooLinkback', dict(name='span', attrs={'data-country-code':True, 'data-ticker-code':True}),
'articleTabs_panel_comments', 'footer', 'emailThisScrim', 'emailConfScrim', 'emailErrorScrim', ]
"articleTabs_tab_interactive", "articleTabs_tab_video",
"articleTabs_tab_map", "articleTabs_tab_slideshow",
"articleTabs_tab_quotes", "articleTabs_tab_document",
"printModeAd", "aFbLikeAuth", "videoModule",
"mostRecommendations", "topDiscussions"]),
{'class':['footer_columns','hidden', 'network','insetCol3wide','interactive','video','slideshow','map','insettip',
'insetClose','more_in', "insetContent", 'articleTools_bottom', 'aTools', "tooltip", "adSummary", "nav-inline"]},
dict(rel='shortcut icon'),
{'class':filter_classes},
]
remove_tags_after = [dict(id="article_story_body"), {'class':"article story"},]
use_javascript_to_login = True use_javascript_to_login = True
@ -72,15 +47,12 @@ class WallStreetJournal(BasicNewsRecipe):
if picdiv is not None: if picdiv is not None:
self.add_toc_thumbnail(article,picdiv['src']) self.add_toc_thumbnail(article,picdiv['src'])
def postprocess_html(self, soup, first): def preprocess_html(self, soup):
for tag in soup.findAll(name=['table', 'tr', 'td']): # Remove thumbnail for zoomable images
tag.name = 'div' for div in soup.findAll('div', attrs={'class':lambda x: x and 'insetZoomTargetBox' in x.split()}):
img = div.find('img')
for tag in soup.findAll('div', dict(id=[ if img is not None:
"articleThumbnail_1", "articleThumbnail_2", "articleThumbnail_3", img.extract()
"articleThumbnail_4", "articleThumbnail_5", "articleThumbnail_6",
"articleThumbnail_7"])):
tag.extract()
return soup return soup

View File

@ -19,35 +19,18 @@ class WallStreetJournal(BasicNewsRecipe):
max_articles_per_feed = 1000 max_articles_per_feed = 1000
timefmt = ' [%a, %b %d, %Y]' timefmt = ' [%a, %b %d, %Y]'
no_stylesheets = True no_stylesheets = True
ignore_duplicate_articles = {'url'}
extra_css = '''h1{color:#093D72 ; font-size:large ; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; } keep_only_tags = [
h2{color:#474537; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} dict(name='h1'), dict(name='h2', attrs={'class':['subhead', 'subHed deck']}),
.subhead{color:gray; font-family:Georgia,"Century Schoolbook","Times New Roman",Times,serif; font-size:small; font-style:italic;} dict(name='span', itemprop='author', rel='author'),
.insettipUnit {color:#666666; font-family:Arial,Sans-serif;font-size:xx-small } dict(name='article', id='articleBody'),
.targetCaption{ font-size:x-small; color:#333333; font-family:Arial,Helvetica,sans-serif} dict(name='div', id='article_story_body'),
.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 = [ remove_tags = [
dict(id=["articleTabs_tab_article", dict(attrs={'class':['insetButton', 'insettipBox']}),
"articleTabs_tab_comments", dict(name='span', attrs={'data-country-code':True, 'data-ticker-code':True}),
"articleTabs_tab_interactive","articleTabs_tab_video","articleTabs_tab_map","articleTabs_tab_slideshow", ]
"articleTabs_tab_quotes"]),
{'class':['footer_columns','network','insetCol3wide','interactive','video','slideshow','map','insettip','insetClose','more_in', "insetContent", 'articleTools_bottom', 'aTools', "tooltip", "adSummary", "nav-inline"]},
dict(name='div', attrs={'data-flash-settings':True}),
{'class':['insetContent embedType-interactive insetCol3wide','insetCol6wide','insettipUnit']},
dict(rel='shortcut icon'),
{'class':lambda x: x and 'sTools' in x},
{'class':lambda x: x and 'printSummary' in x},
{'class':lambda x: x and 'mostPopular' in x},
]
remove_tags_after = [dict(id="article_story_body"), {'class':"article story"},]
def populate_article_metadata(self, article, soup, first): def populate_article_metadata(self, article, soup, first):
if first and hasattr(self, 'add_toc_thumbnail'): if first and hasattr(self, 'add_toc_thumbnail'):
@ -55,12 +38,12 @@ class WallStreetJournal(BasicNewsRecipe):
if picdiv is not None: if picdiv is not None:
self.add_toc_thumbnail(article,picdiv['src']) self.add_toc_thumbnail(article,picdiv['src'])
def postprocess_html(self, soup, first): def preprocess_html(self, soup):
for tag in soup.findAll(name=['table', 'tr', 'td']): # Remove thumbnail for zoomable images
tag.name = 'div' for div in soup.findAll('div', attrs={'class':lambda x: x and 'insetZoomTargetBox' in x.split()}):
img = div.find('img')
for tag in soup.findAll('div', dict(id=["articleThumbnail_1", "articleThumbnail_2", "articleThumbnail_3", "articleThumbnail_4", "articleThumbnail_5", "articleThumbnail_6", "articleThumbnail_7"])): if img is not None:
tag.extract() img.extract()
return soup return soup
@ -69,7 +52,6 @@ class WallStreetJournal(BasicNewsRecipe):
href = 'http://online.wsj.com' + href href = 'http://online.wsj.com' + href
return href return href
def wsj_get_index(self): def wsj_get_index(self):
return self.index_to_soup('http://online.wsj.com/itp') return self.index_to_soup('http://online.wsj.com/itp')
@ -83,7 +65,7 @@ class WallStreetJournal(BasicNewsRecipe):
except: except:
articles = [] articles = []
if articles: if articles:
feeds.append((title, articles)) feeds.append((title, articles))
return feeds return feeds
def parse_index(self): def parse_index(self):
@ -99,16 +81,16 @@ class WallStreetJournal(BasicNewsRecipe):
for a in div.findAll('a', href=lambda x: x and '/itp/' in x): for a in div.findAll('a', href=lambda x: x and '/itp/' in x):
pageone = a['href'].endswith('pageone') pageone = a['href'].endswith('pageone')
if pageone: if pageone:
title = 'Front Section' title = 'Front Section'
url = self.abs_wsj_url(a['href']) url = self.abs_wsj_url(a['href'])
feeds = self.wsj_add_feed(feeds,title,url) feeds = self.wsj_add_feed(feeds,title,url)
title = 'What''s News' title = 'What''s News'
url = url.replace('pageone','whatsnews') url = url.replace('pageone','whatsnews')
feeds = self.wsj_add_feed(feeds,title,url) feeds = self.wsj_add_feed(feeds,title,url)
else: else:
title = self.tag_to_string(a) title = self.tag_to_string(a)
url = self.abs_wsj_url(a['href']) url = self.abs_wsj_url(a['href'])
feeds = self.wsj_add_feed(feeds,title,url) feeds = self.wsj_add_feed(feeds,title,url)
return feeds return feeds
def wsj_find_wn_articles(self, url): def wsj_find_wn_articles(self, url):
@ -117,21 +99,21 @@ class WallStreetJournal(BasicNewsRecipe):
whats_news = soup.find('div', attrs={'class':lambda x: x and 'whatsNews-simple' in x}) whats_news = soup.find('div', attrs={'class':lambda x: x and 'whatsNews-simple' in x})
if whats_news is not None: if whats_news is not None:
for a in whats_news.findAll('a', href=lambda x: x and '/article/' in x): for a in whats_news.findAll('a', href=lambda x: x and '/article/' in x):
container = a.findParent(['p']) container = a.findParent(['p'])
meta = a.find(attrs={'class':'meta_sectionName'}) meta = a.find(attrs={'class':'meta_sectionName'})
if meta is not None: if meta is not None:
meta.extract() meta.extract()
title = self.tag_to_string(a).strip() title = self.tag_to_string(a).strip()
url = a['href'] url = a['href']
desc = '' desc = ''
if container is not None: if container is not None:
desc = self.tag_to_string(container) desc = self.tag_to_string(container)
articles.append({'title':title, 'url':url, articles.append({'title':title, 'url':url,
'description':desc, 'date':''}) 'description':desc, 'date':''})
self.log('\tFound WN article:', title) self.log('\tFound WN article:', title)
return articles return articles
@ -140,18 +122,18 @@ class WallStreetJournal(BasicNewsRecipe):
whats_news = soup.find('div', attrs={'class':lambda x: x and 'whatsNews-simple' in x}) whats_news = soup.find('div', attrs={'class':lambda x: x and 'whatsNews-simple' in x})
if whats_news is not None: if whats_news is not None:
whats_news.extract() whats_news.extract()
articles = [] articles = []
flavorarea = soup.find('div', attrs={'class':lambda x: x and 'ahed' in x}) flavorarea = soup.find('div', attrs={'class':lambda x: x and 'ahed' in x})
if flavorarea is not None: if flavorarea is not None:
flavorstory = flavorarea.find('a', href=lambda x: x and x.startswith('/article')) flavorstory = flavorarea.find('a', href=lambda x: x and x.startswith('/article'))
if flavorstory is not None: if flavorstory is not None:
flavorstory['class'] = 'mjLinkItem' flavorstory['class'] = 'mjLinkItem'
metapage = soup.find('span', attrs={'class':lambda x: x and 'meta_sectionName' in x}) metapage = soup.find('span', attrs={'class':lambda x: x and 'meta_sectionName' in x})
if metapage is not None: if metapage is not None:
flavorstory.append( copy.copy(metapage) ) #metapage should always be A1 because that should be first on the page flavorstory.append(copy.copy(metapage)) # metapage should always be A1 because that should be first on the page
for a in soup.findAll('a', attrs={'class':'mjLinkItem'}, href=True): for a in soup.findAll('a', attrs={'class':'mjLinkItem'}, href=True):
container = a.findParent(['li', 'div']) container = a.findParent(['li', 'div'])
@ -176,5 +158,3 @@ class WallStreetJournal(BasicNewsRecipe):
self.log('\tFound article:', title) self.log('\tFound article:', title)
return articles return articles

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -138,6 +138,8 @@ class UploadInstallers(Command): # {{{
available = set(glob.glob('dist/*')) available = set(glob.glob('dist/*'))
files = {x:installer_description(x) for x in files = {x:installer_description(x) for x in
all_possible.intersection(available)} all_possible.intersection(available)}
for x in files:
os.chmod(x, stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
sizes = {os.path.basename(x):os.path.getsize(x) for x in files} sizes = {os.path.basename(x):os.path.getsize(x) for x in files}
self.record_sizes(sizes) self.record_sizes(sizes)
tdir = mkdtemp() tdir = mkdtemp()

View File

@ -4,7 +4,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
__appname__ = u'calibre' __appname__ = u'calibre'
numeric_version = (1, 6, 0) numeric_version = (1, 7, 0)
__version__ = u'.'.join(map(unicode, numeric_version)) __version__ = u'.'.join(map(unicode, numeric_version))
__author__ = u"Kovid Goyal <kovid@kovidgoyal.net>" __author__ = u"Kovid Goyal <kovid@kovidgoyal.net>"

View File

@ -1008,7 +1008,7 @@ class DB(object):
callback(_('Restoring database from SQL') + '...') callback(_('Restoring database from SQL') + '...')
with closing(Connection(tmpdb)) as conn: with closing(Connection(tmpdb)) as conn:
shell = Shell(db=conn, encoding='utf-8') shell = Shell(db=conn, encoding='utf-8')
shell.process_command('.read ' + fname) shell.process_command('.read ' + fname.replace(os.sep, '/'))
conn.execute('PRAGMA user_version=%d;'%uv) conn.execute('PRAGMA user_version=%d;'%uv)
self.close() self.close()
@ -1406,6 +1406,8 @@ class DB(object):
source_ok = current_path and os.path.exists(spath) source_ok = current_path and os.path.exists(spath)
wam = WindowsAtomicFolderMove(spath) if iswindows and source_ok else None wam = WindowsAtomicFolderMove(spath) if iswindows and source_ok else None
format_map = {}
original_format_map = {}
try: try:
if not os.path.exists(tpath): if not os.path.exists(tpath):
os.makedirs(tpath) os.makedirs(tpath)
@ -1416,22 +1418,34 @@ class DB(object):
windows_atomic_move=wam, use_hardlink=True) windows_atomic_move=wam, use_hardlink=True)
for fmt in formats: for fmt in formats:
dest = os.path.join(tpath, fname+'.'+fmt.lower()) dest = os.path.join(tpath, fname+'.'+fmt.lower())
self.copy_format_to(book_id, fmt, formats_field.format_fname(book_id, fmt), current_path, format_map[fmt] = dest
ofmt_fname = formats_field.format_fname(book_id, fmt)
original_format_map[fmt] = os.path.join(spath, ofmt_fname+'.'+fmt.lower())
self.copy_format_to(book_id, fmt, ofmt_fname, current_path,
dest, windows_atomic_move=wam, use_hardlink=True) dest, windows_atomic_move=wam, use_hardlink=True)
# Update db to reflect new file locations # Update db to reflect new file locations
for fmt in formats: for fmt in formats:
formats_field.table.set_fname(book_id, fmt, fname, self) formats_field.table.set_fname(book_id, fmt, fname, self)
path_field.table.set_path(book_id, path, self) path_field.table.set_path(book_id, path, self)
# Delete not needed directories # Delete not needed files and directories
if source_ok: if source_ok:
if os.path.exists(spath) and not samefile(spath, tpath): if os.path.exists(spath):
if wam is not None: if samefile(spath, tpath):
wam.delete_originals() # The format filenames may have changed while the folder
self.rmtree(spath) # name remains the same
parent = os.path.dirname(spath) for fmt, opath in original_format_map.iteritems():
if len(os.listdir(parent)) == 0: npath = format_map.get(fmt, None)
self.rmtree(parent) if npath and os.path.abspath(npath.lower()) != os.path.abspath(opath.lower()) and samefile(opath, npath):
# opath and npath are different hard links to the same file
os.unlink(opath)
else:
if wam is not None:
wam.delete_originals()
self.rmtree(spath)
parent = os.path.dirname(spath)
if len(os.listdir(parent)) == 0:
self.rmtree(parent)
finally: finally:
if wam is not None: if wam is not None:
wam.close_handles() wam.close_handles()

View File

@ -18,7 +18,7 @@ from calibre.constants import iswindows, preferred_encoding
from calibre.customize.ui import run_plugins_on_import, run_plugins_on_postimport from calibre.customize.ui import run_plugins_on_import, run_plugins_on_postimport
from calibre.db import SPOOL_SIZE, _get_next_series_num_for_list from calibre.db import SPOOL_SIZE, _get_next_series_num_for_list
from calibre.db.categories import get_categories from calibre.db.categories import get_categories
from calibre.db.locking import create_locks, DowngradeLockError from calibre.db.locking import create_locks, DowngradeLockError, SafeReadLock
from calibre.db.errors import NoSuchFormat from calibre.db.errors import NoSuchFormat
from calibre.db.fields import create_field, IDENTITY, InvalidLinkTable from calibre.db.fields import create_field, IDENTITY, InvalidLinkTable
from calibre.db.search import Search from calibre.db.search import Search
@ -57,11 +57,8 @@ def wrap_simple(lock, func):
return func(*args, **kwargs) return func(*args, **kwargs)
except DowngradeLockError: except DowngradeLockError:
# We already have an exclusive lock, no need to acquire a shared # We already have an exclusive lock, no need to acquire a shared
# lock. This can happen when updating the search cache in the # lock. See the safe_read_lock properties' documentation for why
# presence of composite columns. Updating the search cache holds an # this is necessary.
# exclusive lock, but searching a composite column involves
# reading field values via ProxyMetadata which tries to get a
# shared lock.
return func(*args, **kwargs) return func(*args, **kwargs)
return call_func_with_lock return call_func_with_lock
@ -118,6 +115,22 @@ class Cache(object):
self._search_api = Search(self, 'saved_searches', self.field_metadata.get_search_terms()) self._search_api = Search(self, 'saved_searches', self.field_metadata.get_search_terms())
self.initialize_dynamic() self.initialize_dynamic()
@property
def safe_read_lock(self):
''' A safe read lock is a lock that does nothing if the thread already
has a write lock, otherwise it acquires a read lock. This is necessary
to prevent DowngradeLockErrors, which can happen when updating the
search cache in the presence of composite columns. Updating the search
cache holds an exclusive lock, but searching a composite column
involves reading field values via ProxyMetadata which tries to get a
shared lock. There may be other scenarios that trigger this as well.
This property returns a new lock object on every access. This lock
object is not recursive (for performance) and must only be used in a
with statement as ``with cache.safe_read_lock:`` otherwise bad things
will happen.'''
return SafeReadLock(self.read_lock)
@write_api @write_api
def initialize_dynamic(self): def initialize_dynamic(self):
# Reconstruct the user categories, putting them into field_metadata # Reconstruct the user categories, putting them into field_metadata
@ -501,7 +514,7 @@ class Cache(object):
x = self.format_metadata_cache[book_id].get(fmt, None) x = self.format_metadata_cache[book_id].get(fmt, None)
if x is not None: if x is not None:
return x return x
with self.read_lock: with self.safe_read_lock:
try: try:
name = self.fields['formats'].format_fname(book_id, fmt) name = self.fields['formats'].format_fname(book_id, fmt)
path = self._field_for('path', book_id).replace('/', os.sep) path = self._field_for('path', book_id).replace('/', os.sep)
@ -545,7 +558,7 @@ class Cache(object):
cover_as_data is True then as mi.cover_data. cover_as_data is True then as mi.cover_data.
''' '''
with self.read_lock: with self.safe_read_lock:
mi = self._get_metadata(book_id, get_user_categories=get_user_categories) mi = self._get_metadata(book_id, get_user_categories=get_user_categories)
if get_cover: if get_cover:
@ -751,7 +764,7 @@ class Cache(object):
ext = ('.'+fmt.lower()) if fmt else '' ext = ('.'+fmt.lower()) if fmt else ''
if as_path: if as_path:
if preserve_filename: if preserve_filename:
with self.read_lock: with self.safe_read_lock:
try: try:
fname = self.fields['formats'].format_fname(book_id, fmt) fname = self.fields['formats'].format_fname(book_id, fmt)
except: except:
@ -777,7 +790,7 @@ class Cache(object):
return None return None
ret = pt.name ret = pt.name
elif as_file: elif as_file:
with self.read_lock: with self.safe_read_lock:
try: try:
fname = self.fields['formats'].format_fname(book_id, fmt) fname = self.fields['formats'].format_fname(book_id, fmt)
except: except:
@ -878,7 +891,7 @@ class Cache(object):
@api @api
def get_categories(self, sort='name', book_ids=None, icon_map=None, already_fixed=None): def get_categories(self, sort='name', book_ids=None, icon_map=None, already_fixed=None):
try: try:
with self.read_lock: with self.safe_read_lock:
return get_categories(self, sort=sort, book_ids=book_ids, icon_map=icon_map) return get_categories(self, sort=sort, book_ids=book_ids, icon_map=icon_map)
except InvalidLinkTable as err: except InvalidLinkTable as err:
bad_field = err.field_name bad_field = err.field_name
@ -1397,6 +1410,10 @@ class Cache(object):
except: except:
path = None path = None
path_map[book_id] = path path_map[book_id] = path
if iswindows:
paths = (x.replace(os.sep, '/') for x in path_map.itervalues() if x)
self.backend.windows_check_if_files_in_use(paths)
self.backend.remove_books(path_map, permanent=permanent) self.backend.remove_books(path_map, permanent=permanent)
for field in self.fields.itervalues(): for field in self.fields.itervalues():
try: try:

View File

@ -6,10 +6,11 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import os, tempfile, shutil, errno, time import os, tempfile, shutil, errno, time, atexit
from threading import Thread from threading import Thread
from Queue import Queue from Queue import Queue
from calibre.ptempfile import remove_dir
from calibre.utils.recycle_bin import delete_tree, delete_file from calibre.utils.recycle_bin import delete_tree, delete_file
class DeleteService(Thread): class DeleteService(Thread):
@ -40,39 +41,64 @@ class DeleteService(Thread):
base_path = os.path.dirname(library_path) base_path = os.path.dirname(library_path)
base = os.path.basename(library_path) base = os.path.basename(library_path)
try: try:
return tempfile.mkdtemp(prefix=base+' deleted ', dir=base_path) ans = tempfile.mkdtemp(prefix=base+' deleted ', dir=base_path)
except OSError: except OSError:
return tempfile.mkdtemp(prefix=base+' deleted ') ans = tempfile.mkdtemp(prefix=base+' deleted ')
atexit.register(remove_dir, ans)
return ans
def remove_dir_if_empty(self, path):
try:
os.rmdir(path)
except OSError as e:
if e.errno == errno.ENOTEMPTY or len(os.listdir(path)) > 0:
# Some linux systems appear to raise an EPERM instead of an
# ENOTEMPTY, see https://bugs.launchpad.net/bugs/1240797
return
raise
def delete_books(self, paths, library_path): def delete_books(self, paths, library_path):
tdir = self.create_staging(library_path) tdir = self.create_staging(library_path)
self.queue_paths(tdir, paths, delete_empty_parent=True) self.queue_paths(tdir, paths, delete_empty_parent=True)
def queue_paths(self, tdir, paths, delete_empty_parent=True): def queue_paths(self, tdir, paths, delete_empty_parent=True):
queued = False try:
self._queue_paths(tdir, paths, delete_empty_parent=delete_empty_parent)
except:
if os.path.exists(tdir):
shutil.rmtree(tdir, ignore_errors=True)
raise
def _queue_paths(self, tdir, paths, delete_empty_parent=True):
requests = []
for path in paths: for path in paths:
if os.path.exists(path): if os.path.exists(path):
basename = os.path.basename(path)
c = 0
while True:
dest = os.path.join(tdir, basename)
if not os.path.exists(dest):
break
c += 1
basename = '%d - %s' % (c, os.path.basename(path))
try: try:
shutil.move(path, tdir) shutil.move(path, dest)
except EnvironmentError: except EnvironmentError:
if os.path.isdir(path):
# shutil.move may have partially copied the directory,
# so the subsequent call to move() will fail as the
# destination directory already exists
raise
# Wait a little in case something has locked a file # Wait a little in case something has locked a file
time.sleep(1) time.sleep(1)
shutil.move(path, tdir) shutil.move(path, dest)
if delete_empty_parent: if delete_empty_parent:
parent = os.path.dirname(path) self.remove_dir_if_empty(os.path.dirname(path))
try: requests.append(dest)
os.rmdir(parent) if not requests:
except OSError as e: self.remove_dir_if_empty(tdir)
if e.errno != errno.ENOTEMPTY: else:
raise self.requests.put(tdir)
self.requests.put(os.path.join(tdir, os.path.basename(path)))
queued = True
if not queued:
try:
os.rmdir(tdir)
except OSError as e:
if e.errno != errno.ENOTEMPTY:
raise
def delete_files(self, paths, library_path): def delete_files(self, paths, library_path):
tdir = self.create_staging(library_path) tdir = self.create_staging(library_path)
@ -96,16 +122,17 @@ class DeleteService(Thread):
'Blocks until all pending deletes have completed' 'Blocks until all pending deletes have completed'
self.requests.join() self.requests.join()
def do_delete(self, x): def do_delete(self, tdir):
if os.path.isdir(x): if os.path.exists(tdir):
delete_tree(x) try:
else: for x in os.listdir(tdir):
delete_file(x) x = os.path.join(tdir, x)
try: if os.path.isdir(x):
os.rmdir(os.path.dirname(x)) delete_tree(x)
except OSError as e: else:
if e.errno != errno.ENOTEMPTY: delete_file(x)
raise finally:
shutil.rmtree(tdir)
__ds = None __ds = None
def delete_service(): def delete_service():

View File

@ -132,7 +132,7 @@ def adata_getter(field):
author_ids, adata = cache['adata'] author_ids, adata = cache['adata']
except KeyError: except KeyError:
db = dbref() db = dbref()
with db.read_lock: with db.safe_read_lock:
author_ids = db._field_ids_for('authors', book_id) author_ids = db._field_ids_for('authors', book_id)
adata = db._author_data(author_ids) adata = db._author_data(author_ids)
cache['adata'] = (author_ids, adata) cache['adata'] = (author_ids, adata)

View File

@ -154,7 +154,7 @@ class LibraryDatabase(object):
return tuple(self.new_api.all_book_ids()) return tuple(self.new_api.all_book_ids())
def is_empty(self): def is_empty(self):
with self.new_api.read_lock: with self.new_api.safe_read_lock:
return not bool(self.new_api.fields['title'].table.book_col_map) return not bool(self.new_api.fields['title'].table.book_col_map)
def get_usage_count_by_id(self, field): def get_usage_count_by_id(self, field):
@ -363,7 +363,7 @@ class LibraryDatabase(object):
def authors_with_sort_strings(self, index, index_is_id=False): def authors_with_sort_strings(self, index, index_is_id=False):
book_id = index if index_is_id else self.id(index) book_id = index if index_is_id else self.id(index)
with self.new_api.read_lock: with self.new_api.safe_read_lock:
authors = self.new_api._field_ids_for('authors', book_id) authors = self.new_api._field_ids_for('authors', book_id)
adata = self.new_api._author_data(authors) adata = self.new_api._author_data(authors)
return [(aid, adata[aid]['name'], adata[aid]['sort'], adata[aid]['link']) for aid in authors] return [(aid, adata[aid]['name'], adata[aid]['sort'], adata[aid]['link']) for aid in authors]
@ -379,7 +379,7 @@ class LibraryDatabase(object):
self.notify('metadata', list(changed_books)) self.notify('metadata', list(changed_books))
def book_on_device(self, book_id): def book_on_device(self, book_id):
with self.new_api.read_lock: with self.new_api.safe_read_lock:
return self.new_api.fields['ondevice'].book_on_device(book_id) return self.new_api.fields['ondevice'].book_on_device(book_id)
def book_on_device_string(self, book_id): def book_on_device_string(self, book_id):
@ -393,7 +393,7 @@ class LibraryDatabase(object):
return self.new_api.fields['ondevice'].book_on_device_func return self.new_api.fields['ondevice'].book_on_device_func
def books_in_series(self, series_id): def books_in_series(self, series_id):
with self.new_api.read_lock: with self.new_api.safe_read_lock:
book_ids = self.new_api._books_for_field('series', series_id) book_ids = self.new_api._books_for_field('series', series_id)
ff = self.new_api._field_for ff = self.new_api._field_for
return sorted(book_ids, key=lambda x:ff('series_index', x)) return sorted(book_ids, key=lambda x:ff('series_index', x))

View File

@ -13,6 +13,8 @@ from calibre.utils.config_base import tweaks
class LockingError(RuntimeError): class LockingError(RuntimeError):
is_locking_error = True
def __init__(self, msg, extra=None): def __init__(self, msg, extra=None):
RuntimeError.__init__(self, msg) RuntimeError.__init__(self, msg)
self.locking_debug_msg = extra self.locking_debug_msg = extra
@ -211,16 +213,15 @@ class RWLockWrapper(object):
self._shlock = shlock self._shlock = shlock
self._is_shared = is_shared self._is_shared = is_shared
def __enter__(self): def acquire(self):
self._shlock.acquire(shared=self._is_shared) self._shlock.acquire(shared=self._is_shared)
return self
def __exit__(self, *args): def release(self, *args):
self.release()
def release(self):
self._shlock.release() self._shlock.release()
__enter__ = acquire
__exit__ = release
def owns_lock(self): def owns_lock(self):
return self._shlock.owns_lock() return self._shlock.owns_lock()
@ -229,11 +230,11 @@ class DebugRWLockWrapper(RWLockWrapper):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
RWLockWrapper.__init__(self, *args, **kwargs) RWLockWrapper.__init__(self, *args, **kwargs)
def __enter__(self): def acquire(self):
print ('#' * 120, file=sys.stderr) print ('#' * 120, file=sys.stderr)
print ('acquire called: thread id:', current_thread(), 'shared:', self._is_shared, file=sys.stderr) print ('acquire called: thread id:', current_thread(), 'shared:', self._is_shared, file=sys.stderr)
traceback.print_stack() traceback.print_stack()
RWLockWrapper.__enter__(self) RWLockWrapper.acquire(self)
print ('acquire done: thread id:', current_thread(), file=sys.stderr) print ('acquire done: thread id:', current_thread(), file=sys.stderr)
print ('_' * 120, file=sys.stderr) print ('_' * 120, file=sys.stderr)
@ -245,4 +246,28 @@ class DebugRWLockWrapper(RWLockWrapper):
print ('release done: thread id:', current_thread(), 'is_shared:', self._shlock.is_shared, 'is_exclusive:', self._shlock.is_exclusive, file=sys.stderr) print ('release done: thread id:', current_thread(), 'is_shared:', self._shlock.is_shared, 'is_exclusive:', self._shlock.is_exclusive, file=sys.stderr)
print ('_' * 120, file=sys.stderr) print ('_' * 120, file=sys.stderr)
__enter__ = acquire
__exit__ = release
class SafeReadLock(object):
def __init__(self, read_lock):
self.read_lock = read_lock
self.acquired = False
def acquire(self):
try:
self.read_lock.acquire()
except DowngradeLockError:
pass
else:
self.acquired = True
return self
def release(self, *args):
if self.acquired:
self.read_lock.release()
self.acquired = False
__enter__ = acquire
__exit__ = release

View File

@ -76,9 +76,30 @@ class FilesystemTest(BaseTest):
f = open(fpath, 'rb') f = open(fpath, 'rb')
with self.assertRaises(IOError): with self.assertRaises(IOError):
cache.set_field('title', {1:'Moved'}) cache.set_field('title', {1:'Moved'})
with self.assertRaises(IOError):
cache.remove_books({1})
f.close() f.close()
self.assertNotEqual(cache.field_for('title', 1), 'Moved', 'Title was changed despite file lock') self.assertNotEqual(cache.field_for('title', 1), 'Moved', 'Title was changed despite file lock')
# Test on folder with hardlinks
from calibre.ptempfile import TemporaryDirectory
from calibre.utils.filenames import hardlink_file, WindowsAtomicFolderMove
raw = b'xxx'
with TemporaryDirectory() as tdir1, TemporaryDirectory() as tdir2:
a, b = os.path.join(tdir1, 'a'), os.path.join(tdir1, 'b')
a = os.path.join(tdir1, 'a')
with open(a, 'wb') as f:
f.write(raw)
hardlink_file(a, b)
wam = WindowsAtomicFolderMove(tdir1)
wam.copy_path_to(a, os.path.join(tdir2, 'a'))
wam.copy_path_to(b, os.path.join(tdir2, 'b'))
wam.delete_originals()
self.assertEqual([], os.listdir(tdir1))
self.assertEqual({'a', 'b'}, set(os.listdir(tdir2)))
self.assertEqual(raw, open(os.path.join(tdir2, 'a'), 'rb').read())
self.assertEqual(raw, open(os.path.join(tdir2, 'b'), 'rb').read())
def test_library_move(self): def test_library_move(self):
' Test moving of library ' ' Test moving of library '
from calibre.ptempfile import TemporaryDirectory from calibre.ptempfile import TemporaryDirectory
@ -106,3 +127,15 @@ class FilesystemTest(BaseTest):
self.assertLessEqual(len(cache.field_for('path', 1)), cache.backend.PATH_LIMIT * 2) self.assertLessEqual(len(cache.field_for('path', 1)), cache.backend.PATH_LIMIT * 2)
fpath = cache.format_abspath(1, cache.formats(1)[0]) fpath = cache.format_abspath(1, cache.formats(1)[0])
self.assertLessEqual(len(fpath), len(cache.backend.library_path) + cache.backend.PATH_LIMIT * 4) self.assertLessEqual(len(fpath), len(cache.backend.library_path) + cache.backend.PATH_LIMIT * 4)
def test_fname_change(self):
' Test the changing of the filename but not the folder name '
cache = self.init_cache()
title = 'a'*30 + 'bbb'
cache.backend.PATH_LIMIT = 100
cache.set_field('title', {3:title})
cache.add_format(3, 'TXT', BytesIO(b'xxx'))
cache.backend.PATH_LIMIT = 40
cache.set_field('title', {3:title})
fpath = cache.format_abspath(3, 'TXT')
self.assertEqual(sorted([os.path.basename(fpath)]), sorted(os.listdir(os.path.dirname(fpath))))

View File

@ -205,7 +205,7 @@ class View(object):
def get_series_sort(self, idx, index_is_id=True, default_value=''): def get_series_sort(self, idx, index_is_id=True, default_value=''):
book_id = idx if index_is_id else self.index_to_id(idx) book_id = idx if index_is_id else self.index_to_id(idx)
with self.cache.read_lock: with self.cache.safe_read_lock:
lang_map = self.cache.fields['languages'].book_value_map lang_map = self.cache.fields['languages'].book_value_map
lang = lang_map.get(book_id, None) or None lang = lang_map.get(book_id, None) or None
if lang: if lang:
@ -223,7 +223,7 @@ class View(object):
def get_author_data(self, idx, index_is_id=True, default_value=None): def get_author_data(self, idx, index_is_id=True, default_value=None):
id_ = idx if index_is_id else self.index_to_id(idx) id_ = idx if index_is_id else self.index_to_id(idx)
with self.cache.read_lock: with self.cache.safe_read_lock:
ids = self.cache._field_ids_for('authors', id_) ids = self.cache._field_ids_for('authors', id_)
adata = self.cache._author_data(ids) adata = self.cache._author_data(ids)
ans = [':::'.join((adata[aid]['name'], adata[aid]['sort'], adata[aid]['link'])) for aid in ids if aid in adata] ans = [':::'.join((adata[aid]['name'], adata[aid]['sort'], adata[aid]['link'])) for aid in ids if aid in adata]

View File

@ -59,8 +59,24 @@ class TOLINO(EB600):
VENDOR_NAME = ['DEUTSCHE'] VENDOR_NAME = ['DEUTSCHE']
WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = ['_TELEKOMTOLINO'] WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = ['_TELEKOMTOLINO']
EXTRA_CUSTOMIZATION_MESSAGE = [
_('Swap main and card A') +
':::' +
_('Check this box if the device\'s main memory is being seen as card a and the card '
'is being seen as main memory. Some Tolino devices may need this option.'),
]
EXTRA_CUSTOMIZATION_DEFAULT = [
True,
]
OPT_SWAP_MEMORY = 0
# There are apparently two versions of this device, one with swapped
# drives and one without, see https://bugs.launchpad.net/bugs/1240504
def linux_swap_drives(self, drives): def linux_swap_drives(self, drives):
if len(drives) < 2 or not drives[1] or not drives[2]: e = self.settings().extra_customization
if len(drives) < 2 or not drives[0] or not drives[1] or not e[self.OPT_SWAP_MEMORY]:
return drives return drives
drives = list(drives) drives = list(drives)
t = drives[0] t = drives[0]
@ -69,7 +85,8 @@ class TOLINO(EB600):
return tuple(drives) return tuple(drives)
def windows_sort_drives(self, drives): def windows_sort_drives(self, drives):
if len(drives) < 2: e = self.settings().extra_customization
if len(drives) < 2 or not e[self.OPT_SWAP_MEMORY]:
return drives return drives
main = drives.get('main', None) main = drives.get('main', None)
carda = drives.get('carda', None) carda = drives.get('carda', None)

View File

@ -64,7 +64,7 @@ class HANLINV3(USBMS):
return names return names
def linux_swap_drives(self, drives): def linux_swap_drives(self, drives):
if len(drives) < 2 or not drives[1] or not drives[2]: return drives if len(drives) < 2 or not drives[0] or not drives[1]: return drives
drives = list(drives) drives = list(drives)
t = drives[0] t = drives[0]
drives[0] = drives[1] drives[0] = drives[1]

View File

@ -461,7 +461,7 @@ class WAYTEQ(USBMS):
def linux_swap_drives(self, drives): def linux_swap_drives(self, drives):
# See https://bugs.launchpad.net/bugs/1151901 # See https://bugs.launchpad.net/bugs/1151901
if len(drives) < 2 or not drives[1] or not drives[2]: if len(drives) < 2 or not drives[0] or not drives[1]:
return drives return drives
drives = list(drives) drives = list(drives)
t = drives[0] t = drives[0]

View File

@ -120,7 +120,8 @@ class USER_DEFINED(USBMS):
self.plugin_needs_delayed_initialization = False self.plugin_needs_delayed_initialization = False
def windows_sort_drives(self, drives): def windows_sort_drives(self, drives):
if len(drives) < 2: return drives if len(drives) < 2:
return drives
e = self.settings().extra_customization e = self.settings().extra_customization
if not e[self.OPT_SWAP_MAIN_AND_CARD]: if not e[self.OPT_SWAP_MAIN_AND_CARD]:
return drives return drives
@ -132,7 +133,8 @@ class USER_DEFINED(USBMS):
return drives return drives
def linux_swap_drives(self, drives): def linux_swap_drives(self, drives):
if len(drives) < 2 or not drives[1] or not drives[2]: return drives if len(drives) < 2 or not drives[0] or not drives[1]:
return drives
e = self.settings().extra_customization e = self.settings().extra_customization
if not e[self.OPT_SWAP_MAIN_AND_CARD]: if not e[self.OPT_SWAP_MAIN_AND_CARD]:
return drives return drives
@ -143,7 +145,8 @@ class USER_DEFINED(USBMS):
return tuple(drives) return tuple(drives)
def osx_sort_names(self, names): def osx_sort_names(self, names):
if len(names) < 2: return names if len(names) < 2:
return names
e = self.settings().extra_customization e = self.settings().extra_customization
if not e[self.OPT_SWAP_MAIN_AND_CARD]: if not e[self.OPT_SWAP_MAIN_AND_CARD]:
return names return names

View File

@ -17,11 +17,13 @@ from lxml import html
from urlparse import urldefrag from urlparse import urldefrag
from calibre import prepare_string_for_xml from calibre import prepare_string_for_xml
from calibre.ebooks.oeb.base import XHTML, XHTML_NS, barename, namespace,\ from calibre.ebooks.oeb.base import (
OEB_IMAGES, XLINK, rewrite_links, urlnormalize XHTML, XHTML_NS, barename, namespace, OEB_IMAGES, XLINK, rewrite_links, urlnormalize)
from calibre.ebooks.oeb.stylizer import Stylizer from calibre.ebooks.oeb.stylizer import Stylizer
from calibre.utils.logging import default_log from calibre.utils.logging import default_log
SELF_CLOSING_TAGS = {'area', 'base', 'basefont', 'br', 'hr', 'input', 'img', 'link', 'meta'}
class OEB2HTML(object): class OEB2HTML(object):
''' '''
Base class. All subclasses should implement dump_text to actually transform Base class. All subclasses should implement dump_text to actually transform
@ -49,7 +51,7 @@ class OEB2HTML(object):
return self.mlize_spine(oeb_book) return self.mlize_spine(oeb_book)
def mlize_spine(self, oeb_book): def mlize_spine(self, oeb_book):
output = [u'<html><body><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" /></head>'] output = [u'<html><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" /></head><body>']
for item in oeb_book.spine: for item in oeb_book.spine:
self.log.debug('Converting %s to HTML...' % item.href) self.log.debug('Converting %s to HTML...' % item.href)
self.rewrite_ids(item.data, item) self.rewrite_ids(item.data, item)
@ -183,7 +185,11 @@ class OEB2HTMLNoCSSizer(OEB2HTML):
at += ' %s="%s"' % (k, prepare_string_for_xml(v, attribute=True)) at += ' %s="%s"' % (k, prepare_string_for_xml(v, attribute=True))
# Write the tag. # Write the tag.
text.append('<%s%s>' % (tag, at)) text.append('<%s%s' % (tag, at))
if tag in SELF_CLOSING_TAGS:
text.append(' />')
else:
text.append('>')
# Turn styles into tags. # Turn styles into tags.
if style['font-weight'] in ('bold', 'bolder'): if style['font-weight'] in ('bold', 'bolder'):
@ -210,7 +216,8 @@ class OEB2HTMLNoCSSizer(OEB2HTML):
# Close all open tags. # Close all open tags.
tags.reverse() tags.reverse()
for t in tags: for t in tags:
text.append('</%s>' % t) if t not in SELF_CLOSING_TAGS:
text.append('</%s>' % t)
# Add the text that is outside of the tag. # Add the text that is outside of the tag.
if hasattr(elem, 'tail') and elem.tail: if hasattr(elem, 'tail') and elem.tail:
@ -267,10 +274,14 @@ class OEB2HTMLInlineCSSizer(OEB2HTML):
# Turn style into strings for putting in the tag. # Turn style into strings for putting in the tag.
style_t = '' style_t = ''
if style_a: if style_a:
style_t = ' style="%s"' % style_a style_t = ' style="%s"' % style_a.replace('"', "'")
# Write the tag. # Write the tag.
text.append('<%s%s%s>' % (tag, at, style_t)) text.append('<%s%s%s' % (tag, at, style_t))
if tag in SELF_CLOSING_TAGS:
text.append(' />')
else:
text.append('>')
# Process tags that contain text. # Process tags that contain text.
if hasattr(elem, 'text') and elem.text: if hasattr(elem, 'text') and elem.text:
@ -283,7 +294,8 @@ class OEB2HTMLInlineCSSizer(OEB2HTML):
# Close all open tags. # Close all open tags.
tags.reverse() tags.reverse()
for t in tags: for t in tags:
text.append('</%s>' % t) if t not in SELF_CLOSING_TAGS:
text.append('</%s>' % t)
# Add the text that is outside of the tag. # Add the text that is outside of the tag.
if hasattr(elem, 'tail') and elem.tail: if hasattr(elem, 'tail') and elem.tail:
@ -312,7 +324,8 @@ class OEB2HTMLClassCSSizer(OEB2HTML):
css = u'<link href="style.css" rel="stylesheet" type="text/css" />' css = u'<link href="style.css" rel="stylesheet" type="text/css" />'
else: else:
css = u'<style type="text/css">' + self.get_css(oeb_book) + u'</style>' css = u'<style type="text/css">' + self.get_css(oeb_book) + u'</style>'
output = [u'<html><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'] + [css] + [u'</head><body>'] + output + [u'</body></html>'] output = [u'<html><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />'] + \
[css] + [u'</head><body>'] + output + [u'</body></html>']
return ''.join(output) return ''.join(output)
def dump_text(self, elem, stylizer, page): def dump_text(self, elem, stylizer, page):
@ -350,7 +363,11 @@ class OEB2HTMLClassCSSizer(OEB2HTML):
at += ' %s="%s"' % (k, prepare_string_for_xml(v, attribute=True)) at += ' %s="%s"' % (k, prepare_string_for_xml(v, attribute=True))
# Write the tag. # Write the tag.
text.append('<%s%s>' % (tag, at)) text.append('<%s%s' % (tag, at))
if tag in SELF_CLOSING_TAGS:
text.append(' />')
else:
text.append('>')
# Process tags that contain text. # Process tags that contain text.
if hasattr(elem, 'text') and elem.text: if hasattr(elem, 'text') and elem.text:
@ -363,7 +380,8 @@ class OEB2HTMLClassCSSizer(OEB2HTML):
# Close all open tags. # Close all open tags.
tags.reverse() tags.reverse()
for t in tags: for t in tags:
text.append('</%s>' % t) if t not in SELF_CLOSING_TAGS:
text.append('</%s>' % t)
# Add the text that is outside of the tag. # Add the text that is outside of the tag.
if hasattr(elem, 'tail') and elem.tail: if hasattr(elem, 'tail') and elem.tail:

View File

@ -745,6 +745,10 @@ class EpubContainer(Container):
f.write(guess_type('a.epub')) f.write(guess_type('a.epub'))
zip_rebuilder(self.root, outpath) zip_rebuilder(self.root, outpath)
@property
def path_to_ebook(self):
return self.pathtoepub
# }}} # }}}
# AZW3 {{{ # AZW3 {{{
@ -839,6 +843,11 @@ class AZW3Container(Container):
oeb = create_oebbook(default_log, opf, plumber.opts) oeb = create_oebbook(default_log, opf, plumber.opts)
set_cover(oeb) set_cover(oeb)
outp.convert(oeb, outpath, inp, plumber.opts, default_log) outp.convert(oeb, outpath, inp, plumber.opts, default_log)
@property
def path_to_ebook(self):
return self.pathtoepub
# }}} # }}}
def get_container(path, log=None, tdir=None): def get_container(path, log=None, tdir=None):

View File

@ -410,7 +410,10 @@ class CSSFlattener(object):
if cssdict: if cssdict:
for x in self.filter_css: for x in self.filter_css:
cssdict.pop(x, None) popval = cssdict.pop(x, None)
if (self.body_font_family and popval and x == 'font-family' and
popval.partition(',')[0][1:-1] == self.body_font_family.partition(',')[0][1:-1]):
cssdict[x] = popval
if cssdict: if cssdict:
if self.lineh and self.fbase and tag != 'body': if self.lineh and self.fbase and tag != 'body':

View File

@ -118,6 +118,7 @@ defs['cover_grid_color'] = (80, 80, 80)
defs['cover_grid_cache_size'] = 100 defs['cover_grid_cache_size'] = 100
defs['cover_grid_disk_cache_size'] = 2500 defs['cover_grid_disk_cache_size'] = 2500
defs['cover_grid_show_title'] = False defs['cover_grid_show_title'] = False
defs['cover_grid_texture'] = None
defs['show_vl_tabs'] = False defs['show_vl_tabs'] = False
del defs del defs
# }}} # }}}
@ -753,11 +754,13 @@ class ResizableDialog(QDialog):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
QDialog.__init__(self, *args) QDialog.__init__(self, *args)
self.setupUi(self) self.setupUi(self)
nh, nw = min_available_height()-25, available_width()-10 desktop = QCoreApplication.instance().desktop()
geom = desktop.availableGeometry(self)
nh, nw = geom.height()-25, geom.width()-10
if nh < 0: if nh < 0:
nh = 800 nh = max(800, self.height())
if nw < 0: if nw < 0:
nw = 600 nw = max(600, self.height())
nh = min(self.height(), nh) nh = min(self.height(), nh)
nw = min(self.width(), nw) nw = min(self.width(), nw)
self.resize(nw, nh) self.resize(nw, nh)

View File

@ -5,6 +5,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import errno
from functools import partial from functools import partial
from collections import Counter from collections import Counter
@ -360,7 +361,17 @@ class DeleteAction(InterfaceAction):
return return
next_id = view.next_id next_id = view.next_id
if len(to_delete_ids) < 5: if len(to_delete_ids) < 5:
view.model().delete_books_by_id(to_delete_ids) try:
view.model().delete_books_by_id(to_delete_ids)
except IOError as err:
if err.errno == errno.EACCES:
import traceback
fname = getattr(err, 'filename', 'file') or 'file'
return error_dialog(self.gui, _('Permission denied'),
_('Could not access %s. Is it being used by another'
' program? Click "Show details" for more information.')%fname, det_msg=traceback.format_exc(),
show=True)
self.library_ids_deleted2(to_delete_ids, next_id=next_id) self.library_ids_deleted2(to_delete_ids, next_id=next_id)
else: else:
self.__md = MultiDeleter(self.gui, to_delete_ids, self.__md = MultiDeleter(self.gui, to_delete_ids,

View File

@ -20,7 +20,7 @@ class MarkBooksAction(InterfaceAction):
action_type = 'current' action_type = 'current'
action_add_menu = True action_add_menu = True
dont_add_to = frozenset([ dont_add_to = frozenset([
'toolbar-device', 'context-menu-device', 'menubar-device', 'context-menu-cover-browser']) 'context-menu-device', 'menubar-device', 'context-menu-cover-browser'])
action_menu_clone_qaction = _('Toggle mark for selected books') action_menu_clone_qaction = _('Toggle mark for selected books')
accepts_drops = True accepts_drops = True

View File

@ -15,7 +15,11 @@
</property> </property>
<layout class="QGridLayout" name="gridLayout"> <layout class="QGridLayout" name="gridLayout">
<item row="7" column="1"> <item row="7" column="1">
<widget class="QSpinBox" name="opt_toc_threshold"/> <widget class="QSpinBox" name="opt_toc_threshold">
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item> </item>
<item row="1" column="0" colspan="2"> <item row="1" column="0" colspan="2">
<widget class="QCheckBox" name="opt_use_auto_toc"> <widget class="QCheckBox" name="opt_use_auto_toc">

View File

@ -234,9 +234,12 @@ class Comments(Base):
self.widgets = [self._box] self.widgets = [self._box]
def setter(self, val): def setter(self, val):
if val is None: if not val or not val.strip():
val = '' val = ''
self._tb.html = comments_to_html(val) else:
val = comments_to_html(val)
self._tb.html = val
self._tb.wyswyg_dirtied()
def getter(self): def getter(self):
val = unicode(self._tb.html).strip() val = unicode(self._tb.html).strip()

View File

@ -18,8 +18,8 @@ class CommentsDialog(QDialog, Ui_CommentsDialog):
self.setWindowFlags(self.windowFlags()&(~Qt.WindowContextHelpButtonHint)) self.setWindowFlags(self.windowFlags()&(~Qt.WindowContextHelpButtonHint))
self.setWindowIcon(icon) self.setWindowIcon(icon)
if text is not None: self.textbox.html = comments_to_html(text) if text else ''
self.textbox.html = comments_to_html(text) self.textbox.wyswyg_dirtied()
# self.textbox.setTabChangesFocus(True) # self.textbox.setTabChangesFocus(True)
self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK')) self.buttonBox.button(QDialogButtonBox.Ok).setText(_('&OK'))
self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel')) self.buttonBox.button(QDialogButtonBox.Cancel).setText(_('&Cancel'))

View File

@ -15,7 +15,7 @@ from calibre import browser, get_download_filename
from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks import BOOK_EXTENSIONS
from calibre.gui2 import Dispatcher from calibre.gui2 import Dispatcher
from calibre.gui2.threaded_jobs import ThreadedJob from calibre.gui2.threaded_jobs import ThreadedJob
from calibre.ptempfile import PersistentTemporaryFile from calibre.ptempfile import PersistentTemporaryDirectory
from calibre.utils.filenames import ascii_filename from calibre.utils.filenames import ascii_filename
class EbookDownload(object): class EbookDownload(object):
@ -56,7 +56,8 @@ class EbookDownload(object):
cj.load(cookie_file) cj.load(cookie_file)
br.set_cookiejar(cj) br.set_cookiejar(cj)
with closing(br.open(url)) as r: with closing(br.open(url)) as r:
tf = PersistentTemporaryFile(suffix=filename) temp_path = os.path.join(PersistentTemporaryDirectory(), filename)
tf = open(temp_path, 'w+b')
tf.write(r.read()) tf.write(r.read())
dfilename = tf.name dfilename = tf.name

View File

@ -8,7 +8,6 @@ __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import itertools, operator, os import itertools, operator, os
from types import MethodType from types import MethodType
from time import time
from threading import Event, Thread from threading import Event, Thread
from Queue import LifoQueue from Queue import LifoQueue
from functools import wraps, partial from functools import wraps, partial
@ -19,10 +18,11 @@ from PyQt4.Qt import (
QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication, QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication,
QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent, QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent,
QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView, QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView,
QStyleOptionViewItem, QToolTip, QByteArray, QBuffer) QStyleOptionViewItem, QToolTip, QByteArray, QBuffer, QBrush)
from calibre import fit_image, prints, prepare_string_for_xml from calibre import fit_image, prints, prepare_string_for_xml
from calibre.ebooks.metadata import fmt_sidx from calibre.ebooks.metadata import fmt_sidx
from calibre.utils import join_with_timeout
from calibre.gui2 import gprefs, config from calibre.gui2 import gprefs, config
from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.utils.config import prefs, tweaks from calibre.utils.config import prefs, tweaks
@ -482,17 +482,6 @@ class CoverDelegate(QStyledItemDelegate):
return True return True
return False return False
def join_with_timeout(q, timeout=2):
q.all_tasks_done.acquire()
try:
endtime = time() + timeout
while q.unfinished_tasks:
remaining = endtime - time()
if remaining <= 0.0:
raise RuntimeError('Waiting for queue to clear timed out')
q.all_tasks_done.wait(remaining)
finally:
q.all_tasks_done.release()
# }}} # }}}
# The View {{{ # The View {{{
@ -586,6 +575,12 @@ class GridView(QListView):
pal = QPalette() pal = QPalette()
col = QColor(r, g, b) col = QColor(r, g, b)
pal.setColor(pal.Base, col) pal.setColor(pal.Base, col)
tex = gprefs['cover_grid_texture']
if tex:
from calibre.gui2.preferences.texture_chooser import texture_path
path = texture_path(tex)
if path:
pal.setBrush(pal.Base, QBrush(QPixmap(path)))
dark = (r + g + b)/3.0 < 128 dark = (r + g + b)/3.0 < 128
pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black)) pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black))
self.setPalette(pal) self.setPalette(pal)

View File

@ -928,7 +928,10 @@ class BooksModel(QAbstractTableModel): # {{{
if role == Qt.DisplayRole: # orientation is vertical if role == Qt.DisplayRole: # orientation is vertical
return QVariant(section+1) return QVariant(section+1)
if role == Qt.DecorationRole: if role == Qt.DecorationRole:
return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration try:
return self.marked_icon if self.db.data.get_marked(self.db.data.index_to_id(section)) else self.row_decoration
except (ValueError, IndexError):
pass
return NONE return NONE
def flags(self, index): def flags(self, index):

View File

@ -66,7 +66,7 @@ class HeaderView(QHeaderView): # {{{
try: try:
opt.icon = model.headerData(logical_index, opt.orientation, Qt.DecorationRole) opt.icon = model.headerData(logical_index, opt.orientation, Qt.DecorationRole)
opt.iconAlignment = Qt.AlignVCenter opt.iconAlignment = Qt.AlignVCenter
except TypeError: except (IndexError, ValueError, TypeError):
pass pass
if sm.isRowSelected(logical_index, QModelIndex()): if sm.isRowSelected(logical_index, QModelIndex()):
opt.state |= QStyle.State_Sunken opt.state |= QStyle.State_Sunken
@ -693,7 +693,13 @@ class BooksView(QTableView): # {{{
self.alternate_views.marked_changed(old_marked, current_marked) self.alternate_views.marked_changed(old_marked, current_marked)
if bool(old_marked) == bool(current_marked): if bool(old_marked) == bool(current_marked):
changed = old_marked | current_marked changed = old_marked | current_marked
sections = tuple(map(self.model().db.data.id_to_index, changed)) i = self.model().db.data.id_to_index
def f(x):
try:
return i(x)
except ValueError:
pass
sections = tuple(x for x in map(f, changed) if x is not None)
self.row_header.headerDataChanged(Qt.Vertical, min(sections), max(sections)) self.row_header.headerDataChanged(Qt.Vertical, min(sections), max(sections))
else: else:
# Marked items have either appeared or all been removed # Marked items have either appeared or all been removed

View File

@ -8,13 +8,15 @@ __docformat__ = 'restructuredtext en'
from threading import Thread from threading import Thread
from functools import partial from functools import partial
from PyQt4.Qt import (QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, from PyQt4.Qt import (
QAbstractListModel, Qt, QIcon, QKeySequence, QPalette, QColor, pyqtSignal) QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, QPainter,
QAbstractListModel, Qt, QIcon, QKeySequence, QColor, pyqtSignal,
QWidget, QSizePolicy, QBrush, QPixmap, QSize, QPushButton)
from calibre import human_readable from calibre import human_readable
from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList
from calibre.gui2.preferences.look_feel_ui import Ui_Form from calibre.gui2.preferences.look_feel_ui import Ui_Form
from calibre.gui2 import config, gprefs, qt_app, NONE, open_local_file from calibre.gui2 import config, gprefs, qt_app, NONE, open_local_file, question_dialog
from calibre.utils.localization import (available_translations, from calibre.utils.localization import (available_translations,
get_language, get_lang) get_language, get_lang)
from calibre.utils.config import prefs from calibre.utils.config import prefs
@ -98,6 +100,33 @@ class DisplayedFields(QAbstractListModel): # {{{
# }}} # }}}
class Background(QWidget): # {{{
def __init__(self, parent):
QWidget.__init__(self, parent)
self.bcol = QColor(*gprefs['cover_grid_color'])
self.btex = gprefs['cover_grid_texture']
self.update_brush()
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def update_brush(self):
self.brush = QBrush(self.bcol)
if self.btex:
from calibre.gui2.preferences.texture_chooser import texture_path
path = texture_path(self.btex)
if path:
self.brush.setTexture(QPixmap(path))
self.update()
def sizeHint(self):
return QSize(200, 120)
def paintEvent(self, ev):
painter = QPainter(self)
painter.fillRect(ev.rect(), self.brush)
painter.end()
# }}}
class ConfigWidget(ConfigWidgetBase, Ui_Form): class ConfigWidget(ConfigWidgetBase, Ui_Form):
size_calculated = pyqtSignal(object) size_calculated = pyqtSignal(object)
@ -209,10 +238,24 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
keys = [unicode(x.toString(QKeySequence.NativeText)) for x in keys] keys = [unicode(x.toString(QKeySequence.NativeText)) for x in keys]
self.fs_help_msg.setText(unicode(self.fs_help_msg.text())%( self.fs_help_msg.setText(unicode(self.fs_help_msg.text())%(
_(' or ').join(keys))) _(' or ').join(keys)))
self.cover_grid_color_button.clicked.connect(self.change_cover_grid_color)
self.cover_grid_default_color_button.clicked.connect(self.restore_cover_grid_color)
self.size_calculated.connect(self.update_cg_cache_size, type=Qt.QueuedConnection) self.size_calculated.connect(self.update_cg_cache_size, type=Qt.QueuedConnection)
self.tabWidget.currentChanged.connect(self.tab_changed) self.tabWidget.currentChanged.connect(self.tab_changed)
l = self.cg_background_box.layout()
self.cg_bg_widget = w = Background(self)
l.addWidget(w, 0, 0, 3, 1)
self.cover_grid_color_button = b = QPushButton(_('Change &color'), self)
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
l.addWidget(b, 0, 1)
b.clicked.connect(self.change_cover_grid_color)
self.cover_grid_texture_button = b = QPushButton(_('Change &background image'), self)
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
l.addWidget(b, 1, 1)
b.clicked.connect(self.change_cover_grid_texture)
self.cover_grid_default_appearance_button = b = QPushButton(_('Restore &default appearance'), self)
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
l.addWidget(b, 2, 1)
b.clicked.connect(self.restore_cover_grid_appearance)
self.cover_grid_empty_cache.clicked.connect(self.empty_cache) self.cover_grid_empty_cache.clicked.connect(self.empty_cache)
self.cover_grid_open_cache.clicked.connect(self.open_cg_cache) self.cover_grid_open_cache.clicked.connect(self.open_cg_cache)
self.cover_grid_smaller_cover.clicked.connect(partial(self.resize_cover, True)) self.cover_grid_smaller_cover.clicked.connect(partial(self.resize_cover, True))
@ -270,6 +313,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.edit_rules.initialize(db.field_metadata, db.prefs, mi, 'column_color_rules') self.edit_rules.initialize(db.field_metadata, db.prefs, mi, 'column_color_rules')
self.icon_rules.initialize(db.field_metadata, db.prefs, mi, 'column_icon_rules') self.icon_rules.initialize(db.field_metadata, db.prefs, mi, 'column_icon_rules')
self.set_cg_color(gprefs['cover_grid_color']) self.set_cg_color(gprefs['cover_grid_color'])
self.set_cg_texture(gprefs['cover_grid_texture'])
self.update_aspect_ratio() self.update_aspect_ratio()
def open_cg_cache(self): def open_cg_cache(self):
@ -292,9 +336,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.size_calculated.emit(self.gui.grid_view.thumbnail_cache.current_size) self.size_calculated.emit(self.gui.grid_view.thumbnail_cache.current_size)
def set_cg_color(self, val): def set_cg_color(self, val):
pal = QPalette() self.cg_bg_widget.bcol = QColor(*val)
pal.setColor(QPalette.Window, QColor(*val)) self.cg_bg_widget.update_brush()
self.cover_grid_color_label.setPalette(pal)
def set_cg_texture(self, val):
self.cg_bg_widget.btex = val
self.cg_bg_widget.update_brush()
def empty_cache(self): def empty_cache(self):
self.gui.grid_view.thumbnail_cache.empty() self.gui.grid_view.thumbnail_cache.empty()
@ -312,17 +359,32 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.icon_rules.clear() self.icon_rules.clear()
self.changed_signal.emit() self.changed_signal.emit()
self.set_cg_color(gprefs.defaults['cover_grid_color']) self.set_cg_color(gprefs.defaults['cover_grid_color'])
self.set_cg_texture(gprefs.defaults['cover_grid_texture'])
def change_cover_grid_color(self): def change_cover_grid_color(self):
col = QColorDialog.getColor(self.cover_grid_color_label.palette().color(QPalette.Window), col = QColorDialog.getColor(self.cg_bg_widget.bcol,
self.gui, _('Choose background color for cover grid')) self.gui, _('Choose background color for cover grid'))
if col.isValid(): if col.isValid():
col = tuple(col.getRgb())[:3] col = tuple(col.getRgb())[:3]
self.set_cg_color(col) self.set_cg_color(col)
self.changed_signal.emit() self.changed_signal.emit()
if self.cg_bg_widget.btex:
if question_dialog(
self, _('Remove background image?'),
_('There is currently a background image set, so the color'
' you have chosen will not be visible. Remove the background image?')):
self.set_cg_texture(None)
def restore_cover_grid_color(self): def change_cover_grid_texture(self):
from calibre.gui2.preferences.texture_chooser import TextureChooser
d = TextureChooser(parent=self, initial=self.cg_bg_widget.btex)
if d.exec_() == d.Accepted:
self.set_cg_texture(d.texture)
self.changed_signal.emit()
def restore_cover_grid_appearance(self):
self.set_cg_color(gprefs.defaults['cover_grid_color']) self.set_cg_color(gprefs.defaults['cover_grid_color'])
self.set_cg_texture(gprefs.defaults['cover_grid_texture'])
self.changed_signal.emit() self.changed_signal.emit()
def build_font_obj(self): def build_font_obj(self):
@ -383,7 +445,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.display_model.commit() self.display_model.commit()
self.edit_rules.commit(self.gui.current_db.prefs) self.edit_rules.commit(self.gui.current_db.prefs)
self.icon_rules.commit(self.gui.current_db.prefs) self.icon_rules.commit(self.gui.current_db.prefs)
gprefs['cover_grid_color'] = tuple(self.cover_grid_color_label.palette().color(QPalette.Window).getRgb())[:3] gprefs['cover_grid_color'] = tuple(self.cg_bg_widget.bcol.getRgb())[:3]
gprefs['cover_grid_texture'] = self.cg_bg_widget.btex
return rr return rr
def refresh_gui(self, gui): def refresh_gui(self, gui):

View File

@ -312,61 +312,12 @@
</layout> </layout>
</item> </item>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout_2"> <widget class="QGroupBox" name="cg_background_box">
<item> <property name="title">
<widget class="QLabel" name="label_14"> <string>Background for the cover grid</string>
<property name="text"> </property>
<string>Background color for the cover grid:</string> <layout class="QGridLayout" name="gridLayout_5"/>
</property> </widget>
<property name="buddy">
<cstring>cover_grid_color_button</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="cover_grid_color_label">
<property name="minimumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="autoFillBackground">
<bool>true</bool>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cover_grid_color_button">
<property name="text">
<string>Change &amp;color</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cover_grid_default_color_button">
<property name="text">
<string>Restore &amp;default color</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item> </item>
<item> <item>
<widget class="QGroupBox" name="groupBox_4"> <widget class="QGroupBox" name="groupBox_4">

View File

@ -0,0 +1,151 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import glob, os, string, shutil
from functools import partial
from PyQt4.Qt import (
QDialog, QVBoxLayout, QListWidget, QListWidgetItem, Qt, QIcon,
QApplication, QSize, QPixmap, QDialogButtonBox, QTimer, QLabel)
from calibre.constants import config_dir
from calibre.gui2 import choose_files, error_dialog
from calibre.utils.icu import sort_key
def texture_dir():
ans = os.path.join(config_dir, 'textures')
if not os.path.exists(ans):
os.makedirs(ans)
return ans
def texture_path(fname):
if not fname:
return
if fname.startswith(':'):
return I('textures/%s' % fname[1:])
return os.path.join(texture_dir(), fname)
class TextureChooser(QDialog):
def __init__(self, parent=None, initial=None):
QDialog.__init__(self, parent)
self.setWindowTitle(_('Choose a texture'))
self.l = l = QVBoxLayout()
self.setLayout(l)
self.tdir = texture_dir()
self.images = il = QListWidget(self)
il.itemDoubleClicked.connect(self.accept, type=Qt.QueuedConnection)
il.setIconSize(QSize(256, 256))
il.setViewMode(il.IconMode)
il.setFlow(il.LeftToRight)
il.setSpacing(20)
il.setSelectionMode(il.SingleSelection)
il.itemSelectionChanged.connect(self.update_remove_state)
l.addWidget(il)
self.ad = ad = QLabel(_('The builtin textures come from <a href="http://subtlepatterns.com">subtlepatterns.com</a>.'))
ad.setOpenExternalLinks(True)
ad.setWordWrap(True)
l.addWidget(ad)
self.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
b = self.add_button = bb.addButton(_('Add texture'), bb.ActionRole)
b.setIcon(QIcon(I('plus.png')))
b.clicked.connect(self.add_texture)
b = self.remove_button = bb.addButton(_('Remove texture'), bb.ActionRole)
b.setIcon(QIcon(I('minus.png')))
b.clicked.connect(self.remove_texture)
l.addWidget(bb)
images = [{
'fname': ':'+os.path.basename(x),
'path': x,
'name': ' '.join(map(string.capitalize, os.path.splitext(os.path.basename(x))[0].split('_')))
} for x in glob.glob(I('textures/*.png'))] + [{
'fname': os.path.basename(x),
'path': x,
'name': os.path.splitext(os.path.basename(x))[0],
} for x in glob.glob(os.path.join(self.tdir, '*')) if x.rpartition('.')[-1].lower() in {'jpeg', 'png', 'jpg'}]
images.sort(key=lambda x:sort_key(x['name']))
map(self.create_item, images)
self.update_remove_state()
if initial:
existing = {unicode(i.data(Qt.UserRole).toString()):i for i in (self.images.item(c) for c in xrange(self.images.count()))}
item = existing.get(initial, None)
if item is not None:
item.setSelected(True)
QTimer.singleShot(100, partial(il.scrollToItem, item))
self.resize(QSize(950, 650))
def create_item(self, data):
x = data
i = QListWidgetItem(QIcon(QPixmap(x['path']).scaled(256, 256, transformMode=Qt.SmoothTransformation)), x['name'], self.images)
i.setData(Qt.UserRole, x['fname'])
i.setData(Qt.UserRole+1, x['path'])
return i
def update_remove_state(self):
removeable = bool(self.selected_fname and not self.selected_fname.startswith(':'))
self.remove_button.setEnabled(removeable)
@property
def texture(self):
return self.selected_fname
def add_texture(self):
path = choose_files(self, 'choose-texture-image', _('Choose Image'),
filters=[(_('Images'), ['jpeg', 'jpg', 'png'])], all_files=False, select_only_single_file=True)
if not path:
return
path = path[0]
fname = os.path.basename(path)
name = fname.rpartition('.')[0]
existing = {unicode(i.data(Qt.UserRole).toString()):i for i in (self.images.item(c) for c in xrange(self.images.count()))}
dest = os.path.join(self.tdir, fname)
with open(path, 'rb') as s, open(dest, 'wb') as f:
shutil.copyfileobj(s, f)
if fname in existing:
self.takeItem(existing[fname])
data = {'fname': fname, 'path': dest, 'name': name}
i = self.create_item(data)
i.setSelected(True)
self.images.scrollToItem(i)
@property
def selected_item(self):
for x in self.images.selectedItems():
return x
@property
def selected_fname(self):
try:
return unicode(self.selected_item.data(Qt.UserRole).toString())
except (AttributeError, TypeError):
pass
def remove_texture(self):
if not self.selected_fname:
return
if self.selected_fname.startswith(':'):
return error_dialog(self, _('Cannot remove'),
_('Cannot remover builtin textures'), show=True)
os.remove(unicode(self.selected_item.data(Qt.UserRole+1).toString()))
self.images.takeItem(self.images.row(self.selected_item))
if __name__ == '__main__':
app = QApplication([]) # noqa
d = TextureChooser()
d.exec_()
print (d.texture)

View File

@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
from calibre.utils.config import JSONConfig
tprefs = JSONConfig('tweak_book_gui')
_current_container = None _current_container = None

View File

@ -8,22 +8,25 @@ __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import tempfile, shutil import tempfile, shutil
from PyQt4.Qt import QObject from PyQt4.Qt import QObject, QApplication
from calibre.gui2 import error_dialog, choose_files from calibre.gui2 import error_dialog, choose_files, question_dialog, info_dialog
from calibre.ptempfile import PersistentTemporaryDirectory from calibre.ptempfile import PersistentTemporaryDirectory
from calibre.ebooks.oeb.polish.main import SUPPORTED from calibre.ebooks.oeb.polish.main import SUPPORTED
from calibre.ebooks.oeb.polish.container import get_container, clone_container from calibre.ebooks.oeb.polish.container import get_container, clone_container
from calibre.gui2.tweak_book import set_current_container, current_container from calibre.gui2.tweak_book import set_current_container, current_container, tprefs
from calibre.gui2.tweak_book.undo import GlobalUndoHistory from calibre.gui2.tweak_book.undo import GlobalUndoHistory
from calibre.gui2.tweak_book.save import SaveManager
class Boss(QObject): class Boss(QObject):
def __init__(self, parent=None): def __init__(self, parent):
QObject.__init__(self, parent) QObject.__init__(self, parent)
self.global_undo = GlobalUndoHistory() self.global_undo = GlobalUndoHistory()
self.container_count = 0 self.container_count = 0
self.tdir = None self.tdir = None
self.save_manager = SaveManager(parent)
self.save_manager.report_error.connect(self.report_save_error)
def __call__(self, gui): def __call__(self, gui):
self.gui = gui self.gui = gui
@ -40,6 +43,10 @@ class Boss(QObject):
def open_book(self, path=None): def open_book(self, path=None):
if not self.check_dirtied(): if not self.check_dirtied():
return return
if self.save_manager.has_tasks:
return info_dialog(self.gui, _('Cannot open'),
_('The current book is being saved, you cannot open a new book until'
' the saving is completed'), show=True)
if not hasattr(path, 'rpartition'): if not hasattr(path, 'rpartition'):
path = choose_files(self.gui, 'open-book-for-tweaking', _('Choose book'), path = choose_files(self.gui, 'open-book-for-tweaking', _('Choose book'),
@ -71,12 +78,41 @@ class Boss(QObject):
self.current_metadata = self.gui.current_metadata = container.mi self.current_metadata = self.gui.current_metadata = container.mi
self.global_undo.open_book(container) self.global_undo.open_book(container)
self.gui.update_window_title() self.gui.update_window_title()
self.gui.file_list.build(container) self.gui.file_list.build(container, preserve_state=False)
self.gui.action_save.setEnabled(False)
self.update_global_history_actions()
def update_global_history_actions(self):
gu = self.global_undo
for x, text in (('undo', _('&Revert to before')), ('redo', '&Revert to after')):
ac = getattr(self.gui, 'action_global_%s' % x)
ac.setEnabled(getattr(gu, 'can_' + x))
ac.setText(text + ' ' + (getattr(gu, x + '_msg') or '...'))
def add_savepoint(self, msg): def add_savepoint(self, msg):
nc = clone_container(current_container(), self.mkdtemp()) nc = clone_container(current_container(), self.mkdtemp())
self.global_undo.add_savepoint(nc, msg) self.global_undo.add_savepoint(nc, msg)
set_current_container(nc) set_current_container(nc)
self.update_global_history_actions()
def apply_container_update_to_gui(self):
container = current_container()
self.gui.file_list.build(container)
self.update_global_history_actions()
self.gui.action_save.setEnabled(True)
# TODO: Apply to other GUI elements
def do_global_undo(self):
container = self.global_undo.undo()
if container is not None:
set_current_container(container)
self.apply_container_update_to_gui()
def do_global_redo(self):
container = self.global_undo.redo()
if container is not None:
set_current_container(container)
self.apply_container_update_to_gui()
def delete_requested(self, spine_items, other_items): def delete_requested(self, spine_items, other_items):
if not self.check_dirtied(): if not self.check_dirtied():
@ -86,6 +122,48 @@ class Boss(QObject):
c.remove_from_spine(spine_items) c.remove_from_spine(spine_items)
for name in other_items: for name in other_items:
c.remove_item(name) c.remove_item(name)
self.gui.action_save.setEnabled(True)
self.gui.file_list.delete_done(spine_items, other_items) self.gui.file_list.delete_done(spine_items, other_items)
# TODO: Update other GUI elements
def save_book(self):
self.gui.action_save.setEnabled(False)
tdir = tempfile.mkdtemp(prefix='save-%05d-' % self.container_count, dir=self.tdir)
container = clone_container(current_container(), tdir)
self.save_manager.schedule(tdir, container)
def report_save_error(self, tb):
error_dialog(self.gui, _('Could not save'),
_('Saving of the book failed. Click "Show Details"'
' for more information.'), det_msg=tb, show=True)
def quit(self):
if not self.confirm_quit():
return
self.save_state()
QApplication.instance().quit()
def confirm_quit(self):
if self.save_manager.has_tasks:
if not question_dialog(
self.gui, _('Are you sure?'), _(
'The current book is being saved in the background, quitting will abort'
' the save process, are you sure?'), default_yes=False):
return False
if self.gui.action_save.isEnabled():
if not question_dialog(
self.gui, _('Are you sure?'), _(
'The current book has unsaved changes, you will lose them if you quit,'
' are you sure?'), default_yes=False):
return False
return True
def shutdown(self):
self.save_state()
self.save_manager.shutdown()
self.save_manager.wait(0.1)
def save_state(self):
with tprefs:
self.gui.save_state()

View File

@ -6,6 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
from binascii import hexlify
from PyQt4.Qt import ( from PyQt4.Qt import (
QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon, QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon,
QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal) QStyledItemDelegate, QStyle, QPixmap, QPainter, pyqtSignal)
@ -16,6 +17,7 @@ from calibre.ebooks.oeb.polish.container import guess_type
from calibre.ebooks.oeb.polish.cover import get_cover_page_name, get_raster_cover_name from calibre.ebooks.oeb.polish.cover import get_cover_page_name, get_raster_cover_name
from calibre.gui2 import error_dialog from calibre.gui2 import error_dialog
from calibre.gui2.tweak_book import current_container from calibre.gui2.tweak_book import current_container
from calibre.utils.icu import sort_key
TOP_ICON_SIZE = 24 TOP_ICON_SIZE = 24
NAME_ROLE = Qt.UserRole NAME_ROLE = Qt.UserRole
@ -73,7 +75,6 @@ class FileList(QTreeWidget):
self.setAutoExpandDelay(1000) self.setAutoExpandDelay(1000)
self.setAnimated(True) self.setAnimated(True)
self.setMouseTracking(True) self.setMouseTracking(True)
self.in_drop_event = False
self.setContextMenuPolicy(Qt.CustomContextMenu) self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu) self.customContextMenuRequested.connect(self.show_context_menu)
self.root = self.invisibleRootItem() self.root = self.invisibleRootItem()
@ -89,7 +90,25 @@ class FileList(QTreeWidget):
'images':'view-image.png', 'images':'view-image.png',
}.iteritems()} }.iteritems()}
def build(self, container): def get_state(self):
s = {'pos':self.verticalScrollBar().value()}
s['expanded'] = {c for c, item in self.categories.iteritems() if item.isExpanded()}
s['selected'] = {unicode(i.data(0, NAME_ROLE).toString()) for i in self.selectedItems()}
return s
def set_state(self, state):
for category, item in self.categories.iteritems():
item.setExpanded(category in state['expanded'])
self.verticalScrollBar().setValue(state['pos'])
for parent in self.categories.itervalues():
for c in (parent.child(i) for i in xrange(parent.childCount())):
name = unicode(c.data(0, NAME_ROLE).toString())
if name in state['selected']:
c.setSelected(True)
def build(self, container, preserve_state=True):
if preserve_state:
state = self.get_state()
self.clear() self.clear()
self.root = self.invisibleRootItem() self.root = self.invisibleRootItem()
self.root.setFlags(Qt.ItemIsDragEnabled) self.root.setFlags(Qt.ItemIsDragEnabled)
@ -140,6 +159,7 @@ class FileList(QTreeWidget):
# We have an exact duplicate (can happen if there are # We have an exact duplicate (can happen if there are
# duplicates in the spine) # duplicates in the spine)
item.setText(0, processed[name].text(0)) item.setText(0, processed[name].text(0))
item.setText(1, processed[name].text(1))
return return
parts = name.split('/') parts = name.split('/')
@ -148,6 +168,7 @@ class FileList(QTreeWidget):
text = parts.pop() + '/' + text text = parts.pop() + '/' + text
seen[text] = item seen[text] = item
item.setText(0, text) item.setText(0, text)
item.setText(1, hexlify(sort_key(text)))
def render_emblems(item, emblems): def render_emblems(item, emblems):
emblems = tuple(emblems) emblems = tuple(emblems)
@ -220,11 +241,16 @@ class FileList(QTreeWidget):
continue continue
processed[name] = create_item(name) processed[name] = create_item(name)
for c in self.categories.itervalues(): for name, c in self.categories.iteritems():
self.expandItem(c) c.setExpanded(True)
if name != 'text':
c.sortChildren(1, Qt.AscendingOrder)
if preserve_state:
self.set_state(state)
def show_context_menu(self, point): def show_context_menu(self, point):
pass pass # TODO: Implement this
def keyPressEvent(self, ev): def keyPressEvent(self, ev):
if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace): if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace):
@ -262,6 +288,14 @@ class FileList(QTreeWidget):
for c in removals: for c in removals:
c.parent().removeChild(c) c.parent().removeChild(c)
def dropEvent(self, event):
text = self.categories['text']
pre_drop_order = {text.child(i):i for i in xrange(text.childCount())}
super(FileList, self).dropEvent(event)
current_order = {text.child(i):i for i in xrange(text.childCount())}
if current_order != pre_drop_order:
pass # TODO: Implement this
class FileListWidget(QWidget): class FileListWidget(QWidget):
delete_requested = pyqtSignal(object, object) delete_requested = pyqtSignal(object, object)
@ -277,7 +311,7 @@ class FileListWidget(QWidget):
for x in ('delete_done',): for x in ('delete_done',):
setattr(self, x, getattr(self.file_list, x)) setattr(self, x, getattr(self.file_list, x))
def build(self, container): def build(self, container, preserve_state=True):
self.file_list.build(container) self.file_list.build(container, preserve_state=preserve_state)

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import shutil, os
from threading import Thread
from Queue import LifoQueue, Empty
from PyQt4.Qt import (QObject, pyqtSignal, QLabel, QWidget, QHBoxLayout, Qt)
from calibre.constants import iswindows
from calibre.ptempfile import PersistentTemporaryFile
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.utils import join_with_timeout
from calibre.utils.filenames import atomic_rename
class SaveWidget(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QHBoxLayout(self)
self.setLayout(l)
self.label = QLabel('')
self.pi = ProgressIndicator(self, 24)
l.addWidget(self.label)
l.addWidget(self.pi)
l.setContentsMargins(0, 0, 0, 0)
self.pi.setVisible(False)
self.stop()
def start(self):
self.pi.setVisible(True)
self.pi.startAnimation()
self.label.setText(_('Saving...'))
def stop(self):
self.pi.setVisible(False)
self.pi.stopAnimation()
self.label.setText(_('Saved'))
class SaveManager(QObject):
start_save = pyqtSignal()
report_error = pyqtSignal(object)
save_done = pyqtSignal()
def __init__(self, parent):
QObject.__init__(self, parent)
self.count = 0
self.last_saved = -1
self.requests = LifoQueue()
t = Thread(name='save-thread', target=self.run)
t.daemon = True
t.start()
self.status_widget = w = SaveWidget(parent)
self.start_save.connect(w.start, type=Qt.QueuedConnection)
self.save_done.connect(w.stop, type=Qt.QueuedConnection)
def schedule(self, tdir, container):
self.count += 1
self.requests.put((self.count, tdir, container))
def run(self):
while True:
x = self.requests.get()
if x is None:
self.requests.task_done()
self.__empty_queue()
break
try:
count, tdir, container = x
self.process_save(count, tdir, container)
except:
import traceback
traceback.print_exc()
finally:
self.requests.task_done()
def __empty_queue(self):
' Only to be used during shutdown '
while True:
try:
self.requests.get_nowait()
except Empty:
break
else:
self.requests.task_done()
def process_save(self, count, tdir, container):
if count <= self.last_saved:
shutil.rmtree(tdir, ignore_errors=True)
return
self.last_saved = count
self.start_save.emit()
try:
self.do_save(tdir, container)
except:
import traceback
self.report_error.emit(traceback.format_exc())
self.save_done.emit()
def do_save(self, tdir, container):
temp = None
try:
path = container.path_to_ebook
temp = PersistentTemporaryFile(
prefix=('_' if iswindows else '.'), suffix=os.path.splitext(path)[1], dir=os.path.dirname(path))
temp.close()
temp = temp.name
container.commit(temp)
atomic_rename(temp, path)
finally:
if temp and os.path.exists(temp):
os.remove(temp)
shutil.rmtree(tdir, ignore_errors=True)
@property
def has_tasks(self):
return bool(self.requests.unfinished_tasks)
def wait(self, timeout=30):
if timeout is None:
self.requests.join()
else:
try:
join_with_timeout(self.requests, timeout)
except RuntimeError:
return False
return True
def shutdown(self):
self.requests.put(None)

View File

@ -6,10 +6,11 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon, QAction from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon, QAction, QApplication
from calibre.constants import __appname__, get_version
from calibre.gui2.main_window import MainWindow from calibre.gui2.main_window import MainWindow
from calibre.gui2.tweak_book import current_container from calibre.gui2.tweak_book import current_container, tprefs
from calibre.gui2.tweak_book.file_list import FileListWidget from calibre.gui2.tweak_book.file_list import FileListWidget
from calibre.gui2.tweak_book.job import BlockingJob from calibre.gui2.tweak_book.job import BlockingJob
from calibre.gui2.tweak_book.boss import Boss from calibre.gui2.tweak_book.boss import Boss
@ -18,6 +19,7 @@ from calibre.gui2.keyboard import Manager as KeyboardManager
class Main(MainWindow): class Main(MainWindow):
APP_NAME = _('Tweak Book') APP_NAME = _('Tweak Book')
STATE_VERSION = 0
def __init__(self, opts): def __init__(self, opts):
MainWindow.__init__(self, opts, disable_automatic_gc=True) MainWindow.__init__(self, opts, disable_automatic_gc=True)
@ -38,9 +40,17 @@ class Main(MainWindow):
self.status_bar = self.statusBar() self.status_bar = self.statusBar()
self.l = QLabel('Placeholder') self.l = QLabel('Placeholder')
self.status_bar.addPermanentWidget(self.boss.save_manager.status_widget)
self.status_bar.addWidget(QLabel(_('{0} {1} created by {2}').format(__appname__, get_version(), 'Kovid Goyal')))
f = self.status_bar.font()
f.setBold(True)
self.status_bar.setFont(f)
self.setCentralWidget(self.l) self.setCentralWidget(self.l)
self.boss(self) self.boss(self)
g = QApplication.instance().desktop().availableGeometry(self)
self.resize(g.width()-50, g.height()-50)
self.restore_state()
self.keyboard.finalize() self.keyboard.finalize()
@ -58,18 +68,37 @@ class Main(MainWindow):
return ac return ac
self.action_open_book = reg('document_open.png', _('Open &book'), self.boss.open_book, 'open-book', 'Ctrl+O', _('Open a new book')) self.action_open_book = reg('document_open.png', _('Open &book'), self.boss.open_book, 'open-book', 'Ctrl+O', _('Open a new book'))
self.action_global_undo = reg('back.png', _('&Revert to before'), self.boss.do_global_undo, 'global-undo', 'Ctrl+Left',
_('Revert book to before the last action (Undo)'))
self.action_global_redo = reg('forward.png', _('&Revert to after'), self.boss.do_global_redo, 'global-redo', 'Ctrl+Right',
_('Revert book state to after the next action (Redo)'))
self.action_save = reg('save.png', _('&Save'), self.boss.save_book, 'save-book', 'Ctrl+S', _('Save book'))
self.action_save.setEnabled(False)
self.action_quit = reg('quit.png', _('&Quit'), self.boss.quit, 'quit', 'Ctrl+Q', _('Quit'))
def create_menubar(self): def create_menubar(self):
b = self.menuBar() b = self.menuBar()
f = b.addMenu(_('&File')) f = b.addMenu(_('&File'))
f.addAction(self.action_open_book) f.addAction(self.action_open_book)
f.addAction(self.action_save)
f.addAction(self.action_quit)
e = b.addMenu(_('&Edit'))
e.addAction(self.action_global_undo)
e.addAction(self.action_global_redo)
def create_toolbar(self): def create_toolbar(self):
self.global_bar = b = self.addToolBar(_('Global')) self.global_bar = b = self.addToolBar(_('Global'))
b.setObjectName('global_bar') # Needed for saveState
b.addAction(self.action_open_book) b.addAction(self.action_open_book)
b.addAction(self.action_global_undo)
b.addAction(self.action_global_redo)
b.addAction(self.action_save)
def create_docks(self): def create_docks(self):
self.file_list_dock = d = QDockWidget(_('&Files Browser'), self) self.file_list_dock = d = QDockWidget(_('&Files Browser'), self)
d.setObjectName('file_list_dock') # Needed for saveState
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
self.file_list = FileListWidget(d) self.file_list = FileListWidget(d)
d.setWidget(self.file_list) d.setWidget(self.file_list)
@ -81,3 +110,26 @@ class Main(MainWindow):
def update_window_title(self): def update_window_title(self):
self.setWindowTitle(self.current_metadata.title + ' [%s] - %s' %(current_container().book_type.upper(), self.APP_NAME)) self.setWindowTitle(self.current_metadata.title + ' [%s] - %s' %(current_container().book_type.upper(), self.APP_NAME))
def closeEvent(self, e):
if not self.boss.confirm_quit():
e.ignore()
return
try:
self.boss.shutdown()
except:
import traceback
traceback.print_exc()
e.accept()
def save_state(self):
tprefs.set('main_window_geometry', bytearray(self.saveGeometry()))
tprefs.set('main_window_state', bytearray(self.saveState(self.STATE_VERSION)))
def restore_state(self):
geom = tprefs.get('main_window_geometry', None)
if geom is not None:
self.restoreGeometry(geom)
state = tprefs.get('main_window_state', None)
if state is not None:
self.restoreState(state, self.STATE_VERSION)

View File

@ -53,4 +53,23 @@ class GlobalUndoHistory(object):
self.pos += 1 self.pos += 1
return self.current_container return self.current_container
@property
def can_undo(self):
return self.pos > 0
@property
def can_redo(self):
return self.pos < len(self.states) - 1
@property
def undo_msg(self):
if not self.can_undo:
return ''
return self.states[self.pos - 1].message or ''
@property
def redo_msg(self):
if not self.can_redo:
return ''
return self.states[self.pos].message or ''

View File

@ -500,7 +500,7 @@ class BrowseServer(object):
datatype, self.opts.url_prefix) datatype, self.opts.url_prefix)
href = re.search(r'<a href="([^"]+)"', html) href = re.search(r'<a href="([^"]+)"', html)
if href is not None: if href is not None:
raise cherrypy.HTTPRedirect(href.group(1)) raise cherrypy.InternalRedirect(href.group(1))
if len(items) <= self.opts.max_opds_ungrouped_items: if len(items) <= self.opts.max_opds_ungrouped_items:
script = 'false' script = 'false'

View File

@ -7,3 +7,19 @@ __docformat__ = 'restructuredtext en'
Miscelleaneous utilities. Miscelleaneous utilities.
''' '''
from time import time
def join_with_timeout(q, timeout=2):
''' Join the queue q with a specified timeout. Blocks until all tasks on
the queue are done or times out with a runtime error. '''
q.all_tasks_done.acquire()
try:
endtime = time() + timeout
while q.unfinished_tasks:
remaining = endtime - time()
if remaining <= 0.0:
raise RuntimeError('Waiting for queue to clear timed out')
q.all_tasks_done.wait(remaining)
finally:
q.all_tasks_done.release()

View File

@ -201,32 +201,31 @@ def case_preserving_open_file(path, mode='wb', mkdir_mode=0o777):
fpath = os.path.join(cpath, fname) fpath = os.path.join(cpath, fname)
return ans, fpath return ans, fpath
def samefile_windows(src, dst): def windows_get_fileid(path):
''' The fileid uniquely identifies actual file contents (it is the same for
all hardlinks to a file). Similar to inode number on linux. '''
import win32file import win32file
from pywintypes import error from pywintypes import error
if isbytestring(path):
path = path.decode(filesystem_encoding)
try:
h = win32file.CreateFile(path, 0, 0, None, win32file.OPEN_EXISTING,
win32file.FILE_FLAG_BACKUP_SEMANTICS, 0)
try:
data = win32file.GetFileInformationByHandle(h)
finally:
win32file.CloseHandle(h)
except (error, EnvironmentError):
return None
return data[4], data[8], data[9]
def samefile_windows(src, dst):
samestring = (os.path.normcase(os.path.abspath(src)) == samestring = (os.path.normcase(os.path.abspath(src)) ==
os.path.normcase(os.path.abspath(dst))) os.path.normcase(os.path.abspath(dst)))
if samestring: if samestring:
return True return True
handles = [] a, b = windows_get_fileid(src), windows_get_fileid(dst)
def get_fileid(x):
if isbytestring(x):
x = x.decode(filesystem_encoding)
try:
h = win32file.CreateFile(x, 0, 0, None, win32file.OPEN_EXISTING,
win32file.FILE_FLAG_BACKUP_SEMANTICS, 0)
handles.append(h)
data = win32file.GetFileInformationByHandle(h)
except (error, EnvironmentError):
return None
return (data[4], data[8], data[9])
a, b = get_fileid(src), get_fileid(dst)
for h in handles:
win32file.CloseHandle(h)
if a is None and b is None: if a is None and b is None:
return False return False
return a == b return a == b
@ -319,6 +318,7 @@ class WindowsAtomicFolderMove(object):
import win32file, winerror import win32file, winerror
from pywintypes import error from pywintypes import error
from collections import defaultdict
if isbytestring(path): if isbytestring(path):
path = path.decode(filesystem_encoding) path = path.decode(filesystem_encoding)
@ -326,7 +326,13 @@ class WindowsAtomicFolderMove(object):
if not os.path.exists(path): if not os.path.exists(path):
return return
for x in os.listdir(path): names = os.listdir(path)
name_to_fileid = {x:windows_get_fileid(os.path.join(path, x)) for x in names}
fileid_to_names = defaultdict(set)
for name, fileid in name_to_fileid.iteritems():
fileid_to_names[fileid].add(name)
for x in names:
f = os.path.normcase(os.path.abspath(os.path.join(path, x))) f = os.path.normcase(os.path.abspath(os.path.join(path, x)))
if not os.path.isfile(f): if not os.path.isfile(f):
continue continue
@ -341,6 +347,21 @@ class WindowsAtomicFolderMove(object):
win32file.FILE_SHARE_DELETE, None, win32file.FILE_SHARE_DELETE, None,
win32file.OPEN_EXISTING, win32file.FILE_FLAG_SEQUENTIAL_SCAN, 0) win32file.OPEN_EXISTING, win32file.FILE_FLAG_SEQUENTIAL_SCAN, 0)
except error as e: except error as e:
if getattr(e, 'winerror', 0) == winerror.ERROR_SHARING_VIOLATION:
# The file could be a hardlink to an already opened file,
# in which case we use the same handle for both files
fileid = name_to_fileid[x]
found = False
if fileid is not None:
for other in fileid_to_names[fileid]:
other = os.path.normcase(os.path.abspath(os.path.join(path, other)))
if other in self.handle_map:
self.handle_map[f] = self.handle_map[other]
found = True
break
if found:
continue
self.close_handles() self.close_handles()
if getattr(e, 'winerror', 0) == winerror.ERROR_SHARING_VIOLATION: if getattr(e, 'winerror', 0) == winerror.ERROR_SHARING_VIOLATION:
err = IOError(errno.EACCES, err = IOError(errno.EACCES,
@ -371,6 +392,8 @@ class WindowsAtomicFolderMove(object):
return return
except: except:
pass pass
win32file.SetFilePointer(handle, 0, win32file.FILE_BEGIN)
with lopen(dest, 'wb') as f: with lopen(dest, 'wb') as f:
while True: while True:
hr, raw = win32file.ReadFile(handle, 1024*1024) hr, raw = win32file.ReadFile(handle, 1024*1024)
@ -381,6 +404,7 @@ class WindowsAtomicFolderMove(object):
f.write(raw) f.write(raw)
def release_file(self, path): def release_file(self, path):
' Release the lock on the file pointed to by path. Will also release the lock on any hardlinks to path '
key = None key = None
for p, h in self.handle_map.iteritems(): for p, h in self.handle_map.iteritems():
if samefile_windows(path, p): if samefile_windows(path, p):
@ -389,7 +413,9 @@ class WindowsAtomicFolderMove(object):
if key is not None: if key is not None:
import win32file import win32file
win32file.CloseHandle(key[1]) win32file.CloseHandle(key[1])
self.handle_map.pop(key[0]) remove = [f for f, h in self.handle_map.iteritems() if h is key[1]]
for x in remove:
self.handle_map.pop(x)
def close_handles(self): def close_handles(self):
import win32file import win32file

View File

@ -515,8 +515,8 @@ class TemplateFormatter(string.Formatter):
try: try:
ans = self.evaluate(fmt, [], kwargs).strip() ans = self.evaluate(fmt, [], kwargs).strip()
except Exception as e: except Exception as e:
# if DEBUG: if DEBUG and getattr(e, 'is_locking_error', False):
# traceback.print_exc() traceback.print_exc()
ans = error_value + ' ' + e.message ans = error_value + ' ' + e.message
return ans return ans
@ -529,7 +529,7 @@ class ValidateFormatter(TemplateFormatter):
def validate(self, x): def validate(self, x):
from calibre.ebooks.metadata.book.base import Metadata from calibre.ebooks.metadata.book.base import Metadata
self.book = Metadata(''); self.book = Metadata('')
return self.vformat(x, [], {}) return self.vformat(x, [], {})
validation_formatter = ValidateFormatter() validation_formatter = ValidateFormatter()

View File

@ -1187,7 +1187,7 @@ class BuiltinDaysBetween(BuiltinFormatterFunction):
except: except:
return '' return ''
i = d1 - d2 i = d1 - d2
return str('%d.%d'%(i.days, i.seconds/8640)) return '%.1f'%(i.days + (i.seconds/(24.0*60.0*60.0)))
class BuiltinLanguageStrings(BuiltinFormatterFunction): class BuiltinLanguageStrings(BuiltinFormatterFunction):
name = 'language_strings' name = 'language_strings'