From be026b8d2bf56f1878b7de275c2e5df45a55231f Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Tue, 14 Jun 2011 23:14:00 -0300 Subject: [PATCH 01/27] KTouch - Image file names have changed - fix display of covers --- src/calibre/devices/kobo/driver.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 04fb3c37b0..1efce88f27 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -85,6 +85,16 @@ class KOBO(USBMS): except: self.fwversion = 'unknown' + # Determine Hardware version differences + product_id = self.detected_device.idProduct + debug_print ("device: ", product_id) + if product_id == 0x4161: #Original and KWifi + image_suffix = ' - NickelBookCover.parsed' + else: #KTouch + image_suffix = ' - N3_LIBRARY_FULL.parsed' + + debug_print("Image Suffix: ", image_suffix) + if self.fwversion != '1.0' and self.fwversion != '1.4': self.has_kepubs = True debug_print('Version of firmware: ', self.fwversion, 'Has kepubs:', self.has_kepubs) @@ -125,8 +135,9 @@ class KOBO(USBMS): if idx is not None: bl_cache[lpath] = None if ImageID is not None: - imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + ' - NickelBookCover.parsed') + imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + image_suffix) #print "Image name Normalized: " + imagename + if imagename is not None: bl[idx].thumbnail = ImageWrapper(imagename) if (ContentType != '6' and MimeType != 'Shortcover'): From c9d34ca631a564b1d78c18b9b905ce30c7f662d6 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Tue, 14 Jun 2011 23:23:32 -0300 Subject: [PATCH 02/27] KTouch - Image file names have changed - fix deleting images --- src/calibre/devices/kobo/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 1efce88f27..25ef30edbd 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -297,7 +297,7 @@ class KOBO(USBMS): path_prefix = '.kobo/images/' path = self._main_prefix + path_prefix + ImageID - file_endings = (' - iPhoneThumbnail.parsed', ' - bbMediumGridList.parsed', ' - NickelBookCover.parsed',) + file_endings = (' - iPhoneThumbnail.parsed', ' - bbMediumGridList.parsed', ' - NickelBookCover.parsed', ' - N3_LIBRARY_FULL.parsed', ' - N3_LIBRARY_GRID.parsed', ' - N3_LIBRARY_LIST.parsed', ' - N3_SOCIAL_CURRENTREAD.parsed',) for ending in file_endings: fpath = path + ending From e8b723fb9900d8e9e448caf641cc1a6f5c220768 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Wed, 15 Jun 2011 21:53:40 -0300 Subject: [PATCH 03/27] KTouch - Image file names have changed - modify fix for display of covers --- src/calibre/devices/kobo/driver.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 25ef30edbd..6076aa35eb 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -85,16 +85,6 @@ class KOBO(USBMS): except: self.fwversion = 'unknown' - # Determine Hardware version differences - product_id = self.detected_device.idProduct - debug_print ("device: ", product_id) - if product_id == 0x4161: #Original and KWifi - image_suffix = ' - NickelBookCover.parsed' - else: #KTouch - image_suffix = ' - N3_LIBRARY_FULL.parsed' - - debug_print("Image Suffix: ", image_suffix) - if self.fwversion != '1.0' and self.fwversion != '1.4': self.has_kepubs = True debug_print('Version of firmware: ', self.fwversion, 'Has kepubs:', self.has_kepubs) @@ -135,7 +125,11 @@ class KOBO(USBMS): if idx is not None: bl_cache[lpath] = None if ImageID is not None: - imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + image_suffix) + imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + ' - NickelBookCover.parsed') + if not os.path.exists(imagename): + # Try the Touch version if the image does not exist + imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + ' - N3_LIBRARY_FULL.parsed') + #print "Image name Normalized: " + imagename if imagename is not None: From cb66ec304d43609281fe93242b557e018816f568 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Wed, 15 Jun 2011 22:17:52 -0300 Subject: [PATCH 04/27] KTouch - Hide the internal help files that don't exist on the device storage --- src/calibre/devices/kobo/driver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 6076aa35eb..978a848707 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -199,7 +199,9 @@ class KOBO(USBMS): changed = False for i, row in enumerate(cursor): # self.report_progress((i+1) / float(numrows), _('Getting list of books on device...')) - + if row[3].startswith("file:///usr/local/Kobo/help/"): + # These are internal to the Kobo device and do not exist + continue path = self.path_from_contentid(row[3], row[5], row[4], oncard) mime = mime_type_ext(path_to_ext(path)) if path.find('kepub') == -1 else 'application/epub+zip' # debug_print("mime:", mime) From 515bc88d595c93c47912e47eb26d2805c21ce163 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Wed, 15 Jun 2011 22:31:12 -0300 Subject: [PATCH 05/27] KTouch - Why oh why does the content id keep changing format? --- src/calibre/devices/kobo/driver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 978a848707..e606f65e38 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -457,7 +457,10 @@ class KOBO(USBMS): path = self._main_prefix + path + '.kobo' # print "Path: " + path elif (ContentType == "6" or ContentType == "10") and MimeType == 'application/x-kobo-epub+zip': - path = self._main_prefix + '.kobo/kepub/' + path + if path.startswith("file:///mnt/onboard/"): + path = self._main_prefix + path.replace("file:///mnt/onboard/", '') + else: + path = self._main_prefix + '.kobo/kepub/' + path # print "Internal: " + path else: # if path.startswith("file:///mnt/onboard/"): From 405db329847653f6ec1867ec9071e24faee32ac8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sat, 18 Jun 2011 14:50:02 +0100 Subject: [PATCH 06/27] libri.de store plugin --- src/calibre/customize/builtins.py | 11 +++ src/calibre/gui2/store/libri_de_plugin.py | 92 +++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/calibre/gui2/store/libri_de_plugin.py diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index b2268d3732..17b7244c58 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -1300,6 +1300,16 @@ class StoreLegimiStore(StoreBase): headquarters = 'PL' formats = ['EPUB'] +class StoreLibreDEStore(StoreBase): + name = 'Libri DE' + author = 'Charles Haley' + description = u'Sicher Bücher, Hörbücher und Downloads online bestellen.' + actual_plugin = 'calibre.gui2.store.libri_de_plugin:LibreDEStore' + + headquarters = 'DE' + formats = ['EPUB', 'PDF'] + affiliate = True + class StoreManyBooksStore(StoreBase): name = 'ManyBooks' description = u'Public domain and creative commons works from many sources.' @@ -1450,6 +1460,7 @@ plugins += [ StoreGutenbergStore, StoreKoboStore, StoreLegimiStore, + StoreLibreDEStore, StoreManyBooksStore, StoreMobileReadStore, StoreNextoStore, diff --git a/src/calibre/gui2/store/libri_de_plugin.py b/src/calibre/gui2/store/libri_de_plugin.py new file mode 100644 index 0000000000..edcdff0cf2 --- /dev/null +++ b/src/calibre/gui2/store/libri_de_plugin.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +from __future__ import (unicode_literals, division, absolute_import, print_function) + +__license__ = 'GPL 3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +import urllib2 +from contextlib import closing + +from lxml import html + +from PyQt4.Qt import QUrl + +from calibre import browser +from calibre.gui2 import open_url +from calibre.gui2.store import StorePlugin +from calibre.gui2.store.basic_config import BasicStoreConfig +from calibre.gui2.store.search_result import SearchResult +from calibre.gui2.store.web_store_dialog import WebStoreDialog + +class LibreDEStore(BasicStoreConfig, StorePlugin): + + def open(self, parent=None, detail_item=None, external=False): + url = 'http://ad.zanox.com/ppc/?18817073C15644254T' + url_details = ('http://ad.zanox.com/ppc/?18845780C1371495675T&ULP=[[' + 'http://www.libri.de/shop/action/productDetails?artiId={0}]]') + + if external or self.config.get('open_external', False): + if detail_item: + url = url_details.format(detail_item) + print(url) + open_url(QUrl(url)) + else: + detail_url = None + if detail_item: + detail_url = url_details.format(detail_item) + print(detail_url) + d = WebStoreDialog(self.gui, url, parent, detail_url) + d.setWindowTitle(self.name) + d.set_tags(self.config.get('tags', '')) + d.exec_() + + def search(self, query, max_results=10, timeout=60): + url = ('http://www.libri.de/shop/action/quickSearch?facetNodeId=6' + '&mainsearchSubmit=Los!&searchString=' + urllib2.quote(query)) + br = browser() + + counter = max_results + with closing(br.open(url, timeout=timeout)) as f: + doc = html.fromstring(f.read()) + for data in doc.xpath('//div[contains(@class, "item")]'): + if counter <= 0: + break + + details = data.xpath('./div[@class="beschreibungContainer"]') + if not details: + continue + details = details[0] + id = ''.join(details.xpath('./div[@class="text"]/a/@name')).strip() + if not id: + continue + cover_url = ''.join(details.xpath('./div[@class="bild"]/a/img/@src')) + title = ''.join(details.xpath('./div[@class="text"]/span[@class="titel"]/a/text()')).strip() + author = ''.join(details.xpath('./div[@class="text"]/span[@class="author"]/text()')).strip() + pdf = details.xpath( + 'boolean(.//span[@class="format" and contains(text(), "pdf")]/text())') + epub = details.xpath( + 'boolean(.//span[@class="format" and contains(text(), "epub")]/text())') + mobi = details.xpath( + 'boolean(.//span[@class="format" and contains(text(), "mobipocket")]/text())') + price = (''.join(data.xpath('.//span[@class="preis"]/text()'))).replace('*', '') + counter -= 1 + + s = SearchResult() + s.cover_url = cover_url + s.title = title.strip() + s.author = author.strip() + s.price = price + s.drm = SearchResult.DRM_UNKNOWN + s.detail_item = id + formats = [] + if epub: + formats.append('ePub') + if pdf: + formats.append('PDF') + if mobi: + formats.append('MOBI') + s.formats = ', '.join(formats) + + yield s From d7d8acb7cc78698315e1dd31629afa5dee4f96ca Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sat, 18 Jun 2011 14:50:52 +0100 Subject: [PATCH 07/27] Remove print statements --- src/calibre/gui2/store/libri_de_plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/calibre/gui2/store/libri_de_plugin.py b/src/calibre/gui2/store/libri_de_plugin.py index edcdff0cf2..ed93eeff0e 100644 --- a/src/calibre/gui2/store/libri_de_plugin.py +++ b/src/calibre/gui2/store/libri_de_plugin.py @@ -30,13 +30,11 @@ class LibreDEStore(BasicStoreConfig, StorePlugin): if external or self.config.get('open_external', False): if detail_item: url = url_details.format(detail_item) - print(url) open_url(QUrl(url)) else: detail_url = None if detail_item: detail_url = url_details.format(detail_item) - print(detail_url) d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) d.set_tags(self.config.get('tags', '')) From 757778fbda802c999efdd8cb8d5e2b5a1e0c8647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20D=C5=82ugosz?= Date: Sun, 19 Jun 2011 13:37:27 +0200 Subject: [PATCH 08/27] improve many authors handling --- src/calibre/gui2/store/woblink_plugin.py | 2 +- src/calibre/gui2/store/zixo_plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/store/woblink_plugin.py b/src/calibre/gui2/store/woblink_plugin.py index 69be8f2e94..9f2e29e825 100644 --- a/src/calibre/gui2/store/woblink_plugin.py +++ b/src/calibre/gui2/store/woblink_plugin.py @@ -57,7 +57,7 @@ class WoblinkStore(BasicStoreConfig, StorePlugin): cover_url = ''.join(data.xpath('.//td[@class="w10 va-t"]/a[1]/img/@src')) title = ''.join(data.xpath('.//h3[@class="title"]/a[1]/text()')) - author = ''.join(data.xpath('.//p[@class="author"]/a[1]/text()')) + author = ', '.join(data.xpath('.//p[@class="author"]/a/text()')) price = ''.join(data.xpath('.//div[@class="prices"]/p[1]/span/text()')) price = re.sub('PLN', ' zł', price) price = re.sub('\.', ',', price) diff --git a/src/calibre/gui2/store/zixo_plugin.py b/src/calibre/gui2/store/zixo_plugin.py index 419b87137a..b4e54736c0 100644 --- a/src/calibre/gui2/store/zixo_plugin.py +++ b/src/calibre/gui2/store/zixo_plugin.py @@ -53,7 +53,7 @@ class ZixoStore(BasicStoreConfig, StorePlugin): cover_url = ''.join(data.xpath('.//a[@class="productThumb"]/img/@src')) title = ''.join(data.xpath('.//a[@class="title"]/text()')) - author = ''.join(data.xpath('.//div[@class="productDescription"]/span[1]/a/text()')) + author = ','.join(data.xpath('.//div[@class="productDescription"]/span[1]/a/text()')) price = ''.join(data.xpath('.//div[@class="priceList"]/span/text()')) price = re.sub('\.', ',', price) From 91fcf1fd1602868b8e7dfe863eead7e54e757416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20D=C5=82ugosz?= Date: Sun, 19 Jun 2011 13:42:59 +0200 Subject: [PATCH 09/27] fix authors in legimi store plugin --- src/calibre/gui2/store/legimi_plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/gui2/store/legimi_plugin.py b/src/calibre/gui2/store/legimi_plugin.py index 2f69da24e5..792c7db4a7 100644 --- a/src/calibre/gui2/store/legimi_plugin.py +++ b/src/calibre/gui2/store/legimi_plugin.py @@ -58,6 +58,8 @@ class LegimiStore(BasicStoreConfig, StorePlugin): cover_url = ''.join(data.xpath('.//div[@class="item_cover_container"]/a/img/@src')) title = ''.join(data.xpath('.//div[@class="item_entries"]/h2/a/text()')) author = ''.join(data.xpath('.//div[@class="item_entries"]/span[1]/a/text()')) + author = re.sub(',','',author) + author = re.sub(';',',',author) price = ''.join(data.xpath('.//div[@class="item_entries"]/span[3]/text()')) price = re.sub(r'[^0-9,]*','',price) + ' zł' From d9895e8acc47d2e1efbf33c280341e478fd831a0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 19 Jun 2011 17:40:55 +0100 Subject: [PATCH 10/27] Fix beam ebooks not finding books if ratings are present --- src/calibre/gui2/store/beam_ebooks_de_plugin.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/calibre/gui2/store/beam_ebooks_de_plugin.py b/src/calibre/gui2/store/beam_ebooks_de_plugin.py index b589a8c310..6e31a76f8d 100644 --- a/src/calibre/gui2/store/beam_ebooks_de_plugin.py +++ b/src/calibre/gui2/store/beam_ebooks_de_plugin.py @@ -51,19 +51,19 @@ class BeamEBooksDEStore(BasicStoreConfig, StorePlugin): if counter <= 0: break - id = ''.join(data.xpath('./tr/td/div[@class="stil2"]/a/@href')).strip() + id = ''.join(data.xpath('./tr/td[1]/a/@href')).strip() if not id: continue id = id[7:] cover_url = ''.join(data.xpath('./tr/td[1]/a/img/@src')) if cover_url: cover_url = 'http://www.beam-ebooks.de' + cover_url - title = ''.join(data.xpath('./tr/td/div[@class="stil2"]/a/b/text()')) - author = ' '.join(data.xpath('./tr/td/div[@class="stil2"]/' - 'child::b/text()' - '|' - './tr/td/div[@class="stil2"]/' - 'child::strong/text()')) + temp = ''.join(data.xpath('./tr/td[1]/a/img/@alt')) + colon = temp.find(':') + if not temp.startswith('eBook') or colon < 0: + continue + author = temp[5:colon] + title = temp[colon+1:] price = ''.join(data.xpath('./tr/td[3]/text()')) pdf = data.xpath( 'boolean(./tr/td[3]/a/img[contains(@alt, "PDF")]/@alt)') From 9887c222072f7deb50bb8718efd006a48e44a383 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 09:50:33 -0600 Subject: [PATCH 11/27] Add descriptions to builtin plugins --- src/calibre/customize/builtins.py | 30 ++++++++++++++++++++- src/calibre/customize/conversion.py | 4 +++ src/calibre/ebooks/epub/fix/epubcheck.py | 4 +++ src/calibre/ebooks/epub/fix/unmanifested.py | 4 +++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index d1c5b6ccd5..62ad977c88 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -762,99 +762,127 @@ plugins += input_profiles + output_profiles class ActionAdd(InterfaceActionBase): name = 'Add Books' actual_plugin = 'calibre.gui2.actions.add:AddAction' + description = _('Add books to calibre or the connected device') class ActionFetchAnnotations(InterfaceActionBase): name = 'Fetch Annotations' actual_plugin = 'calibre.gui2.actions.annotate:FetchAnnotationsAction' + description = _('Fetch annotations from a connected Kindle (experimental)') class ActionGenerateCatalog(InterfaceActionBase): name = 'Generate Catalog' actual_plugin = 'calibre.gui2.actions.catalog:GenerateCatalogAction' + description = _('Generate a catalog of the books in your calibre library') class ActionConvert(InterfaceActionBase): name = 'Convert Books' actual_plugin = 'calibre.gui2.actions.convert:ConvertAction' + description = _('Convert books to various ebook formats') class ActionDelete(InterfaceActionBase): name = 'Remove Books' actual_plugin = 'calibre.gui2.actions.delete:DeleteAction' + description = _('Delete books from your calibre library or connected device') class ActionEditMetadata(InterfaceActionBase): name = 'Edit Metadata' actual_plugin = 'calibre.gui2.actions.edit_metadata:EditMetadataAction' + description = _('Edit the metadata of books in your calibre library') class ActionView(InterfaceActionBase): name = 'View' actual_plugin = 'calibre.gui2.actions.view:ViewAction' + description = _('Read books in your calibre library') class ActionFetchNews(InterfaceActionBase): name = 'Fetch News' actual_plugin = 'calibre.gui2.actions.fetch_news:FetchNewsAction' + description = _('Download news from the internet in ebook form') class ActionSaveToDisk(InterfaceActionBase): name = 'Save To Disk' actual_plugin = 'calibre.gui2.actions.save_to_disk:SaveToDiskAction' + description = _('Export books from your calibre library to the hard disk') class ActionShowBookDetails(InterfaceActionBase): name = 'Show Book Details' actual_plugin = 'calibre.gui2.actions.show_book_details:ShowBookDetailsAction' + description = _('Show book details in a separate popup') class ActionRestart(InterfaceActionBase): name = 'Restart' actual_plugin = 'calibre.gui2.actions.restart:RestartAction' + description = _('Restart calibre') class ActionOpenFolder(InterfaceActionBase): name = 'Open Folder' actual_plugin = 'calibre.gui2.actions.open:OpenFolderAction' + description = _('Open the folder that contains the book files in your' + ' calibre library') class ActionSendToDevice(InterfaceActionBase): name = 'Send To Device' actual_plugin = 'calibre.gui2.actions.device:SendToDeviceAction' + description = _('Send books to the connected device') class ActionConnectShare(InterfaceActionBase): name = 'Connect Share' actual_plugin = 'calibre.gui2.actions.device:ConnectShareAction' + description = _('Send books via email or the web also connect to iTunes or' + ' folders on your computer as if they are devices') class ActionHelp(InterfaceActionBase): name = 'Help' actual_plugin = 'calibre.gui2.actions.help:HelpAction' + description = _('Browse the calibre User Manual') class ActionPreferences(InterfaceActionBase): name = 'Preferences' actual_plugin = 'calibre.gui2.actions.preferences:PreferencesAction' + description = _('Customize calibre') class ActionSimilarBooks(InterfaceActionBase): name = 'Similar Books' actual_plugin = 'calibre.gui2.actions.similar_books:SimilarBooksAction' + description = _('Easily find books similar to the currently selected one') class ActionChooseLibrary(InterfaceActionBase): name = 'Choose Library' actual_plugin = 'calibre.gui2.actions.choose_library:ChooseLibraryAction' + description = _('Switch between different calibre libraries and perform' + ' maintenance on them') class ActionAddToLibrary(InterfaceActionBase): name = 'Add To Library' actual_plugin = 'calibre.gui2.actions.add_to_library:AddToLibraryAction' + description = _('Copy books from the devce to your calibre library') class ActionEditCollections(InterfaceActionBase): name = 'Edit Collections' actual_plugin = 'calibre.gui2.actions.edit_collections:EditCollectionsAction' + description = _('Edit the collections in which books are placed on your device') class ActionCopyToLibrary(InterfaceActionBase): name = 'Copy To Library' actual_plugin = 'calibre.gui2.actions.copy_to_library:CopyToLibraryAction' + description = _('Copy a book from one calibre library to another') class ActionTweakEpub(InterfaceActionBase): name = 'Tweak ePub' actual_plugin = 'calibre.gui2.actions.tweak_epub:TweakEpubAction' + description = _('Make small twekas to epub files in your calibre library') class ActionNextMatch(InterfaceActionBase): name = 'Next Match' actual_plugin = 'calibre.gui2.actions.next_match:NextMatchAction' + description = _('Find the next or previous match when searching in ' + 'your calibre library in highlight mode') class ActionStore(InterfaceActionBase): name = 'Store' author = 'John Schember' actual_plugin = 'calibre.gui2.actions.store:StoreAction' + description = _('Search for books from different book sellers') def customization_help(self, gui=False): return 'Customize the behavior of the store search.' @@ -870,7 +898,7 @@ class ActionStore(InterfaceActionBase): class ActionPluginUpdater(InterfaceActionBase): name = 'Plugin Updater' author = 'Grant Drake' - description = 'Queries the MobileRead forums for updates to plugins to install' + description = _('Get new calibre plugins or update your existing ones') actual_plugin = 'calibre.gui2.actions.plugin_updates:PluginUpdaterAction' plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, diff --git a/src/calibre/customize/conversion.py b/src/calibre/customize/conversion.py index b77ac81587..88f19df7af 100644 --- a/src/calibre/customize/conversion.py +++ b/src/calibre/customize/conversion.py @@ -259,6 +259,10 @@ class OutputFormatPlugin(Plugin): #: (option_name, recommended_value, recommendation_level) recommendations = set([]) + @property + def description(self): + return _('Convert ebooks to the %s format'%self.file_type) + def __init__(self, *args): Plugin.__init__(self, *args) self.report_progress = DummyReporter() diff --git a/src/calibre/ebooks/epub/fix/epubcheck.py b/src/calibre/ebooks/epub/fix/epubcheck.py index 81f4ce4d80..9e812e1cf4 100644 --- a/src/calibre/ebooks/epub/fix/epubcheck.py +++ b/src/calibre/ebooks/epub/fix/epubcheck.py @@ -26,6 +26,10 @@ class Epubcheck(ePubFixer): 'significant changes to your epub, complain to the epubcheck ' 'project.') + @property + def description(self): + return self.long_description + @property def fix_name(self): return 'epubcheck' diff --git a/src/calibre/ebooks/epub/fix/unmanifested.py b/src/calibre/ebooks/epub/fix/unmanifested.py index da7a9a9d0e..98fdd4615f 100644 --- a/src/calibre/ebooks/epub/fix/unmanifested.py +++ b/src/calibre/ebooks/epub/fix/unmanifested.py @@ -22,6 +22,10 @@ class Unmanifested(ePubFixer): 'the manifest or delete them as specified by the ' 'delete unmanifested option.') + @property + def description(self): + return self.long_description + @property def fix_name(self): return 'unmanifested' From 9672083e842fbf4fc28bfe0bb20228ae76239b52 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 09:55:03 -0600 Subject: [PATCH 12/27] Frontline by DM. Fixes #799802 (New recipe for Frontline magazine) --- recipes/frontlineonnet.recipe | 81 +++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 recipes/frontlineonnet.recipe diff --git a/recipes/frontlineonnet.recipe b/recipes/frontlineonnet.recipe new file mode 100644 index 0000000000..3b65e4bb18 --- /dev/null +++ b/recipes/frontlineonnet.recipe @@ -0,0 +1,81 @@ +__license__ = 'GPL v3' +__copyright__ = '2011, Darko Miletic ' +''' +frontlineonnet.com +''' + +import re +from calibre import strftime +from calibre.web.feeds.news import BasicNewsRecipe + +class Frontlineonnet(BasicNewsRecipe): + title = 'Frontline' + __author__ = 'Darko Miletic' + description = "India's national magazine" + publisher = 'Frontline' + category = 'news, politics, India' + no_stylesheets = True + delay = 1 + INDEX = 'http://frontlineonnet.com/' + use_embedded_content = False + encoding = 'cp1252' + language = 'en_IN' + publication_type = 'magazine' + masthead_url = 'http://frontlineonnet.com/images/newfline.jpg' + extra_css = """ + body{font-family: Verdana,Arial,Helvetica,sans-serif} + img{margin-top:0.5em; margin-bottom: 0.7em; display: block} + """ + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language + , 'linearize_tables' : True + } + + preprocess_regexps = [ + (re.compile(r'.*?title', re.DOTALL|re.IGNORECASE),lambda match: '') + ,(re.compile(r'', re.DOTALL|re.IGNORECASE),lambda match: '') + ,(re.compile(r'
', re.DOTALL|re.IGNORECASE),lambda match: '
') + ,(re.compile(r'
', re.DOTALL|re.IGNORECASE),lambda match: '') + ] + + keep_only_tags= [ + dict(name='font', attrs={'class':'storyhead'}) + ,dict(attrs={'class':'byline'}) + ] + remove_attributes=['size','noshade','border'] + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + for item in soup.findAll('img'): + if not item.has_key('alt'): + item['alt'] = 'image' + return soup + + def parse_index(self): + articles = [] + soup = self.index_to_soup(self.INDEX) + for feed_link in soup.findAll('a',href=True): + if feed_link['href'].startswith('stories/'): + url = self.INDEX + feed_link['href'] + title = self.tag_to_string(feed_link) + date = strftime(self.timefmt) + articles.append({ + 'title' :title + ,'date' :date + ,'url' :url + ,'description':'' + }) + return [('Frontline', articles)] + + def print_version(self, url): + return "http://www.hinduonnet.com/thehindu/thscrip/print.pl?prd=fline&file=" + url.rpartition('/')[2] + + def image_url_processor(self, baseurl, url): + return url.replace('../images/', self.INDEX + 'images/').strip() From 47415caf3b46ac8a9d7de1d8f8e41ff2e3217e08 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 12:40:25 -0600 Subject: [PATCH 13/27] Print out job sarted notification for device jobs to the debug queue --- src/calibre/gui2/device.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 99f795cdda..9f6381b859 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -49,6 +49,9 @@ class DeviceJob(BaseJob): # {{{ self._aborted = False def start_work(self): + if DEBUG: + prints('Job:', self.id, self.description, 'started', + safe_encode=True) self.start_time = time.time() self.job_manager.changed_queue.put(self) From 8e22f5c4325362dc234f58a30af6b7d0e61d3848 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 13:40:04 -0600 Subject: [PATCH 14/27] More device job sequencing debug printouts --- src/calibre/gui2/device.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 9f6381b859..a527cc2e27 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -58,10 +58,17 @@ class DeviceJob(BaseJob): # {{{ def job_done(self): self.duration = time.time() - self.start_time self.percent = 1 + if DEBUG: + prints('DeviceJob:', self.id, self.description, + 'done, calling callback', safe_encode=True) + try: self.callback_on_done(self) except: pass + if DEBUG: + prints('DeviceJob:', self.id, self.description, + 'callback returned', safe_encode=True) self.job_manager.changed_queue.put(self) def report_progress(self, percent, msg=''): From 15d8272efe25bae56731d49dd46fbbf4377b84d4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 15:54:32 -0600 Subject: [PATCH 15/27] ... --- src/calibre/library/database2.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 67c67b1ff7..51ada244e3 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -201,16 +201,14 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): dbprefs = DBPrefs(self) for key in default_prefs: # be sure that prefs not to be copied are listed below - if key in ['news_to_be_synced']: + if key in frozenset(['news_to_be_synced']): continue - try: - dbprefs[key] = default_prefs[key] - except: - pass # ignore options that don't exist anymore - fmvals = [f for f in default_prefs['field_metadata'].values() if f['is_custom']] - for f in fmvals: - self.create_custom_column(f['label'], f['name'], f['datatype'], - f['is_multiple'] is not None, f['is_editable'], f['display']) + dbprefs[key] = default_prefs[key] + if 'field_metadata' in default_prefs: + fmvals = [f for f in default_prefs['field_metadata'].values() if f['is_custom']] + for f in fmvals: + self.create_custom_column(f['label'], f['name'], f['datatype'], + f['is_multiple'] is not None, f['is_editable'], f['display']) self.initialize_dynamic() def get_property(self, idx, index_is_id=False, loc=-1): From 50dadb45cf0c5e80e16fcbcb9b193dcdb88356c8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 16:13:18 -0600 Subject: [PATCH 16/27] Start work on new db backend --- src/calibre/db/__init__.py | 67 ++++++ src/calibre/db/backend.py | 404 +++++++++++++++++++++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 src/calibre/db/__init__.py create mode 100644 src/calibre/db/backend.py diff --git a/src/calibre/db/__init__.py b/src/calibre/db/__init__.py new file mode 100644 index 0000000000..4384cab2da --- /dev/null +++ b/src/calibre/db/__init__.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + +''' +Rewrite of the calibre database backend. + +Broad Objectives: + + * Use the sqlite db only as a datastore. i.e. do not do + sorting/searching/concatenation or anything else in sqlite. Instead + mirror the sqlite tables in memory, create caches and lookup maps from + them and create a set_* API that updates the memory caches and the sqlite + correctly. + + * Move from keeping a list of books in memory as a cache to a per table + cache. This allows much faster search and sort operations at the expense + of slightly slower lookup operations. That slowdown can be mitigated by + keeping lots of maps and updating them in the set_* API. Also + get_categories becomes blazingly fast. + + * Separate the database layer from the cache layer more cleanly. Rather + than having the db layer refer to the cache layer and vice versa, the + cache layer will refer to the db layer only and the new API will be + defined on the cache layer. + + * Get rid of index_is_id and other poor design decisions + + * Minimize the API as much as possible and define it cleanly + + * Do not change the on disk format of metadata.db at all (this is for + backwards compatibility) + + * Get rid of the need for a separate db access thread by switching to apsw + to access sqlite, which is thread safe + + * The new API will have methods to efficiently do bulk operations and will + use shared/exclusive/pending locks to serialize access to the in-mem data + structures. Use the same locking scheme as sqlite itself does. + +How this will proceed: + + 1. Create the new API + 2. Create a test suite for it + 3. Write a replacement for LibraryDatabase2 that uses the new API + internally + 4. Lots of testing of calibre with the new LibraryDatabase2 + 5. Gradually migrate code to use the (much faster) new api wherever possible (the new api + will be exposed via db.new_api) + + I plan to work on this slowly, in parallel to normal calibre development + work. + +Various things that require other things before they can be migrated: + 1. From initialize_dynamic(): set_saved_searches, + load_user_template_functions. Also add custom + columns/categories/searches info into + self.field_metadata. Finally, implement metadata dirtied + functionality. + +''' diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py new file mode 100644 index 0000000000..4e6c028b93 --- /dev/null +++ b/src/calibre/db/backend.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +# Imports {{{ +import os, shutil, uuid, json +from functools import partial + +import apsw + +from calibre import isbytestring, force_unicode, prints +from calibre.constants import (iswindows, filesystem_encoding, + preferred_encoding) +from calibre.ptempfile import PersistentTemporaryFile +from calibre.library.schema_upgrades import SchemaUpgrade +from calibre.library.field_metadata import FieldMetadata +from calibre.ebooks.metadata import title_sort, author_to_author_sort +from calibre.utils.icu import strcmp +from calibre.utils.config import to_json, from_json, prefs, tweaks +from calibre.utils.date import utcfromtimestamp +# }}} + +''' +Differences in semantics from pysqlite: + + 1. execute/executemany/executescript operate in autocommit mode + +''' + +class DynamicFilter(object): # {{{ + + 'No longer used, present for legacy compatibility' + + def __init__(self, name): + self.name = name + self.ids = frozenset([]) + + def __call__(self, id_): + return int(id_ in self.ids) + + def change(self, ids): + self.ids = frozenset(ids) +# }}} + +class DBPrefs(dict): # {{{ + + 'Store preferences as key:value pairs in the db' + + def __init__(self, db): + dict.__init__(self) + self.db = db + self.defaults = {} + self.disable_setting = False + for key, val in self.db.conn.get('SELECT key,val FROM preferences'): + try: + val = self.raw_to_object(val) + except: + prints('Failed to read value for:', key, 'from db') + continue + dict.__setitem__(self, key, val) + + def raw_to_object(self, raw): + if not isinstance(raw, unicode): + raw = raw.decode(preferred_encoding) + return json.loads(raw, object_hook=from_json) + + def to_raw(self, val): + return json.dumps(val, indent=2, default=to_json) + + def __getitem__(self, key): + try: + return dict.__getitem__(self, key) + except KeyError: + return self.defaults[key] + + def __delitem__(self, key): + dict.__delitem__(self, key) + self.db.conn.execute('DELETE FROM preferences WHERE key=?', (key,)) + + def __setitem__(self, key, val): + if self.disable_setting: + return + raw = self.to_raw(val) + self.db.conn.execute('INSERT OR REPLACE INTO preferences (key,val) VALUES (?,?)', (key, + raw)) + dict.__setitem__(self, key, val) + + def set(self, key, val): + self.__setitem__(key, val) + +# }}} + +# Extra collators {{{ +def pynocase(one, two, encoding='utf-8'): + if isbytestring(one): + try: + one = one.decode(encoding, 'replace') + except: + pass + if isbytestring(two): + try: + two = two.decode(encoding, 'replace') + except: + pass + return cmp(one.lower(), two.lower()) + +def _author_to_author_sort(x): + if not x: return '' + return author_to_author_sort(x.replace('|', ',')) + +def icu_collator(s1, s2): + return strcmp(force_unicode(s1, 'utf-8'), force_unicode(s2, 'utf-8')) +# }}} + +class Connection(apsw.Connection): # {{{ + + BUSY_TIMEOUT = 2000 # milliseconds + + def __init__(self, path): + apsw.Connection.__init__(self, path) + + self.setbusytimeout(self.BUSY_TIMEOUT) + self.execute('pragma cache_size=5000') + self.conn.execute('pragma temp_store=2') + + encoding = self.execute('pragma encoding').fetchone()[0] + self.conn.create_collation('PYNOCASE', partial(pynocase, + encoding=encoding)) + + self.conn.create_function('title_sort', 1, title_sort) + self.conn.create_function('author_to_author_sort', 1, + _author_to_author_sort) + + self.conn.create_function('uuid4', 0, lambda : str(uuid.uuid4())) + + # Dummy functions for dynamically created filters + self.conn.create_function('books_list_filter', 1, lambda x: 1) + self.conn.create_collation('icucollate', icu_collator) + + def create_dynamic_filter(self, name): + f = DynamicFilter(name) + self.conn.create_function(name, 1, f) + + def get(self, *args, **kw): + ans = self.cursor().execute(*args) + if kw.get('all', True): + return ans.fetchall() + for row in ans: + return ans[0] + + def execute(self, sql, bindings=None): + cursor = self.cursor() + return cursor.execute(sql, bindings) + + def executemany(self, sql, sequence_of_bindings): + return self.cursor().executemany(sql, sequence_of_bindings) + + def executescript(self, sql): + with self: + # Use an explicit savepoint so that even if this is called + # while a transaction is active, it is atomic + return self.cursor().execute(sql) +# }}} + +class DB(SchemaUpgrade): + + PATH_LIMIT = 40 if iswindows else 100 + WINDOWS_LIBRARY_PATH_LIMIT = 75 + + # Initialize database {{{ + + def __init__(self, library_path, default_prefs=None, read_only=False): + try: + if isbytestring(library_path): + library_path = library_path.decode(filesystem_encoding) + except: + import traceback + traceback.print_exc() + + self.field_metadata = FieldMetadata() + + self.library_path = os.path.abspath(library_path) + self.dbpath = os.path.join(library_path, 'metadata.db') + self.dbpath = os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', + self.dbpath) + + if iswindows and len(self.library_path) + 4*self.PATH_LIMIT + 10 > 259: + raise ValueError(_( + 'Path to library too long. Must be less than' + ' %d characters.')%(259-4*self.PATH_LIMIT-10)) + exists = self._exists = os.path.exists(self.dbpath) + if not exists: + # Be more strict when creating new libraries as the old calculation + # allowed for max path lengths of 265 chars. + if (iswindows and len(self.library_path) > + self.WINDOWS_LIBRARY_PATH_LIMIT): + raise ValueError(_( + 'Path to library too long. Must be less than' + ' %d characters.')%self.WINDOWS_LIBRARY_PATH_LIMIT) + + if read_only and os.path.exists(self.dbpath): + # Work on only a copy of metadata.db to ensure that + # metadata.db is not changed + pt = PersistentTemporaryFile('_metadata_ro.db') + pt.close() + shutil.copyfile(self.dbpath, pt.name) + self.dbpath = pt.name + + self.is_case_sensitive = (not iswindows and + not os.path.exists(self.dbpath.replace('metadata.db', + 'MeTAdAtA.dB'))) + + self._conn = None + + if self.user_version == 0: + self.initialize_database() + + SchemaUpgrade.__init__(self) + # Guarantee that the library_id is set + self.library_id + + self.initialize_prefs(default_prefs) + + # Fix legacy triggers and columns + self.conn.executescript(''' + DROP TRIGGER IF EXISTS author_insert_trg; + CREATE TEMP TRIGGER author_insert_trg + AFTER INSERT ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) WHERE id=NEW.id; + END; + DROP TRIGGER IF EXISTS author_update_trg; + CREATE TEMP TRIGGER author_update_trg + BEFORE UPDATE ON authors + BEGIN + UPDATE authors SET sort=author_to_author_sort(NEW.name) + WHERE id=NEW.id AND name <> NEW.name; + END; + UPDATE authors SET sort=author_to_author_sort(name) WHERE sort IS NULL; + ''') + + def initialize_prefs(self, default_prefs): + self.prefs = DBPrefs(self) + + if default_prefs is not None and not self._exists: + # Only apply default prefs to a new database + for key in default_prefs: + # be sure that prefs not to be copied are listed below + if key not in frozenset(['news_to_be_synced']): + self.prefs[key] = default_prefs[key] + if 'field_metadata' in default_prefs: + fmvals = [f for f in default_prefs['field_metadata'].values() + if f['is_custom']] + for f in fmvals: + self.create_custom_column(f['label'], f['name'], + f['datatype'], f['is_multiple'] is not None, + f['is_editable'], f['display']) + + defs = self.prefs.defaults + defs['gui_restriction'] = defs['cs_restriction'] = '' + defs['categories_using_hierarchy'] = [] + defs['column_color_rules'] = [] + + # Migrate the bool tristate tweak + defs['bools_are_tristate'] = \ + tweaks.get('bool_custom_columns_are_tristate', 'yes') == 'yes' + if self.prefs.get('bools_are_tristate') is None: + self.prefs.set('bools_are_tristate', defs['bools_are_tristate']) + + # Migrate column coloring rules + if self.prefs.get('column_color_name_1', None) is not None: + from calibre.library.coloring import migrate_old_rule + old_rules = [] + for i in range(1, 6): + col = self.prefs.get('column_color_name_'+str(i), None) + templ = self.prefs.get('column_color_template_'+str(i), None) + if col and templ: + try: + del self.prefs['column_color_name_'+str(i)] + rules = migrate_old_rule(self.field_metadata, templ) + for templ in rules: + old_rules.append((col, templ)) + except: + pass + if old_rules: + self.prefs['column_color_rules'] += old_rules + + # Migrate saved search and user categories to db preference scheme + def migrate_preference(key, default): + oldval = prefs[key] + if oldval != default: + self.prefs[key] = oldval + prefs[key] = default + if key not in self.prefs: + self.prefs[key] = default + + migrate_preference('user_categories', {}) + migrate_preference('saved_searches', {}) + + # migrate grouped_search_terms + if self.prefs.get('grouped_search_terms', None) is None: + try: + ogst = tweaks.get('grouped_search_terms', {}) + ngst = {} + for t in ogst: + ngst[icu_lower(t)] = ogst[t] + self.prefs.set('grouped_search_terms', ngst) + except: + pass + + # Rename any user categories with names that differ only in case + user_cats = self.prefs.get('user_categories', []) + catmap = {} + for uc in user_cats: + ucl = icu_lower(uc) + if ucl not in catmap: + catmap[ucl] = [] + catmap[ucl].append(uc) + cats_changed = False + for uc in catmap: + if len(catmap[uc]) > 1: + prints('found user category case overlap', catmap[uc]) + cat = catmap[uc][0] + suffix = 1 + while icu_lower((cat + unicode(suffix))) in catmap: + suffix += 1 + prints('Renaming user category %s to %s'%(cat, cat+unicode(suffix))) + user_cats[cat + unicode(suffix)] = user_cats[cat] + del user_cats[cat] + cats_changed = True + if cats_changed: + self.prefs.set('user_categories', user_cats) + + @property + def conn(self): + if self._conn is None: + self._conn = apsw.Connection(self.dbpath) + if self._exists and self.user_version == 0: + self._conn.close() + os.remove(self.dbpath) + self._conn = apsw.Connection(self.dbpath) + return self._conn + + @dynamic_property + def user_version(self): + doc = 'The user version of this database' + + def fget(self): + return self.conn.get('pragma user_version;', all=False) + + def fset(self, val): + self.conn.execute('pragma user_version=%d'%int(val)) + + return property(doc=doc, fget=fget, fset=fset) + + def initialize_database(self): + metadata_sqlite = P('metadata_sqlite.sql', data=True, + allow_user_override=False).decode('utf-8') + self.conn.executescript(metadata_sqlite) + if self.user_version == 0: + self.user_version = 1 + # }}} + + # Database layer API {{{ + + @classmethod + def exists_at(cls, path): + return path and os.path.exists(os.path.join(path, 'metadata.db')) + + @dynamic_property + def library_id(self): + doc = ('The UUID for this library. As long as the user only operates' + ' on libraries with calibre, it will be unique') + + def fget(self): + if getattr(self, '_library_id_', None) is None: + ans = self.conn.get('SELECT uuid FROM library_id', all=False) + if ans is None: + ans = str(uuid.uuid4()) + self.library_id = ans + else: + self._library_id_ = ans + return self._library_id_ + + def fset(self, val): + self._library_id_ = unicode(val) + self.conn.execute(''' + DELETE FROM library_id; + INSERT INTO library_id (uuid) VALUES (?); + ''', self._library_id_) + + return property(doc=doc, fget=fget, fset=fset) + + def last_modified(self): + ''' Return last modified time as a UTC datetime object ''' + return utcfromtimestamp(os.stat(self.dbpath).st_mtime) + + # }}} + From a34a2a0e0574dfb9d7b4ae6374ed2034e2bf9d85 Mon Sep 17 00:00:00 2001 From: John Schember Date: Mon, 20 Jun 2011 19:01:12 -0400 Subject: [PATCH 17/27] Fix bug #799367: Could not download ebooks from store --- src/calibre/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index 3a35feb66f..663b4d8a50 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -591,8 +591,10 @@ def get_download_filename(url, cookie_file=None): cj.load(cookie_file) br.set_cookiejar(cj) + last_part_name = '' try: with closing(br.open(url)) as r: + last_part_name = r.geturl().split('/')[-1] disposition = r.info().get('Content-disposition', '') for p in disposition.split(';'): if 'filename' in p: @@ -612,7 +614,7 @@ def get_download_filename(url, cookie_file=None): traceback.print_exc() if not filename: - filename = r.geturl().split('/')[-1] + filename = last_part_name return filename From 83634d8a542aadfefbd74767aac15f6286af5a22 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Mon, 20 Jun 2011 23:33:54 -0300 Subject: [PATCH 18/27] Expired books should be reported as a Collection - The next server sync should delete them --- src/calibre/devices/kobo/driver.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index e606f65e38..6a9ea56ceb 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -100,7 +100,7 @@ class KOBO(USBMS): for idx,b in enumerate(bl): bl_cache[b.lpath] = idx - def update_booklist(prefix, path, title, authors, mime, date, ContentType, ImageID, readstatus, MimeType): + def update_booklist(prefix, path, title, authors, mime, date, ContentType, ImageID, readstatus, MimeType, expired): changed = False try: lpath = path.partition(self.normalize_path(prefix))[2] @@ -118,6 +118,11 @@ class KOBO(USBMS): elif readstatus == 3: playlist_map[lpath]= "Closed" + # Related to a bug in the Kobo firmware that leaves an expired row for deleted books + # this shows an expired Collection so the user can decide to delete the book + if expired == 3: + playlist_map[lpath] = "Expired" + path = self.normalize_path(path) # print "Normalized FileName: " + path @@ -131,7 +136,8 @@ class KOBO(USBMS): imagename = self.normalize_path(self._main_prefix + '.kobo/images/' + ImageID + ' - N3_LIBRARY_FULL.parsed') #print "Image name Normalized: " + imagename - + if not os.path.exists(imagename): + debug_print("Strange - The image name does not exist - title: ", title) if imagename is not None: bl[idx].thumbnail = ImageWrapper(imagename) if (ContentType != '6' and MimeType != 'Shortcover'): @@ -192,7 +198,7 @@ class KOBO(USBMS): self.dbversion = result[0] query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ - 'ImageID, ReadStatus from content where BookID is Null' + 'ImageID, ReadStatus, ___ExpirationStatus from content where BookID is Null' cursor.execute (query) @@ -207,10 +213,10 @@ class KOBO(USBMS): # debug_print("mime:", mime) if oncard != 'carda' and oncard != 'cardb' and not row[3].startswith("file:///mnt/sd/"): - changed = update_booklist(self._main_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4]) + changed = update_booklist(self._main_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8]) # print "shortbook: " + path elif oncard == 'carda' and row[3].startswith("file:///mnt/sd/"): - changed = update_booklist(self._card_a_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4]) + changed = update_booklist(self._card_a_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8]) if changed: need_sync = True From 43c9ebe77f3294f14f13036c59d1c58a290f346a Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Tue, 21 Jun 2011 00:06:25 -0300 Subject: [PATCH 19/27] Emulate the Kobo device delete more closely. Leave the Book record, the next server sync should delete them --- src/calibre/devices/kobo/driver.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 6a9ea56ceb..b523b7f296 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -280,8 +280,12 @@ class KOBO(USBMS): cursor.execute('delete from content_keys where volumeid = ?', t) # Delete the chapters associated with the book next - t = (ContentID,ContentID,) - cursor.execute('delete from content where BookID = ? or ContentID = ?', t) + t = (ContentID,) + # Kobo does not delete the Book row (ie the row where the BookID is Null) + # The next server sync should remove the row + cursor.execute('delete from content where BookID = ?', t) + cursor.execute('update content set ReadStatus=0, FirstTimeReading = \'true\', ___PercentRead=0, ___ExpirationStatus=3 ' \ + 'where BookID is Null and ContentID =?',t) connection.commit() From f9dc7d978011f09fc1ef046da316b3ee38871fc2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 20 Jun 2011 21:48:18 -0600 Subject: [PATCH 20/27] EOD on the new db api --- resources/metadata_sqlite.sql | 6 +- src/calibre/db/backend.py | 39 +++++++++- src/calibre/db/tables.py | 143 ++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/calibre/db/tables.py diff --git a/resources/metadata_sqlite.sql b/resources/metadata_sqlite.sql index 9c4f666449..aa29d4b8de 100644 --- a/resources/metadata_sqlite.sql +++ b/resources/metadata_sqlite.sql @@ -13,8 +13,10 @@ CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, isbn TEXT DEFAULT "" COLLATE NOCASE, lccn TEXT DEFAULT "" COLLATE NOCASE, path TEXT NOT NULL DEFAULT "", - flags INTEGER NOT NULL DEFAULT 1 - , uuid TEXT, has_cover BOOL DEFAULT 0, last_modified TIMESTAMP NOT NULL DEFAULT "2000-01-01 00:00:00+00:00"); + flags INTEGER NOT NULL DEFAULT 1, + uuid TEXT, + has_cover BOOL DEFAULT 0, + last_modified TIMESTAMP NOT NULL DEFAULT "2000-01-01 00:00:00+00:00"); CREATE TABLE books_authors_link ( id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL, diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 4e6c028b93..159612e52d 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -23,6 +23,8 @@ from calibre.ebooks.metadata import title_sort, author_to_author_sort from calibre.utils.icu import strcmp from calibre.utils.config import to_json, from_json, prefs, tweaks from calibre.utils.date import utcfromtimestamp +from calibre.db.tables import (OneToOneTable, ManyToOneTable, ManyToManyTable, + SizeTable, FormatsTable, AuthorsTable, IdentifiersTable) # }}} ''' @@ -167,7 +169,7 @@ class Connection(apsw.Connection): # {{{ return self.cursor().execute(sql) # }}} -class DB(SchemaUpgrade): +class DB(object, SchemaUpgrade): PATH_LIMIT = 40 if iswindows else 100 WINDOWS_LIBRARY_PATH_LIMIT = 75 @@ -400,5 +402,40 @@ class DB(SchemaUpgrade): ''' Return last modified time as a UTC datetime object ''' return utcfromtimestamp(os.stat(self.dbpath).st_mtime) + def read_tables(self): + tables = {} + for col in ('title', 'sort', 'author_sort', 'series_index', 'comments', + 'timestamp', 'published', 'uuid', 'path', 'cover', + 'last_modified'): + metadata = self.field_metadata[col].copy() + if metadata['table'] is None: + metadata['table'], metadata['column'] == 'books', ('has_cover' + if col == 'cover' else col) + tables[col] = OneToOneTable(col, metadata) + + for col in ('series', 'publisher', 'rating'): + tables[col] = ManyToOneTable(col, self.field_metadata[col].copy()) + + for col in ('authors', 'tags', 'formats', 'identifiers'): + cls = { + 'authors':AuthorsTable, + 'formats':FormatsTable, + 'identifiers':IdentifiersTable, + }.get(col, ManyToManyTable) + tables[col] = cls(col, self.field_metadata[col].copy()) + + tables['size'] = SizeTable('size', self.field_metadata['size'].copy()) + + with self.conn: # Use a single transaction, to ensure nothing modifies + # the db while we are reading + for table in tables.itervalues(): + try: + table.read() + except: + prints('Failed to read table:', table.name) + raise + + return tables + # }}} diff --git a/src/calibre/db/tables.py b/src/calibre/db/tables.py new file mode 100644 index 0000000000..7240b3ec6e --- /dev/null +++ b/src/calibre/db/tables.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from datetime import datetime + +from dateutil.tz import tzoffset + +from calibre.constants import plugins +from calibre.utils.date import parse_date, local_tz +from calibre.ebooks.metadata import author_to_author_sort + +_c_speedup = plugins['speedup'][0] + +def _c_convert_timestamp(val): + if not val: + return None + try: + ret = _c_speedup.parse_date(val.strip()) + except: + ret = None + if ret is None: + return parse_date(val, as_utc=False) + year, month, day, hour, minutes, seconds, tzsecs = ret + return datetime(year, month, day, hour, minutes, seconds, + tzinfo=tzoffset(None, tzsecs)).astimezone(local_tz) + +class Table(object): + + def __init__(self, name, metadata): + self.name, self.metadata = name, metadata + + # self.adapt() maps values from the db to python objects + self.adapt = \ + { + 'datetime': _c_convert_timestamp, + 'bool': bool + }.get( + metadata['datatype'], lambda x: x) + if name == 'authors': + # Legacy + self.adapt = lambda x: x.replace('|', ',') if x else None + +class OneToOneTable(Table): + + def read(self, db): + self.book_col_map = {} + idcol = 'id' if self.metadata['table'] == 'books' else 'book' + for row in db.conn.execute('SELECT {0}, {1} FROM {2}'.format(idcol, + self.metadata['column'], self.metadata['table'])): + self.book_col_map[row[0]] = self.adapt(row[1]) + +class SizeTable(OneToOneTable): + + def read(self, db): + self.book_col_map = {} + for row in db.conn.execute( + 'SELECT books.id, (SELECT MAX(uncompressed_size) FROM data ' + 'WHERE data.book=books.id) FROM books'): + self.book_col_map[row[0]] = self.adapt(row[1]) + +class ManyToOneTable(Table): + + def read(self, db): + self.id_map = {} + self.extra_map = {} + self.col_book_map = {} + self.book_col_map = {} + self.read_id_maps(db) + self.read_maps(db) + + def read_id_maps(self, db): + for row in db.conn.execute('SELECT id, {0} FROM {1}'.format( + self.metadata['name'], self.metadata['table'])): + if row[1]: + self.id_map[row[0]] = self.adapt(row[1]) + + def read_maps(self, db): + for row in db.conn.execute( + 'SELECT book, {0} FROM books_{1}_link'.format( + self.metadata['link_column'], self.metadata['table'])): + if row[1] not in self.col_book_map: + self.col_book_map[row[1]] = [] + self.col_book_map.append(row[0]) + self.book_col_map[row[0]] = row[1] + +class ManyToManyTable(ManyToOneTable): + + def read_maps(self, db): + for row in db.conn.execute( + 'SELECT book, {0} FROM books_{1}_link'.format( + self.metadata['link_column'], self.metadata['table'])): + if row[1] not in self.col_book_map: + self.col_book_map[row[1]] = [] + self.col_book_map.append(row[0]) + if row[0] not in self.book_col_map: + self.book_col_map[row[0]] = [] + self.book_col_map[row[0]].append(row[1]) + +class AuthorsTable(ManyToManyTable): + + def read_id_maps(self, db): + for row in db.conn.execute( + 'SELECT id, name, sort FROM authors'): + self.id_map[row[0]] = row[1] + self.extra_map[row[0]] = (row[2] if row[2] else + author_to_author_sort(row[1])) + +class FormatsTable(ManyToManyTable): + + def read_id_maps(self, db): + pass + + def read_maps(self, db): + for row in db.conn.execute('SELECT book, format, name FROM data'): + if row[1] is not None: + if row[1] not in self.col_book_map: + self.col_book_map[row[1]] = [] + self.col_book_map.append(row[0]) + if row[0] not in self.book_col_map: + self.book_col_map[row[0]] = [] + self.book_col_map[row[0]].append((row[1], row[2])) + +class IdentifiersTable(ManyToManyTable): + + def read_id_maps(self, db): + pass + + def read_maps(self, db): + for row in db.conn.execute('SELECT book, type, val FROM identifiers'): + if row[1] is not None and row[2] is not None: + if row[1] not in self.col_book_map: + self.col_book_map[row[1]] = [] + self.col_book_map.append(row[0]) + if row[0] not in self.book_col_map: + self.book_col_map[row[0]] = [] + self.book_col_map[row[0]].append((row[1], row[2])) + From f2d20aebaa0e9236c222cb840bc218a871e51759 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 21 Jun 2011 14:15:30 +0100 Subject: [PATCH 21/27] Allow custom comments as a search/replace target field --- src/calibre/gui2/dialogs/metadata_bulk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index 8829dc97c0..ce21eba00e 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -361,7 +361,7 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): fm = self.db.field_metadata for f in fm: if (f in ['author_sort'] or - (fm[f]['datatype'] in ['text', 'series', 'enumeration'] + (fm[f]['datatype'] in ['text', 'series', 'enumeration', 'comments'] and fm[f].get('search_terms', None) and f not in ['formats', 'ondevice']) or (fm[f]['datatype'] in ['int', 'float', 'bool'] and From 56f15785102590c4b03d95dfeef58f0f787d8851 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 21 Jun 2011 14:15:56 +0100 Subject: [PATCH 22/27] The quickview window described in http://www.mobileread.com/forums/showpost.php?p=1620979&postcount=60 --- src/calibre/customize/builtins.py | 8 +- src/calibre/gui2/actions/show_quickview.py | 41 +++++ src/calibre/gui2/dialogs/quickview.py | 171 +++++++++++++++++++++ src/calibre/gui2/dialogs/quickview.ui | 131 ++++++++++++++++ src/calibre/library/database2.py | 17 +- 5 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 src/calibre/gui2/actions/show_quickview.py create mode 100644 src/calibre/gui2/dialogs/quickview.py create mode 100644 src/calibre/gui2/dialogs/quickview.ui diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index d1c5b6ccd5..116493e9db 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -791,6 +791,10 @@ class ActionFetchNews(InterfaceActionBase): name = 'Fetch News' actual_plugin = 'calibre.gui2.actions.fetch_news:FetchNewsAction' +class ActionQuickview(InterfaceActionBase): + name = 'Show Quickview' + actual_plugin = 'calibre.gui2.actions.show_quickview:ShowQuickviewAction' + class ActionSaveToDisk(InterfaceActionBase): name = 'Save To Disk' actual_plugin = 'calibre.gui2.actions.save_to_disk:SaveToDiskAction' @@ -875,8 +879,8 @@ class ActionPluginUpdater(InterfaceActionBase): plugins += [ActionAdd, ActionFetchAnnotations, ActionGenerateCatalog, ActionConvert, ActionDelete, ActionEditMetadata, ActionView, - ActionFetchNews, ActionSaveToDisk, ActionShowBookDetails, - ActionRestart, ActionOpenFolder, ActionConnectShare, + ActionFetchNews, ActionSaveToDisk, ActionQuickview, + ActionShowBookDetails,ActionRestart, ActionOpenFolder, ActionConnectShare, ActionSendToDevice, ActionHelp, ActionPreferences, ActionSimilarBooks, ActionAddToLibrary, ActionEditCollections, ActionChooseLibrary, ActionCopyToLibrary, ActionTweakEpub, ActionNextMatch, ActionStore, diff --git a/src/calibre/gui2/actions/show_quickview.py b/src/calibre/gui2/actions/show_quickview.py new file mode 100644 index 0000000000..61a41b08ae --- /dev/null +++ b/src/calibre/gui2/actions/show_quickview.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + +from calibre.gui2.actions import InterfaceAction +from calibre.gui2.dialogs.quickview import Quickview +from calibre.gui2 import error_dialog + +class ShowQuickviewAction(InterfaceAction): + + name = 'Show quickview' + action_spec = (_('Show quickview'), 'user_profile.png', None, + _('Q')) + dont_add_to = frozenset(['menubar-device', 'toolbar-device', 'context-menu-device']) + action_type = 'current' + + current_instance = None + + def genesis(self): + self.qaction.triggered.connect(self.show_quickview) + + def show_quickview(self, *args): + if self.current_instance: + if not self.current_instance.is_closed: + return + self.current_instance = None + if self.gui.current_view() is not self.gui.library_view: + error_dialog(self.gui, _('No quickview available'), + _('Quickview is not available for books ' + 'on the device.')).exec_() + return + index = self.gui.library_view.currentIndex() + if index.isValid(): + self.current_instance = \ + Quickview(self.gui, self.gui.library_view, index) + self.current_instance.show() + diff --git a/src/calibre/gui2/dialogs/quickview.py b/src/calibre/gui2/dialogs/quickview.py new file mode 100644 index 0000000000..5dcdbf04fa --- /dev/null +++ b/src/calibre/gui2/dialogs/quickview.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__docformat__ = 'restructuredtext en' + + +from PyQt4.Qt import (Qt, QDialog, QAbstractItemView, QTableWidgetItem, + QListWidgetItem, QByteArray, QModelIndex) + +from calibre.gui2.dialogs.quickview_ui import Ui_Quickview +from calibre.utils.icu import sort_key +from calibre.gui2 import gprefs + +class tableItem(QTableWidgetItem): + + def __init__(self, val): + QTableWidgetItem.__init__(self, val) + self.setFlags(Qt.ItemIsEnabled|Qt.ItemIsSelectable) + + def __ge__(self, other): + return sort_key(unicode(self.text())) >= sort_key(unicode(other.text())) + + def __lt__(self, other): + return sort_key(unicode(self.text())) < sort_key(unicode(other.text())) + +class Quickview(QDialog, Ui_Quickview): + + def __init__(self, gui, view, row): + QDialog.__init__(self, gui, flags=Qt.Window) + Ui_Quickview.__init__(self) + self.setupUi(self) + self.isClosed = False + + try: + geom = gprefs.get('quickview_dialog_geometry', bytearray('')) + self.restoreGeometry(QByteArray(geom)) + except: + pass + + icon = self.windowIcon() + self.setWindowFlags(self.windowFlags()&(~Qt.WindowContextHelpButtonHint)) + self.setWindowIcon(icon) + + self.db = view.model().db + self.view = view + self.gui = gui + + self.items.setSelectionMode(QAbstractItemView.SingleSelection) + self.items.currentTextChanged.connect(self.item_selected) +# self.items.setFixedWidth(150) + + self.books_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.books_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.books_table.setColumnCount(3) + t = QTableWidgetItem(_('Title')) + self.books_table.setHorizontalHeaderItem(0, t) + t = QTableWidgetItem(_('Authors')) + self.books_table.setHorizontalHeaderItem(1, t) + t = QTableWidgetItem(_('Series')) + self.books_table.setHorizontalHeaderItem(2, t) + self.books_table_header_height = self.books_table.height() + self.books_table.cellDoubleClicked.connect(self.book_doubleclicked) + + self.is_closed = False + self.current_book_id = None + self.current_key = None + self.use_current_key_for_next_refresh = False + self.last_search = None + + self.refresh(row) +# self.connect(self.view.selectionModel(), SIGNAL('currentChanged(QModelIndex,QModelIndex)'), self.slave) + self.view.selectionModel().currentChanged[QModelIndex,QModelIndex].connect(self.slave) + self.search_button.clicked.connect(self.do_search) + + def do_search(self): + if self.last_search is not None: + self.use_current_key_for_next_refresh = True + self.gui.search.set_search_string(self.last_search) + + def item_selected(self, txt): + self.fill_in_books_box(unicode(txt)) + + def refresh(self, idx): + bv_row = idx.row() + key = self.view.model().column_map[idx.column()] + + book_id = self.view.model().id(bv_row) + + if self.use_current_key_for_next_refresh: + key = self.current_key + self.use_current_key_for_next_refresh = False + else: + if not self.db.field_metadata[key]['is_category']: + if self.current_key is None: + return + key = self.current_key + self.items_label.setText('{0} ({1})'.format( + self.db.field_metadata[key]['name'], key)) + self.items.clear() + self.books_table.setRowCount(0) + + mi = self.db.get_metadata(book_id, index_is_id=True, get_user_categories=False) + vals = mi.get(key, None) + if not vals: + return + + if not isinstance(vals, list): + vals = [vals] + vals.sort(key=sort_key) + + self.items.blockSignals(True) + for v in vals: + a = QListWidgetItem(v) + self.items.addItem(a) + self.items.setCurrentRow(0) + self.items.blockSignals(False) + + self.current_book_id = book_id + self.current_key = key + + self.fill_in_books_box(vals[0]) + + def fill_in_books_box(self, selected_item): + if selected_item.startswith('.'): + sv = '.' + selected_item + else: + sv = selected_item + sv = sv.replace('"', r'\"') + self.last_search = self.current_key+':"=' + sv + '"' + books = self.db.search_getting_ids(self.last_search, + self.db.data.search_restriction) + self.books_table.setRowCount(len(books)) + self.books_label.setText(_('Books with selected item: {0}').format(len(books))) + + select_row = None + self.books_table.setSortingEnabled(False) + for row, b in enumerate(books): + mi = self.db.get_metadata(b, index_is_id=True, get_user_categories=False) + a = tableItem(mi.title) + a.setData(Qt.UserRole, b) + self.books_table.setItem(row, 0, a) + a = tableItem(' & '.join(mi.authors)) + self.books_table.setItem(row, 1, a) + series = mi.format_field('series')[1] + if series is None: + series = '' + a = tableItem(series) + self.books_table.setItem(row, 2, a) + if b == self.current_book_id: + select_row = row + + self.books_table.resizeColumnsToContents() +# self.books_table.resizeRowsToContents() + + if select_row is not None: + self.books_table.selectRow(select_row) + self.books_table.setSortingEnabled(True) + + def book_doubleclicked(self, row, column): + self.use_current_key_for_next_refresh = True + self.view.select_rows([self.books_table.item(row, 0).data(Qt.UserRole).toInt()[0]]) + + def slave(self, current, previous): + self.refresh(current) + self.view.activateWindow() + + def done(self, r): + geom = bytearray(self.saveGeometry()) + gprefs['quickview_dialog_geometry'] = geom + self.is_closed = True + QDialog.done(self, r) diff --git a/src/calibre/gui2/dialogs/quickview.ui b/src/calibre/gui2/dialogs/quickview.ui new file mode 100644 index 0000000000..2cdc7b7379 --- /dev/null +++ b/src/calibre/gui2/dialogs/quickview.ui @@ -0,0 +1,131 @@ + + + Quickview + + + + 0 + 0 + 768 + 342 + + + + + 0 + 0 + + + + Quickview + + + + + + Items + + + + + + + + 1 + 0 + + + + + + + + + + + + + 4 + 0 + + + + 0 + + + 0 + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + Search + + + Search in the library view for the selected item + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + QDialogButtonBox::Close + + + false + + + + + + + + + + + buttonBox + rejected() + Quickview + reject() + + + 297 + 217 + + + 286 + 234 + + + + + diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 67c67b1ff7..530d617f9a 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1931,13 +1931,13 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): def authors_with_sort_strings(self, id, index_is_id=False): id = id if index_is_id else self.id(id) aut_strings = self.conn.get(''' - SELECT authors.name, authors.sort + SELECT authors.id, authors.name, authors.sort FROM authors, books_authors_link as bl WHERE bl.book=? and authors.id=bl.author ORDER BY bl.id''', (id,)) result = [] - for (author, sort,) in aut_strings: - result.append((author.replace('|', ','), sort)) + for (id_, author, sort,) in aut_strings: + result.append((id_, author.replace('|', ','), sort)) return result # Given a book, return the author_sort string for authors of the book @@ -1945,6 +1945,15 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): auts = self.authors_sort_strings(id, index_is_id) return ' & '.join(auts).replace('|', ',') + # Given an author, return a list of books with that author + def books_for_author(self, id_, index_is_id=False): + id_ = id_ if index_is_id else self.id(id_) + books = self.conn.get(''' + SELECT bl.book + FROM books_authors_link as bl + WHERE bl.author=?''', (id_,)) + return [b[0] for b in books] + # Given a list of authors, return the author_sort string for the authors, # preferring the author sort associated with the author over the computed # string @@ -1968,7 +1977,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): aum = self.authors_with_sort_strings(id_, index_is_id=True) self.data.set(id_, self.FIELD_MAP['au_map'], - ':#:'.join([':::'.join((au.replace(',', '|'), aus)) for (au, aus) in aum]), + ':#:'.join([':::'.join((au.replace(',', '|'), aus)) for (_, au, aus) in aum]), row_is_id=True) def _set_authors(self, id, authors, allow_case_change=False): From ab44da6f50cc3490bd9e86a9d00005ddc8acce49 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 21 Jun 2011 15:56:38 -0600 Subject: [PATCH 23/27] El club del ebook by DM. Fixes #800406 (New recipe for ebook related blog in spanish - El club del ebook) --- recipes/elclubdelebook.recipe | 61 +++++++++++++++++++++++++++++++ recipes/icons/elclubdelebook.png | Bin 0 -> 5423 bytes 2 files changed, 61 insertions(+) create mode 100644 recipes/elclubdelebook.recipe create mode 100644 recipes/icons/elclubdelebook.png diff --git a/recipes/elclubdelebook.recipe b/recipes/elclubdelebook.recipe new file mode 100644 index 0000000000..e05b176cc5 --- /dev/null +++ b/recipes/elclubdelebook.recipe @@ -0,0 +1,61 @@ + +__license__ = 'GPL v3' +__copyright__ = '2011, Darko Miletic ' +''' +www.clubdelebook.com +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class ElClubDelEbook(BasicNewsRecipe): + title = 'El club del ebook' + __author__ = 'Darko Miletic' + description = 'El Club del eBook, es la primera fuente de informacion sobre ebooks de Argentina. Aca vas a encontrar noticias, tips, tutoriales, recursos y opiniones sobre el mundo de los libros electronicos.' + tags = 'ebook, libro electronico, e-book, ebooks, libros electronicos, e-books' + oldest_article = 7 + max_articles_per_feed = 100 + language = 'es_AR' + encoding = 'utf-8' + no_stylesheets = True + use_embedded_content = True + publication_type = 'blog' + masthead_url = 'http://dl.dropbox.com/u/2845131/elclubdelebook.png' + extra_css = """ + body{font-family: Arial,Helvetica,sans-serif} + img{ margin-bottom: 0.8em; + border: 1px solid #333333; + padding: 4px; display: block + } + """ + + conversion_options = { + 'comment' : description + , 'tags' : tags + , 'publisher': title + , 'language' : language + } + + remove_tags = [dict(attrs={'id':'crp_related'})] + remove_tags_after = dict(attrs={'id':'crp_related'}) + + feeds = [(u'Articulos', u'http://feeds.feedburner.com/ElClubDelEbook')] + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + for item in soup.findAll('a'): + limg = item.find('img') + if item.string is not None: + str = item.string + item.replaceWith(str) + else: + if limg: + item.name = 'div' + item.attrs = [] + else: + str = self.tag_to_string(item) + item.replaceWith(str) + for item in soup.findAll('img'): + if not item.has_key('alt'): + item['alt'] = 'image' + return soup diff --git a/recipes/icons/elclubdelebook.png b/recipes/icons/elclubdelebook.png new file mode 100644 index 0000000000000000000000000000000000000000..c43f0454848f640c5d035596b9dc2705d15e38ba GIT binary patch literal 5423 zcmV+~70~L5P)7L&AwYL1XdSuDV_r9dQ;!<5*bL+qVIp;s;+-4GLf)J7< z7KRXo5Rs65dpp;zU2D->$yY~~(__qNqrm7AiG;x@Fbu;Bax4}ruP*g^y%viJ->M8t z|3DSAkzSkfI*bgFv{9t@*&O{+O0q25tEP1D;KBD#x=m7uBoeEhxQR@kjH_{9)aP{c z!-utRfAp8X{6){Z4F-dlmi{FyC9j%Hree3#9SSi$J>F_6q0ddfvhKC-yzsyYk znn|mwqLMYQZ`rqR-?v7Mn>=}(QBQc5sPeU$a_lu&))X-sNh(du=ALgwg>Wo^6|N$> z0oJwE-2hAY>O)Q>sW_QG|H(umcJICSJ~(kCurjfv`uXR+msUMLdi3b2lgC&rB$H0- z^?F_?7?Gk=2&;`8Mm`}EgCXYk*c}d{V-lf|EK82EGR-ru9^7@8q6V~pPPjUz(`qi4 zvzQG$PyaHNA~PPDkM~Zz|IRUEMzQHM*|~EkKHqZ>#Ux2Yr_=E~M^qK3O8)r-rbHkB z=bQGx1K%1s(&};nQ{3+-it;bd8$PJosr_d(G7g6UZ_i{%GD*2#x^!tM6#C$U4?G?} zv+2(r8#iv8IsJQM$2Rh!BFl0v-5HO^O;*EIhKS13Wh->WyshTF_ugOr!t-@?bskSA zTz=oj@5kf0OeRImbiMtw!$ACW9;RSi%9El6L?W3-L=SHKS|*c$*K6-0D^|Sp!O@oa z^XD@QpML+;sZ-nET7>0T=-Wv??bQK1BVT6@?F$XoPiFq&^5x4myCs=S4jee(^Upsw z>UFz!?@%>B{}&cZ!q>S~AwuaWNh~i2Af}?CG@mE3Omqgacklnc@W%ED6DBa@=B)4T z?%uxsXErm@XH(E2o+M$tw9LV>l4=Qu!*$ht^?Gt--`}5m?z!q=Lr$DH;Vv$I_St7~ zv3>jYQ^!w?9Xs}!Ws8ErG!G?^B#vjncag)6d8AYV3J10@sv2sQq23`W5_C&pRooZ! zIuOS5JPbT(;ak+@2aJ8HzP^6rifQQ_Dbq=KC=uzfI-T*9Xf<0ULbXaxk(Xayy?@sS z4Gj&uj~$&lb?OhMPQ?@z<+e9AZr!u%gVU!^H$U3c+&o>C2*(joB7#5^i3Ee;GN+wt z3Cn1JuEAnCRtqRalo84*oB1PtE!f7r$c2c=rEk+fS#A9X-6hu2{eKe_lWK z;ch|6Y+kj}clvK^%rAOd%A8DhNbC;gyZu>u22wbEfv7(iyWH8;;qCVNypc#Gn?t4% z1y(WXIo4LDn%&Gz)1LvlJ!_r-c5^mjFc^udB%@KivjR&y+m?i}rrkIB-FM$@J8^W) znl%kWO3xfWZZ(_FoHIO+fN?zWLFo01F&p18%MZwIxX;~Xf$dy zTKo0umyX9EJZDZFL+b6y?et2o_Xe(5bwbHj(mAx_3G7w`&v6XI;7xPJbx+e z!ZJ`tz=(qyZbI!KXGJ_&TUiCrf7-sKrKP3R?YQf%yTYMvm{Qf{K^4rSDJzQ^Si)h< zrYKt+;W2=F1EgF7I%4K38W33el`B_3QAsfeOu0Z{*|KGj3BTVDC_8p+EiH9_@x>Pi zcpA@&f;GTN@c?|t=ks9-%z*L2g$uoU^%^y56jltI;^A@Q#^Eui-RW|=O57!Gx4Wc- zj`***q@=1>ukz~ZlFABKaWTcj^`tsDE7A;`jY|$wG19h@AfA>}caHi-2tjMJG&c5m z=biP=SZn^vgAO@w&ZYuNAH{i#Rg^7xNr?umYTl^C4RTCOb=eHrT*lkgacbaz-amch zf%3A-_3Jk5-hH5=q7Ref68@y;l0VoPjf7G-&tJHhNM$c}w6}XbT|sXb;xZG;s98lP zD@HkuGy@R^hgdhF0fO1y-VSdlE-r@OJoeaQNDk0a)K$>8R4P?gRt8VXLy`&2n^+d~ z7Cr$P1UHqHmC*gp&d%?C_q$*VAHnf!udRc(+gz@SnwrweN>{N9u3cYWhnM!Mt*xr8 zEH5j^>ns)v=0U=hd+HX#;x%}spk;+2Lx#+rJ^Or1OHFO<*|TQ>7r7J6!!G^%_lHgK z$N79#Rb^h_a57XF5o2&1(P%Uz67k~VqN7KTJlfpkaN6+krI%J6ICK~u4j+Ioc6NEg z(O7qPARbS4bar~XyS)KlI2KLkax%@Bm{90}1>mZtD%q@RC@C5B{qMv2U~A>dl}C;o z89H<*)(2v`y1KwkHk+lMOCyVxC3p-YKqJB90YFFaLNXABSRQ;1XdZw3ai6yv7lQ^5 zs;VkS9a~oBE-fvt#s^%v-H;wi71jscQhG{aR#p|HBvB?NRP(4X2tnw?YG~K3&=W_4m|^5x4R8AT3fZFLR2 z{*_l==|7;sVYSH;QUp&s2NpdK3wjE{3{gs@5?E|ekpsq}9TCrK?%&?fP(ShhDMpJe znacjOX^!1)3WwuLUcUHcTl?k9p3Y96*AtIK@|i57AcHeJ%lE*79wHV1RV&3vbwdO6 z2EKxMZ@u+aSQ8N6+qxC%f@@8YX}ZArKoT%%4h3P!2^sZ_7=7<>-XqB0vlemry^ zHRI8vM`z8N?{ZOx>)Wp{L=I`Kyu2q8d5*uKN;BE4u~ZU?GpF&VCKAJj4O_TyAtr)R zkcP`kN;kjx$G;sqe9O?G5;`8j6cxGJS}*0Y6e8%9WwpM(!RHGg=>d}lT5q>I&1UO> z0RuO0e!IKdFXr=g{rhiN_u8Mg?Xa8lawe5eMB6W%kNCXtpg$V)vqVOnPOceIrz_+m zL4(V%G(X_jvSkax3Kx(oxZ2AvzZ{7~d-v{*=OIYLhYtrIkPL`^pFVw%r12z3f}p^E z@l0)PEv8+tV8O0kyP!3o4Jl#d$dR*V%|a}5n$&@Qr`wGa&m%R$w>Y+^H(^tmG)~$T zWoTN@WHW_h@#4kEC%bp=zT=KNV0fp^{>xu1cG?{RZ?IY%UXKqkRa4z3oyvc4?37M0 zNusLAj6rYF>&!#HHp0{8U-ZP!eBD8Rw{Ob*lN;*lW;Zvx?UtG{cdv3c3Ugl18i|;X z`lFu9Vk*wbc~%w~^((euSHyx&ASU>hAQ+*c4!g~6!)pHUhZR$%O!@TFPtnZ^wC?u2 z_~MK32Bd1R0$anYU_{Ufm%>FU2cLs)fguzyU=~^ctjK_fEPVU)(@*c%v7?|>U=D17 z9uS7g5&kgYd*h*e?`__U7{s?wIIxaB;MlQattU=ko}MZeu#)K%d<#{620-*PLmRFRPR;*pS{=%0RZ~yu?Sq8331`oaoq9Nwhg$thi z;)|12Rkb{4a2J=G%@&U1kzz_*MOWHdhuwVBupy17PaHdY{OHElUfK8Iu52RG(7(^X zy1oLVc6YWX!hx&ZIL(PXtpSJu8pQMFuY9YjsuYfoK!*{3IAaFH20Wqhf-T@un>K9% zbkI}y7zhOB&Yg>t4+7vK)LAuZ;Y|1zJWZN336Y3G7hM*l2Qi4?-M@c7s0ERDCweOA z5%NHfZ;hDwItc%0^+O0ow-}BGgDj!dW=!Y8i&MNEZ*uS|W>0stz)6pt_o3Y|o9!^o z{CNuy3Lk&G4>_ukh(e(N^n&FWEGeCiqb@4p!nD-MXO15~USzSrd?pi;HiNX-ia|z+hSrYbi~=D0;V~R@LyxtH`r9;sebY5myoXpVa ztH9RB>mCI%o1CU>aa#GMnqZe4OS>mtq#Ha z$1|F02Mj!arNa}6>FrKOS%tZ%h%p$VnKXJem4FDAxnm5oNkrpx0`LWy_Xt-MY1|{{XNB`=Z^QJmtRM{qEU(UO{InN?BCW z+CBsNLruXm2|5wap&^u_5mdhjLpTePI{Yl1&di)Ucj%&>)aCDL`aR-q>#K87K{zE5 z53wXeJ2it1!JpSQeFUU;1e!wk^>Pe)PT6Lo5gLsoX3m^>`BFO~;nbN^-Y#!hxht8> z01#X_pU)u^=*$)b0Y?<1AYP^OSadArH`;9lE$C`({ph2;$mQuw?)eofhR^vUh4uAm z&mz_A+^`5A>@=?%lip+jrkRy!j0}!MA_B z1hBSkTtcTy$p~Xk&47=>#%y8FlhLkO%?>h~r*$`@qpr$mO3czK1qqJsT1hlXpckSw zFhfU)r*$@`?ZI+dZeg`DK^7&bZay!;{Vi69!|C+-{K!amHvb+AX2v)D7BjxJ{^xkV zMO$hvmC|bV0xX7H-O4K@cvk$1L2aw*UmgrC%^7V!F{kYS7LrPVv%(%dIu%8hSzf0| zQZ^?+mIa*-?lu1LwaBnc^RLz+>HcBmb4aXWLecA4DHBIRwCGuFWy)2~3L_`bm#GSE z<5Wd^ze-2YrZslg_GG94F-%s{<|zOwY-DQJg3f?an>WyIPiJ&MhYd!uVdJ)~yFVR0 zdNlL#$?(#pOD9hn|HCPFatdw9iftq>s-}XMs67<*xC|ZsEw;4(B<-JYVq~7~yGsef=&xqtHE4(zBg(b9%IlVo;x2~vh-Kb=y~&+9S+vUlXOOL8VehEg%{{FYBWE)eh0chiuzCQ?HN_g z@mZGtr>_f-=VgeOnj?(KKz|=;yOjL(@)r=NPc}^*Jg6TN%BcwX-~RrcZQHgDx~T!B z%jnyO+O2|4qTl+7DF< zLxE;QVldEOll(z>&;Gxo6G@BmJ@?%6=(r*1oR}mQCDX+pJKnu!-D*hRtvB}@HEPuD zL+iDfG*8lCDU8ODLLIsA>zB4Q(nC;BUw*@{O==I`Yj@bItOHgwn%aHfZwC$>2&Yu2 z!L*;uxZ{q#mB5qF(;&hv^wp>@PG0B$>I0{{=%zsF!1L$NyQ=G;VQQXMXD+jVqD$s= zDGw%Z!YEoee-ZQv1!@Ud(zYt(*T Z{Rh?U!$@|sqe}n)002ovPDHLkV1mm`cL)Fg literal 0 HcmV?d00001 From 767f0d15841319efb5b9a888bdeab977dd3126b1 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 21 Jun 2011 16:07:34 -0600 Subject: [PATCH 24/27] Daytona Beach Journal by BRGriff --- recipes/daytona_beach.recipe | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 recipes/daytona_beach.recipe diff --git a/recipes/daytona_beach.recipe b/recipes/daytona_beach.recipe new file mode 100644 index 0000000000..1230c1d8ed --- /dev/null +++ b/recipes/daytona_beach.recipe @@ -0,0 +1,78 @@ +from calibre.web.feeds.news import BasicNewsRecipe + +class DaytonBeachNewsJournal(BasicNewsRecipe): + title ='Daytona Beach News Journal' + __author__ = 'BRGriff' + pubisher = 'News-JournalOnline.com' + description = 'Daytona Beach, Florida, Newspaper' + category = 'News, Daytona Beach, Florida' + oldest_article = 1 + max_articles_per_feed = 100 + remove_javascript = True + use_embedded_content = False + no_stylesheets = True + language = 'en' + filterDuplicates = True + remove_attributes = ['style'] + + keep_only_tags = [dict(name='div', attrs={'class':'page-header'}), + dict(name='div', attrs={'class':'asset-body'}) + ] + remove_tags = [dict(name='div', attrs={'class':['byline-section', 'asset-meta']}) + ] + + feeds = [ + #####NEWS##### + (u"News", u"http://www.news-journalonline.com/rss.xml"), + (u"Breaking News", u"http://www.news-journalonline.com/breakingnews/rss.xml"), + (u"Local - East Volusia", u"http://www.news-journalonline.com/news/local/east-volusia/rss.xml"), + (u"Local - West Volusia", u"http://www.news-journalonline.com/news/local/west-volusia/rss.xml"), + (u"Local - Southeast", u"http://www.news-journalonline.com/news/local/southeast-volusia/rss.xml"), + (u"Local - Flagler", u"http://www.news-journalonline.com/news/local/flagler/rss.xml"), + (u"Florida", u"http://www.news-journalonline.com/news/florida/rss.xml"), + (u"National/World", u"http://www.news-journalonline.com/news/nationworld/rss.xml"), + (u"Politics", u"http://www.news-journalonline.com/news/politics/rss.xml"), + (u"News of Record", u"http://www.news-journalonline.com/news/news-of-record/rss.xml"), + ####BUSINESS#### + (u"Business", u"http://www.news-journalonline.com/business/rss.xml"), + #(u"Jobs", u"http://www.news-journalonline.com/business/jobs/rss.xml"), + #(u"Markets", u"http://www.news-journalonline.com/business/markets/rss.xml"), + #(u"Real Estate", u"http://www.news-journalonline.com/business/real-estate/rss.xml"), + #(u"Technology", u"http://www.news-journalonline.com/business/technology/rss.xml"), + ####SPORTS#### + (u"Sports", u"http://www.news-journalonline.com/sports/rss.xml"), + (u"Racing", u"http://www.news-journalonline.com/racing/rss.xml"), + (u"Highschool", u"http://www.news-journalonline.com/sports/highschool/rss.xml"), + (u"College", u"http://www.news-journalonline.com/sports/college/rss.xml"), + (u"Basketball", u"http://www.news-journalonline.com/sports/basketball/rss.xml"), + (u"Football", u"http://www.news-journalonline.com/sports/football/rss.xml"), + (u"Golf", u"http://www.news-journalonline.com/sports/golf/rss.xml"), + (u"Other Sports", u"http://www.news-journalonline.com/sports/other/rss.xml"), + ####LIFESTYLE#### + (u"Lifestyle", u"http://www.news-journalonline.com/lifestyle/rss.xml"), + #(u"Fashion", u"http://www.news-journalonline.com/lifestyle/fashion/rss.xml"), + (u"Food", u"http://www.news-journalonline.com/lifestyle/food/rss.xml"), + #(u"Health", u"http://www.news-journalonline.com/lifestyle/health/rss.xml"), + (u"Home and Garden", u"http://www.news-journalonline.com/lifestyle/home-and-garden/rss.xml"), + (u"Living", u"http://www.news-journalonline.com/lifestyle/living/rss.xml"), + (u"Religion", u"http://www.news-journalonline.com/lifestyle/religion/rss.xml"), + #(u"Travel", u"http://www.news-journalonline.com/lifestyle/travel/rss.xml"), + ####OPINION#### + #(u"Opinion", u"http://www.news-journalonline.com/opinion/rss.xml"), + #(u"Letters to Editor", u"http://www.news-journalonline.com/opinion/letters-to-the-editor/rss.xml"), + #(u"Columns", u"http://www.news-journalonline.com/columns/rss.xml"), + #(u"Podcasts", u"http://www.news-journalonline.com/podcasts/rss.xml"), + ####ENTERTAINMENT#### ##Weekly Feature## + (u"Entertainment", u"http://www.go386.com/rss.xml"), + (u"Go Out", u"http://www.go386.com/go/rss.xml"), + (u"Music", u"http://www.go386.com/music/rss.xml"), + (u"Movies", u"http://www.go386.com/movies/rss.xml"), + #(u"Culture", u"http://www.go386.com/culture/rss.xml"), + + ] + + extra_css = ''' + .page-header{font-family:Arial,Helvetica,sans-serif; font-style:bold;font-size:22pt;} + .asset-body{font-family:Helvetica,Arial,sans-serif; font-size:16pt;} + + ''' From cdd6637598fec9af389c13d6580e279ef6651e35 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Tue, 21 Jun 2011 23:05:57 -0300 Subject: [PATCH 25/27] Add support to display Favoutite as a collection for KTouch --- src/calibre/devices/kobo/driver.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index b523b7f296..4ccff13dd6 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -100,7 +100,7 @@ class KOBO(USBMS): for idx,b in enumerate(bl): bl_cache[b.lpath] = idx - def update_booklist(prefix, path, title, authors, mime, date, ContentType, ImageID, readstatus, MimeType, expired): + def update_booklist(prefix, path, title, authors, mime, date, ContentType, ImageID, readstatus, MimeType, expired, favouritesindex): changed = False try: lpath = path.partition(self.normalize_path(prefix))[2] @@ -111,17 +111,23 @@ class KOBO(USBMS): playlist_map = {} + if lpath not in playlist_map: + playlist_map[lpath] = [] + if readstatus == 1: - playlist_map[lpath]= "Im_Reading" + playlist_map[lpath].append('Im_Reading') elif readstatus == 2: - playlist_map[lpath]= "Read" + playlist_map[lpath].append('Read') elif readstatus == 3: - playlist_map[lpath]= "Closed" + playlist_map[lpath].append('Closed') # Related to a bug in the Kobo firmware that leaves an expired row for deleted books # this shows an expired Collection so the user can decide to delete the book if expired == 3: - playlist_map[lpath] = "Expired" + playlist_map[lpath].append('Expired') + # Favourites are supported on the touch but the data field is there on most earlier models + if favouritesindex == 1: + playlist_map[lpath].append('Favourite') path = self.normalize_path(path) # print "Normalized FileName: " + path @@ -149,7 +155,7 @@ class KOBO(USBMS): debug_print(" Strange: The file: ", prefix, lpath, " does mot exist!") if lpath in playlist_map and \ playlist_map[lpath] not in bl[idx].device_collections: - bl[idx].device_collections.append(playlist_map[lpath]) + bl[idx].device_collections = playlist_map.get(lpath,[]) else: if ContentType == '6' and MimeType == 'Shortcover': book = Book(prefix, lpath, title, authors, mime, date, ContentType, ImageID, size=1048576) @@ -168,7 +174,7 @@ class KOBO(USBMS): raise # print 'Update booklist' - book.device_collections = [playlist_map[lpath]] if lpath in playlist_map else [] + book.device_collections = playlist_map.get(lpath,[])# if lpath in playlist_map else [] if bl.add_book(book, replace_metadata=False): changed = True @@ -198,7 +204,7 @@ class KOBO(USBMS): self.dbversion = result[0] query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ - 'ImageID, ReadStatus, ___ExpirationStatus from content where BookID is Null' + 'ImageID, ReadStatus, ___ExpirationStatus, FavouritesIndex from content where BookID is Null' cursor.execute (query) @@ -213,10 +219,10 @@ class KOBO(USBMS): # debug_print("mime:", mime) if oncard != 'carda' and oncard != 'cardb' and not row[3].startswith("file:///mnt/sd/"): - changed = update_booklist(self._main_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8]) + changed = update_booklist(self._main_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8], row[9]) # print "shortbook: " + path elif oncard == 'carda' and row[3].startswith("file:///mnt/sd/"): - changed = update_booklist(self._card_a_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8]) + changed = update_booklist(self._card_a_prefix, path, row[0], row[1], mime, row[2], row[5], row[6], row[7], row[4], row[8], row[9]) if changed: need_sync = True From a8a2db1313ee3b90b366f3a4fec2585af8feb3e3 Mon Sep 17 00:00:00 2001 From: Timothy Legge Date: Tue, 21 Jun 2011 23:20:52 -0300 Subject: [PATCH 26/27] Query for FavouritesIndex only for KTouch and database versions that support it --- src/calibre/devices/kobo/driver.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 4ccff13dd6..650e965941 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -203,8 +203,12 @@ class KOBO(USBMS): result = cursor.fetchone() self.dbversion = result[0] - query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ + if self.dbversion >= 14: + query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ 'ImageID, ReadStatus, ___ExpirationStatus, FavouritesIndex from content where BookID is Null' + else: + query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \ + 'ImageID, ReadStatus, ___ExpirationStatus, "-1" as FavouritesIndex from content where BookID is Null' cursor.execute (query) From f1a4d06e512a1ab63a727a148005fdacf4a52e36 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 21 Jun 2011 20:43:48 -0600 Subject: [PATCH 27/27] Try to ensure that all file I/O on library files happes in the LibraryDatabase2 class. This is in preparation for the new multi-threaded db backend. Also: Content server now sends the Content-Disposition header when sending ebook files. After uploading books to the device, delete the associated temp files. If the user tries to drag and drop more than 25 books out of calibre, only the first 25 will be sent. --- src/calibre/db/backend.py | 4 +- src/calibre/db/errors.py | 13 ++ src/calibre/devices/apple/driver.py | 1 + src/calibre/devices/interface.py | 7 +- src/calibre/ebooks/metadata/worker.py | 30 ++-- src/calibre/gui2/actions/copy_to_library.py | 16 +- src/calibre/gui2/actions/edit_metadata.py | 46 ++++-- src/calibre/gui2/actions/tweak_epub.py | 7 +- src/calibre/gui2/add.py | 1 + src/calibre/gui2/convert/regex_builder.py | 9 +- src/calibre/gui2/convert/single.py | 6 +- src/calibre/gui2/device.py | 28 +++- src/calibre/gui2/dialogs/metadata_bulk.py | 13 +- src/calibre/gui2/email.py | 3 +- src/calibre/gui2/library/models.py | 45 ++---- src/calibre/gui2/library/views.py | 12 +- src/calibre/gui2/metadata/basic_widgets.py | 3 +- src/calibre/gui2/tools.py | 18 ++- src/calibre/library/__init__.py | 18 --- src/calibre/library/catalog.py | 6 + src/calibre/library/database2.py | 155 ++++++++++++++------ src/calibre/library/save_to_disk.py | 30 ++-- src/calibre/library/server/content.py | 35 ++--- 23 files changed, 319 insertions(+), 187 deletions(-) create mode 100644 src/calibre/db/errors.py diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 159612e52d..ba683dde50 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -222,7 +222,9 @@ class DB(object, SchemaUpgrade): if self.user_version == 0: self.initialize_database() - SchemaUpgrade.__init__(self) + with self.conn: + SchemaUpgrade.__init__(self) + # Guarantee that the library_id is set self.library_id diff --git a/src/calibre/db/errors.py b/src/calibre/db/errors.py new file mode 100644 index 0000000000..d2657f9904 --- /dev/null +++ b/src/calibre/db/errors.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + +class NoSuchFormat(ValueError): + pass + diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index dea5844028..cc531b7476 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -107,6 +107,7 @@ class DriverBase(DeviceConfig, DevicePlugin): # Needed for config_widget to work FORMATS = ['epub', 'pdf'] USER_CAN_ADD_NEW_FORMATS = False + KEEP_TEMP_FILES_AFTER_UPLOAD = True # Hide the standard customization widgets SUPPORTS_SUB_DIRS = False diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py index b265331ace..925f9eafd0 100644 --- a/src/calibre/devices/interface.py +++ b/src/calibre/devices/interface.py @@ -327,12 +327,7 @@ class DevicePlugin(Plugin): free space on the device. The text of the FreeSpaceError must contain the word "card" if ``on_card`` is not None otherwise it must contain the word "memory". - :param files: A list of paths and/or file-like objects. If they are paths and - the paths point to temporary files, they may have an additional - attribute, original_file_path pointing to the originals. They may have - another optional attribute, deleted_after_upload which if True means - that the file pointed to by original_file_path will be deleted after - being uploaded to the device. + :param files: A list of paths :param names: A list of file names that the books should have once uploaded to the device. len(names) == len(files) :param metadata: If not None, it is a list of :class:`Metadata` objects. diff --git a/src/calibre/ebooks/metadata/worker.py b/src/calibre/ebooks/metadata/worker.py index c335cc4c13..ca8707258b 100644 --- a/src/calibre/ebooks/metadata/worker.py +++ b/src/calibre/ebooks/metadata/worker.py @@ -15,7 +15,7 @@ from calibre.utils.ipc.server import Server from calibre.ptempfile import PersistentTemporaryDirectory, TemporaryDirectory from calibre import prints, isbytestring from calibre.constants import filesystem_encoding - +from calibre.db.errors import NoSuchFormat def debug(*args): prints(*args) @@ -201,27 +201,35 @@ class SaveWorker(Thread): self.spare_server = spare_server self.start() - def collect_data(self, ids): + def collect_data(self, ids, tdir): from calibre.ebooks.metadata.opf2 import metadata_to_opf data = {} for i in set(ids): - mi = self.db.get_metadata(i, index_is_id=True, get_cover=True) + mi = self.db.get_metadata(i, index_is_id=True, get_cover=True, + cover_as_data=True) opf = metadata_to_opf(mi) if isbytestring(opf): opf = opf.decode('utf-8') cpath = None - if mi.cover: - cpath = mi.cover + if mi.cover_data and mi.cover_data[1]: + cpath = os.path.join(tdir, 'cover_%s.jpg'%i) + with lopen(cpath, 'wb') as f: + f.write(mi.cover_data[1]) if isbytestring(cpath): cpath = cpath.decode(filesystem_encoding) formats = {} if mi.formats: for fmt in mi.formats: - fpath = self.db.format_abspath(i, fmt, index_is_id=True) - if fpath is not None: - if isbytestring(fpath): - fpath = fpath.decode(filesystem_encoding) - formats[fmt.lower()] = fpath + fpath = os.path.join(tdir, 'fmt_%s.%s'%(i, fmt.lower())) + with lopen(fpath, 'wb') as f: + try: + self.db.copy_format_to(i, fmt, f, index_is_id=True) + except NoSuchFormat: + continue + else: + if isbytestring(fpath): + fpath = fpath.decode(filesystem_encoding) + formats[fmt.lower()] = fpath data[i] = [opf, cpath, formats, mi.last_modified.isoformat()] return data @@ -244,7 +252,7 @@ class SaveWorker(Thread): for i, task in enumerate(tasks): tids = [x[-1] for x in task] - data = self.collect_data(tids) + data = self.collect_data(tids, tdir) dpath = os.path.join(tdir, '%d.json'%i) with open(dpath, 'wb') as f: f.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) diff --git a/src/calibre/gui2/actions/copy_to_library.py b/src/calibre/gui2/actions/copy_to_library.py index 7190d1486f..97880faaa1 100644 --- a/src/calibre/gui2/actions/copy_to_library.py +++ b/src/calibre/gui2/actions/copy_to_library.py @@ -53,13 +53,18 @@ class Worker(Thread): # {{{ from calibre.library.database2 import LibraryDatabase2 newdb = LibraryDatabase2(self.loc) for i, x in enumerate(self.ids): - mi = self.db.get_metadata(x, index_is_id=True, get_cover=True) + mi = self.db.get_metadata(x, index_is_id=True, get_cover=True, + cover_as_data=True) self.progress(i, mi.title) fmts = self.db.formats(x, index_is_id=True) if not fmts: fmts = [] else: fmts = fmts.split(',') - paths = [self.db.format_abspath(x, fmt, index_is_id=True) for fmt in - fmts] + paths = [] + for fmt in fmts: + p = self.db.format(x, fmt, index_is_id=True, + as_path=True) + if p: + paths.append(p) added = False if prefs['add_formats_to_existing']: identical_book_list = newdb.find_identical_books(mi) @@ -75,6 +80,11 @@ class Worker(Thread): # {{{ if co is not None: newdb.set_conversion_options(x, 'PIPE', co) self.processed.add(x) + for path in paths: + try: + os.remove(path) + except: + pass # }}} class CopyToLibraryAction(InterfaceAction): diff --git a/src/calibre/gui2/actions/edit_metadata.py b/src/calibre/gui2/actions/edit_metadata.py index 650947100e..718ece46d2 100644 --- a/src/calibre/gui2/actions/edit_metadata.py +++ b/src/calibre/gui2/actions/edit_metadata.py @@ -17,6 +17,7 @@ from calibre.gui2.dialogs.tag_list_editor import TagListEditor from calibre.gui2.actions import InterfaceAction from calibre.ebooks.metadata import authors_to_string from calibre.utils.icu import sort_key +from calibre.db.errors import NoSuchFormat class EditMetadataAction(InterfaceAction): @@ -265,7 +266,7 @@ class EditMetadataAction(InterfaceAction): +'

', 'merge_too_many_books', self.gui): return - dest_id, src_books, src_ids = self.books_to_merge(rows) + dest_id, src_ids = self.books_to_merge(rows) title = self.gui.library_view.model().db.title(dest_id, index_is_id=True) if safe_merge: if not confirm('

'+_( @@ -277,7 +278,7 @@ class EditMetadataAction(InterfaceAction): 'Please confirm you want to proceed.')%title +'

', 'merge_books_safe', self.gui): return - self.add_formats(dest_id, src_books) + self.add_formats(dest_id, self.formats_for_books(rows)) self.merge_metadata(dest_id, src_ids) elif merge_only_formats: if not confirm('

'+_( @@ -293,7 +294,7 @@ class EditMetadataAction(InterfaceAction): 'Are you sure you want to proceed?')%title +'

', 'merge_only_formats', self.gui): return - self.add_formats(dest_id, src_books) + self.add_formats(dest_id, self.formats_for_books(rows)) self.delete_books_after_merge(src_ids) else: if not confirm('

'+_( @@ -308,7 +309,7 @@ class EditMetadataAction(InterfaceAction): 'Are you sure you want to proceed?')%title +'

', 'merge_books', self.gui): return - self.add_formats(dest_id, src_books) + self.add_formats(dest_id, self.formats_for_books(rows)) self.merge_metadata(dest_id, src_ids) self.delete_books_after_merge(src_ids) # leave the selection highlight on first selected book @@ -329,8 +330,22 @@ class EditMetadataAction(InterfaceAction): self.gui.library_view.model().db.add_format(dest_id, fmt, f, index_is_id=True, notify=False, replace=replace) + def formats_for_books(self, rows): + m = self.gui.library_view.model() + ans = [] + for id_ in map(m.id, rows): + dbfmts = m.db.formats(id_, index_is_id=True) + if dbfmts: + for fmt in dbfmts.split(','): + try: + path = m.db.format(id_, fmt, index_is_id=True, + as_path=True) + ans.append(path) + except NoSuchFormat: + continue + return ans + def books_to_merge(self, rows): - src_books = [] src_ids = [] m = self.gui.library_view.model() for i, row in enumerate(rows): @@ -339,22 +354,19 @@ class EditMetadataAction(InterfaceAction): dest_id = id_ else: src_ids.append(id_) - dbfmts = m.db.formats(id_, index_is_id=True) - if dbfmts: - for fmt in dbfmts.split(','): - src_books.append(m.db.format_abspath(id_, fmt, - index_is_id=True)) - return [dest_id, src_books, src_ids] + return [dest_id, src_ids] def delete_books_after_merge(self, ids_to_delete): self.gui.library_view.model().delete_books_by_id(ids_to_delete) def merge_metadata(self, dest_id, src_ids): db = self.gui.library_view.model().db - dest_mi = db.get_metadata(dest_id, index_is_id=True, get_cover=True) + dest_mi = db.get_metadata(dest_id, index_is_id=True) orig_dest_comments = dest_mi.comments + dest_cover = db.cover(dest_id, index_is_id=True) + had_orig_cover = bool(dest_cover) for src_id in src_ids: - src_mi = db.get_metadata(src_id, index_is_id=True, get_cover=True) + src_mi = db.get_metadata(src_id, index_is_id=True) if src_mi.comments and orig_dest_comments != src_mi.comments: if not dest_mi.comments: dest_mi.comments = src_mi.comments @@ -372,8 +384,10 @@ class EditMetadataAction(InterfaceAction): dest_mi.tags = src_mi.tags else: dest_mi.tags.extend(src_mi.tags) - if src_mi.cover and not dest_mi.cover: - dest_mi.cover = src_mi.cover + if not dest_cover: + src_cover = db.cover(src_id, index_is_id=True) + if src_cover: + dest_cover = src_cover if not dest_mi.publisher: dest_mi.publisher = src_mi.publisher if not dest_mi.rating: @@ -382,6 +396,8 @@ class EditMetadataAction(InterfaceAction): dest_mi.series = src_mi.series dest_mi.series_index = src_mi.series_index db.set_metadata(dest_id, dest_mi, ignore_errors=False) + if not had_orig_cover and dest_cover: + db.set_cover(dest_id, dest_cover) for key in db.field_metadata: #loop thru all defined fields if db.field_metadata[key]['is_custom']: diff --git a/src/calibre/gui2/actions/tweak_epub.py b/src/calibre/gui2/actions/tweak_epub.py index c9f2d7a8c6..d3924e7cd3 100755 --- a/src/calibre/gui2/actions/tweak_epub.py +++ b/src/calibre/gui2/actions/tweak_epub.py @@ -5,6 +5,8 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import os + from calibre.gui2 import error_dialog from calibre.gui2.actions import InterfaceAction from calibre.gui2.dialogs.tweak_epub import TweakEpub @@ -30,8 +32,8 @@ class TweakEpubAction(InterfaceAction): # Confirm 'EPUB' in formats book_id = self.gui.library_view.model().id(row) try: - path_to_epub = self.gui.library_view.model().db.format_abspath( - book_id, 'EPUB', index_is_id=True) + path_to_epub = self.gui.library_view.model().db.format( + book_id, 'EPUB', index_is_id=True, as_path=True) except: path_to_epub = None @@ -45,6 +47,7 @@ class TweakEpubAction(InterfaceAction): if dlg.exec_() == dlg.Accepted: self.update_db(book_id, dlg._output) dlg.cleanup() + os.remove(path_to_epub) def update_db(self, book_id, rebuilt): ''' diff --git a/src/calibre/gui2/add.py b/src/calibre/gui2/add.py index 44b5bb446b..3dbf4b94df 100644 --- a/src/calibre/gui2/add.py +++ b/src/calibre/gui2/add.py @@ -445,6 +445,7 @@ class Saver(QObject): # {{{ self.pd.setModal(True) self.pd.show() self.pd.set_min(0) + self.pd.set_msg(_('Collecting data, please wait...')) self._parent = parent self.callback = callback self.callback_called = False diff --git a/src/calibre/gui2/convert/regex_builder.py b/src/calibre/gui2/convert/regex_builder.py index bf32bf472a..f79e6df3fe 100644 --- a/src/calibre/gui2/convert/regex_builder.py +++ b/src/calibre/gui2/convert/regex_builder.py @@ -4,7 +4,7 @@ __license__ = 'GPL 3' __copyright__ = '2009, John Schember ' __docformat__ = 'restructuredtext en' -import re +import re, os from PyQt4.QtCore import SIGNAL, Qt, pyqtSignal from PyQt4.QtGui import QDialog, QWidget, QDialogButtonBox, \ @@ -134,7 +134,12 @@ class RegexBuilder(QDialog, Ui_RegexBuilder): _('Cannot build regex using the GUI builder without a book.'), show=True) return False - self.open_book(db.format_abspath(book_id, format, index_is_id=True)) + fpath = db.format(book_id, format, index_is_id=True, + as_path=True) + try: + self.open_book(fpath) + finally: + os.remove(fpath) return True def open_book(self, pathtoebook): diff --git a/src/calibre/gui2/convert/single.py b/src/calibre/gui2/convert/single.py index 3575fb5ffb..15e670ee32 100644 --- a/src/calibre/gui2/convert/single.py +++ b/src/calibre/gui2/convert/single.py @@ -106,7 +106,6 @@ class Config(ResizableDialog, Ui_Dialog): Configuration dialog for single book conversion. If accepted, has the following important attributes - input_path - Path to input file output_format - Output format (without a leading .) input_format - Input format (without a leading .) opf_path - Path to OPF file with user specified metadata @@ -156,13 +155,10 @@ class Config(ResizableDialog, Ui_Dialog): oidx = self.groups.currentIndex().row() input_format = self.input_format output_format = self.output_format - input_path = self.db.format_abspath(self.book_id, input_format, - index_is_id=True) - self.input_path = input_path output_path = 'dummy.'+output_format log = Log() log.outputs = [] - self.plumber = Plumber(input_path, output_path, log) + self.plumber = Plumber('dummy.'+input_format, output_path, log) def widget_factory(cls): return cls(self.stack, self.plumber.get_option_by_name, diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index a527cc2e27..d5805a8e09 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -396,8 +396,17 @@ class DeviceManager(Thread): # {{{ if DEBUG: prints(traceback.format_exc(), file=sys.__stdout__) - return self.device.upload_books(files, names, on_card, - metadata=metadata, end_session=False) + try: + return self.device.upload_books(files, names, on_card, + metadata=metadata, end_session=False) + finally: + if metadata: + for mi in metadata: + try: + if mi.cover: + os.remove(mi.cover) + except: + pass def upload_books(self, done, files, names, on_card=None, titles=None, metadata=None, plugboards=None, add_as_step_to_job=None): @@ -1072,8 +1081,6 @@ class DeviceMixin(object): # {{{ 'the device?'), autos): self.iactions['Convert Books'].auto_convert_news(auto, format) files = [f for f in files if f is not None] - for f in files: - f.deleted_after_upload = del_on_upload if not files: self.news_to_be_synced = set([]) return @@ -1315,8 +1322,17 @@ class DeviceMixin(object): # {{{ self.card_b_view if on_card == 'cardb' else self.memory_view view.model().resort(reset=False) view.model().research() - for f in files: - getattr(f, 'close', lambda : True)() + if files: + for f in files: + # Remove temporary files + try: + rem = not getattr( + self.device_manager.device, + 'KEEP_TEMP_FILES_AFTER_UPLOAD', False) + if rem and 'caltmpfmt.' in f: + os.remove(f) + except: + pass def book_on_device(self, id, reset=False): ''' diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index ce21eba00e..7c7c78629c 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -24,7 +24,7 @@ from calibre.utils.config import prefs, tweaks from calibre.utils.magick.draw import identify_data from calibre.utils.date import qt_to_dt -def get_cover_data(path): # {{{ +def get_cover_data(stream, ext): # {{{ from calibre.ebooks.metadata.meta import get_metadata old = prefs['read_file_metadata'] if not old: @@ -32,8 +32,8 @@ def get_cover_data(path): # {{{ cdata = area = None try: - mi = get_metadata(open(path, 'rb'), - os.path.splitext(path)[1][1:].lower()) + with stream: + mi = get_metadata(stream, ext) if mi.cover and os.access(mi.cover, os.R_OK): cdata = open(mi.cover).read() elif mi.cover_data[1] is not None: @@ -186,9 +186,10 @@ class MyBlockingBusy(QDialog): # {{{ if fmts: covers = [] for fmt in fmts.split(','): - fmt = self.db.format_abspath(id, fmt, index_is_id=True) - if not fmt: continue - cdata, area = get_cover_data(fmt) + fmtf = self.db.format(id, fmt, index_is_id=True, + as_file=True) + if fmtf is None: continue + cdata, area = get_cover_data(fmtf, fmt) if cdata: covers.append((cdata, area)) covers.sort(key=lambda x: x[1]) diff --git a/src/calibre/gui2/email.py b/src/calibre/gui2/email.py index b82f421e1e..b9c760abff 100644 --- a/src/calibre/gui2/email.py +++ b/src/calibre/gui2/email.py @@ -174,7 +174,8 @@ class EmailMixin(object): # {{{ else: _auto_ids = [] - full_metadata = self.library_view.model().metadata_for(ids) + full_metadata = self.library_view.model().metadata_for(ids, + get_cover=False) bad, remove_ids, jobnames = [], [], [] texts, subjects, attachments, attachment_names = [], [], [], [] diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index f49c6db59a..40d6e2b6cf 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -5,8 +5,7 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import shutil, functools, re, os, traceback -from contextlib import closing +import functools, re, os, traceback from collections import defaultdict from PyQt4.Qt import (QAbstractTableModel, Qt, pyqtSignal, QIcon, QImage, @@ -36,14 +35,6 @@ TIME_FMT = '%d %b %Y' ALIGNMENT_MAP = {'left': Qt.AlignLeft, 'right': Qt.AlignRight, 'center': Qt.AlignHCenter} -class FormatPath(unicode): - - def __new__(cls, path, orig_file_path): - ans = unicode.__new__(cls, path) - ans.orig_file_path = orig_file_path - ans.deleted_after_upload = False - return ans - _default_image = None def default_image(): @@ -391,10 +382,14 @@ class BooksModel(QAbstractTableModel): # {{{ data = self.current_changed(index, None, False) return data - def metadata_for(self, ids): + def metadata_for(self, ids, get_cover=True): + ''' + WARNING: if get_cover=True temp files are created for mi.cover. + Remember to delete them once you are done with them. + ''' ans = [] for id in ids: - mi = self.db.get_metadata(id, index_is_id=True, get_cover=True) + mi = self.db.get_metadata(id, index_is_id=True, get_cover=get_cover) ans.append(mi) return ans @@ -449,18 +444,14 @@ class BooksModel(QAbstractTableModel): # {{{ format = f break if format is not None: - pt = PersistentTemporaryFile(suffix='.'+format) - with closing(self.db.format(id, format, index_is_id=True, - as_file=True)) as src: - shutil.copyfileobj(src, pt) - pt.flush() - if getattr(src, 'name', None): - pt.orig_file_path = os.path.abspath(src.name) + pt = PersistentTemporaryFile(suffix='caltmpfmt.'+format) + self.db.copy_format_to(id, format, pt, index_is_id=True) pt.seek(0) if set_metadata: try: - _set_metadata(pt, self.db.get_metadata(id, get_cover=True, index_is_id=True), - format) + _set_metadata(pt, self.db.get_metadata( + id, get_cover=True, index_is_id=True, + cover_as_data=True), format) except: traceback.print_exc() pt.close() @@ -468,9 +459,7 @@ class BooksModel(QAbstractTableModel): # {{{ if isbytestring(x): x = x.decode(filesystem_encoding) return x - name, op = map(to_uni, map(os.path.abspath, (pt.name, - pt.orig_file_path))) - ans.append(FormatPath(name, op)) + ans.append(to_uni(os.path.abspath(pt.name))) else: need_auto.append(id) if not exclude_auto: @@ -499,13 +488,11 @@ class BooksModel(QAbstractTableModel): # {{{ break if format is not None: pt = PersistentTemporaryFile(suffix='.'+format) - with closing(self.db.format(row, format, as_file=True)) as src: - shutil.copyfileobj(src, pt) - pt.flush() + self.db.copy_format_to(id, format, pt, index_is_id=True) pt.seek(0) if set_metadata: - _set_metadata(pt, self.db.get_metadata(row, get_cover=True), - format) + _set_metadata(pt, self.db.get_metadata(row, get_cover=True, + cover_as_data=True), format) pt.close() if paths else pt.seek(0) ans.append(pt) else: diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 3ca898d15a..9aa926f5c5 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -584,14 +584,15 @@ class BooksView(QTableView): # {{{ m = self.model() db = m.db rows = self.selectionModel().selectedRows() - selected = map(m.id, rows) + selected = list(map(m.id, rows)) ids = ' '.join(map(str, selected)) md = QMimeData() md.setData('application/calibre+from_library', ids) fmt = prefs['output_format'] def url_for_id(i): - ans = db.format_abspath(i, fmt, index_is_id=True) + ans = db.format(i, fmt, index_is_id=True, as_path=True, + preserve_filename=True) if ans is None: fmts = db.formats(i, index_is_id=True) if fmts: @@ -599,14 +600,13 @@ class BooksView(QTableView): # {{{ else: fmts = [] for f in fmts: - ans = db.format_abspath(i, f, index_is_id=True) - if ans is not None: - break + ans = db.format(i, f, index_is_id=True, as_path=True, + preserve_filename=True) if ans is None: ans = db.abspath(i, index_is_id=True) return QUrl.fromLocalFile(ans) - md.setUrls([url_for_id(i) for i in selected]) + md.setUrls([url_for_id(i) for i in selected[:25]]) drag = QDrag(self) col = self.selectionModel().currentIndex().column() md.column_name = self.column_map[col] diff --git a/src/calibre/gui2/metadata/basic_widgets.py b/src/calibre/gui2/metadata/basic_widgets.py index f865a5c62c..303cc51c74 100644 --- a/src/calibre/gui2/metadata/basic_widgets.py +++ b/src/calibre/gui2/metadata/basic_widgets.py @@ -688,7 +688,8 @@ class FormatsManager(QWidget): # {{{ else: stream = open(fmt.path, 'r+b') try: - mi = get_metadata(stream, ext) + with stream: + mi = get_metadata(stream, ext) return mi, ext except: error_dialog(self, _('Could not read metadata'), diff --git a/src/calibre/gui2/tools.py b/src/calibre/gui2/tools.py index 39224c8b35..03b32ca2fb 100644 --- a/src/calibre/gui2/tools.py +++ b/src/calibre/gui2/tools.py @@ -51,12 +51,15 @@ def convert_single_ebook(parent, db, book_ids, auto_conversion=False, # {{{ # continue mi = db.get_metadata(book_id, True) - in_file = db.format_abspath(book_id, d.input_format, True) + in_file = PersistentTemporaryFile('.'+d.input_format) + with in_file: + db.copy_format_to(book_id, d.input_format, in_file, + index_is_id=True) out_file = PersistentTemporaryFile('.' + d.output_format) out_file.write(d.output_format) out_file.close() - temp_files = [] + temp_files = [in_file] try: dtitle = unicode(mi.title) @@ -74,7 +77,7 @@ def convert_single_ebook(parent, db, book_ids, auto_conversion=False, # {{{ recs.append(('cover', d.cover_file.name, OptionRecommendation.HIGH)) temp_files.append(d.cover_file) - args = [in_file, out_file.name, recs] + args = [in_file.name, out_file.name, recs] temp_files.append(out_file) jobs.append(('gui_convert_override', args, desc, d.output_format.upper(), book_id, temp_files)) @@ -142,12 +145,15 @@ class QueueBulk(QProgressDialog): try: input_format = get_input_format_for_book(self.db, book_id, None)[0] mi, opf_file = create_opf_file(self.db, book_id) - in_file = self.db.format_abspath(book_id, input_format, True) + in_file = PersistentTemporaryFile('.'+input_format) + with in_file: + self.db.copy_format_to(book_id, input_format, in_file, + index_is_id=True) out_file = PersistentTemporaryFile('.' + self.output_format) out_file.write(self.output_format) out_file.close() - temp_files = [] + temp_files = [in_file] combined_recs = GuiRecommendations() default_recs = bulk_defaults_for_input_format(input_format) @@ -183,7 +189,7 @@ class QueueBulk(QProgressDialog): self.setLabelText(_('Queueing ')+dtitle) desc = _('Convert book %d of %d (%s)') % (self.i, len(self.book_ids), dtitle) - args = [in_file, out_file.name, lrecs] + args = [in_file.name, out_file.name, lrecs] temp_files.append(out_file) self.jobs.append(('gui_convert_override', args, desc, self.output_format.upper(), book_id, temp_files)) diff --git a/src/calibre/library/__init__.py b/src/calibre/library/__init__.py index 2e00db32c4..84a7acbc73 100644 --- a/src/calibre/library/__init__.py +++ b/src/calibre/library/__init__.py @@ -61,22 +61,4 @@ def generate_test_db(library_path, # {{{ print 'Time per record:', t/float(num_of_records) # }}} -def cover_load_timing(path=None): - from PyQt4.Qt import QApplication, QImage - import os, time - app = QApplication([]) - app - d = db(path) - paths = [d.cover(i, index_is_id=True, as_path=True) for i in - d.data.iterallids()] - paths = [p for p in paths if (p and os.path.exists(p) and os.path.isfile(p))] - - start = time.time() - - for p in paths: - with open(p, 'rb') as f: - img = QImage() - img.loadFromData(f.read()) - - print 'Average load time:', (time.time() - start)/len(paths), 'seconds' diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index 8508fb266f..1b8bb365ab 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -5137,6 +5137,7 @@ Author '{0}': OptionRecommendation.HIGH)) # If cover exists, use it + cpath = None try: search_text = 'title:"%s" author:%s' % ( opts.catalog_title.replace('"', '\\"'), 'calibre') @@ -5157,5 +5158,10 @@ Author '{0}': plumber.merge_ui_recommendations(recommendations) plumber.run() + try: + os.remove(cpath) + except: + pass + # returns to gui2.actions.catalog:catalog_generated() return catalog.error diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index efb130676a..4c61438e35 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -12,8 +12,6 @@ import threading, random from itertools import repeat from math import ceil -from PyQt4.QtGui import QImage - from calibre import prints from calibre.ebooks.metadata import (title_sort, author_to_author_sort, string_to_authors, authors_to_string) @@ -27,7 +25,7 @@ from calibre.library.sqlite import connect, IntegrityError from calibre.library.prefs import DBPrefs from calibre.ebooks.metadata.book.base import Metadata from calibre.constants import preferred_encoding, iswindows, filesystem_encoding -from calibre.ptempfile import PersistentTemporaryFile +from calibre.ptempfile import PersistentTemporaryFile, base_dir from calibre.customize.ui import run_plugins_on_import from calibre import isbytestring from calibre.utils.filenames import ascii_filename @@ -39,8 +37,10 @@ from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format from calibre.utils.magick.draw import save_cover_data_to from calibre.utils.recycle_bin import delete_file, delete_tree from calibre.utils.formatter_functions import load_user_template_functions +from calibre.db.errors import NoSuchFormat copyfile = os.link if hasattr(os, 'link') else shutil.copyfile +SPOOL_SIZE = 30*1024*1024 class Tag(object): @@ -601,14 +601,12 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): with lopen(os.path.join(tpath, 'cover.jpg'), 'wb') as f: f.write(cdata) for format in formats: - # Get data as string (can't use file as source and target files may be the same) - f = self.format(id, format, index_is_id=True, as_file=True) - if f is None: - continue - with tempfile.SpooledTemporaryFile(max_size=30*(1024**2)) as stream: - with f: - shutil.copyfileobj(f, stream) - stream.seek(0) + with tempfile.SpooledTemporaryFile(max_size=SPOOL_SIZE) as stream: + try: + self.copy_format_to(id, format, stream, index_is_id=True) + stream.seek(0) + except NoSuchFormat: + continue self.add_format(id, format, stream, index_is_id=True, path=tpath, notify=False) self.conn.execute('UPDATE books SET path=? WHERE id=?', (path, id)) @@ -661,32 +659,53 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): continue def cover(self, index, index_is_id=False, as_file=False, as_image=False, - as_path=False): + as_path=False): ''' Return the cover image as a bytestring (in JPEG format) or None. - `as_file` : If True return the image as an open file object - `as_image`: If True return the image as a QImage object + WARNING: Using as_path will copy the cover to a temp file and return + the path to the temp file. You should delete the temp file when you are + done with it. + + :param as_file: If True return the image as an open file object (a SpooledTemporaryFile) + :param as_image: If True return the image as a QImage object ''' - id = index if index_is_id else self.id(index) + id = index if index_is_id else self.id(index) path = os.path.join(self.library_path, self.path(id, index_is_id=True), 'cover.jpg') if os.access(path, os.R_OK): - if as_path: - return path try: f = lopen(path, 'rb') except (IOError, OSError): time.sleep(0.2) f = lopen(path, 'rb') - if as_image: - img = QImage() - img.loadFromData(f.read()) - f.close() - return img - ans = f if as_file else f.read() - if ans is not f: - f.close() - return ans + with f: + if as_path: + pt = PersistentTemporaryFile('_dbcover.jpg') + with pt: + shutil.copyfileobj(f, pt) + return pt.name + if as_file: + ret = tempfile.SpooledTemporaryFile(SPOOL_SIZE) + shutil.copyfileobj(f, ret) + ret.seek(0) + else: + ret = f.read() + if as_image: + from PyQt4.Qt import QImage + i = QImage() + i.loadFromData(ret) + ret = i + return ret + + def cover_last_modified(self, index, index_is_id=False): + id = index if index_is_id else self.id(index) + path = os.path.join(self.library_path, self.path(id, index_is_id=True), 'cover.jpg') + try: + return utcfromtimestamp(os.stat(path).st_mtime) + except: + # Cover doesn't exist + pass + return self.last_modified() ### The field-style interface. These use field keys. @@ -859,7 +878,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return (path, mi, sequence) def get_metadata(self, idx, index_is_id=False, get_cover=False, - get_user_categories=True): + get_user_categories=True, cover_as_data=False): ''' Convenience method to return metadata as a :class:`Metadata` object. Note that the list of formats is not verified. @@ -934,7 +953,12 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): mi.user_categories = user_cat_vals if get_cover: - mi.cover = self.cover(id, index_is_id=True, as_path=True) + if cover_as_data: + cdata = self.cover(id, index_is_id=True) + if cdata: + mi.cover_data = ('jpeg', cdata) + else: + mi.cover = self.cover(id, index_is_id=True, as_path=True) return mi def has_book(self, mi): @@ -1099,7 +1123,18 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): return utcfromtimestamp(os.stat(path).st_mtime) def format_abspath(self, index, format, index_is_id=False): - 'Return absolute path to the ebook file of format `format`' + ''' + Return absolute path to the ebook file of format `format` + + WARNING: This method will return a dummy path for a network backend DB, + so do not rely on it, use format(..., as_path=True) instead. + + Currently used only in calibredb list, the viewer and the catalogs (via + get_data_as_dict()). + + Apart from the viewer, I don't believe any of the others do any file + I/O with the results of this call. + ''' id = index if index_is_id else self.id(index) try: name = self.conn.get('SELECT name FROM data WHERE book=? AND format=?', (id, format), all=False) @@ -1119,25 +1154,63 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): shutil.copyfile(candidates[0], fmt_path) return fmt_path - def format(self, index, format, index_is_id=False, as_file=False, mode='r+b'): + def copy_format_to(self, index, fmt, dest, index_is_id=False): + ''' + Copy the format ``fmt`` to the file like object ``dest``. If the + specified format does not exist, raises :class:`NoSuchFormat` error. + ''' + path = self.format_abspath(index, fmt, index_is_id=index_is_id) + if path is None: + id_ = index if index_is_id else self.id(index) + raise NoSuchFormat('Record %d has no %s file'%(id_, fmt)) + with lopen(path, 'rb') as f: + shutil.copyfileobj(f, dest) + if hasattr(dest, 'flush'): + dest.flush() + + def format(self, index, format, index_is_id=False, as_file=False, + mode='r+b', as_path=False, preserve_filename=False): ''' Return the ebook format as a bytestring or `None` if the format doesn't exist, or we don't have permission to write to the ebook file. - `as_file`: If True the ebook format is returned as a file object opened in `mode` + :param as_file: If True the ebook format is returned as a file object. Note + that the file object is a SpooledTemporaryFile, so if what you want to + do is copy the format to another file, use :method:`copy_format_to` + instead for performance. + :param as_path: Copies the format file to a temp file and returns the + path to the temp file + :param preserve_filename: If True and returning a path the filename is + the same as that used in the library. Note that using + this means that repeated calls yield the same + temp file (which is re-created each time) + :param mode: This is ignored (present for legacy compatibility) ''' path = self.format_abspath(index, format, index_is_id=index_is_id) if path is not None: - f = lopen(path, mode) - try: - ret = f if as_file else f.read() - except IOError: - f.seek(0) - out = cStringIO.StringIO() - shutil.copyfileobj(f, out) - ret = out.getvalue() - if not as_file: - f.close() + with lopen(path, mode) as f: + if as_path: + if preserve_filename: + bd = base_dir() + d = os.path.join(bd, 'format_abspath') + try: + os.makedirs(d) + except: + pass + fname = os.path.basename(path) + ret = os.path.join(d, fname) + with lopen(ret, 'wb') as f2: + shutil.copyfileobj(f, f2) + else: + with PersistentTemporaryFile('.'+format.lower()) as pt: + shutil.copyfileobj(f, pt) + ret = pt.name + elif as_file: + ret = tempfile.SpooledTemporaryFile(max_size=SPOOL_SIZE) + shutil.copyfileobj(f, ret) + ret.seek(0) + else: + ret = f.read() return ret def add_format_with_hooks(self, index, format, fpath, index_is_id=False, diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 5f49833564..b5c4e2faf3 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -6,7 +6,7 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import os, traceback, cStringIO, re, shutil +import os, traceback, cStringIO, re from calibre.constants import DEBUG from calibre.utils.config import Config, StringConfig, tweaks @@ -238,7 +238,7 @@ def get_components(template, mi, id, timefmt='%b %Y', length=250, def save_book_to_disk(id_, db, root, opts, length): mi = db.get_metadata(id_, index_is_id=True) - cover = db.cover(id_, index_is_id=True, as_path=True) + cover = db.cover(id_, index_is_id=True) plugboards = db.prefs.get('plugboards', {}) available_formats = db.formats(id_, index_is_id=True) @@ -252,12 +252,20 @@ def save_book_to_disk(id_, db, root, opts, length): if fmts: fmts = fmts.split(',') for fmt in fmts: - fpath = db.format_abspath(id_, fmt, index_is_id=True) + fpath = db.format(id_, fmt, index_is_id=True, as_path=True) if fpath is not None: formats[fmt.lower()] = fpath - return do_save_book_to_disk(id_, mi, cover, plugboards, + try: + return do_save_book_to_disk(id_, mi, cover, plugboards, formats, root, opts, length) + finally: + for temp in formats.itervalues(): + try: + os.remove(temp) + except: + pass + def do_save_book_to_disk(id_, mi, cover, plugboards, @@ -289,10 +297,9 @@ def do_save_book_to_disk(id_, mi, cover, plugboards, raise ocover = mi.cover - if opts.save_cover and cover and os.access(cover, os.R_OK): + if opts.save_cover and cover: with open(base_path+'.jpg', 'wb') as f: - with open(cover, 'rb') as s: - shutil.copyfileobj(s, f) + f.write(cover) mi.cover = base_name+'.jpg' else: mi.cover = None @@ -395,8 +402,13 @@ def save_serialized_to_disk(ids, data, plugboards, root, opts, callback): pass tb = '' try: - failed, id, title = do_save_book_to_disk(x, mi, cover, plugboards, - format_map, root, opts, length) + with open(cover, 'rb') as f: + cover = f.read() + except: + cover = None + try: + failed, id, title = do_save_book_to_disk(x, mi, cover, + plugboards, format_map, root, opts, length) tb = _('Requested formats not available') except: failed, id, title = True, x, mi.title diff --git a/src/calibre/library/server/content.py b/src/calibre/library/server/content.py index 08de4faecd..853ec7829c 100644 --- a/src/calibre/library/server/content.py +++ b/src/calibre/library/server/content.py @@ -10,12 +10,13 @@ import re, os, posixpath import cherrypy from calibre import fit_image, guess_type -from calibre.utils.date import fromtimestamp +from calibre.utils.date import fromtimestamp, utcnow from calibre.library.caches import SortKeyGenerator from calibre.library.save_to_disk import find_plugboard - -from calibre.utils.magick.draw import save_cover_data_to, Image, \ - thumbnail as generate_thumbnail +from calibre.ebooks.metadata import authors_to_string +from calibre.utils.magick.draw import (save_cover_data_to, Image, + thumbnail as generate_thumbnail) +from calibre.utils.filenames import ascii_filename plugboard_content_server_value = 'content_server' plugboard_content_server_formats = ['epub'] @@ -46,7 +47,7 @@ class ContentServer(object): # Utility methods {{{ def last_modified(self, updated): ''' - Generates a local independent, english timestamp from a datetime + Generates a locale independent, english timestamp from a datetime object ''' lm = updated.strftime('day, %d month %Y %H:%M:%S GMT') @@ -151,14 +152,12 @@ class ContentServer(object): try: cherrypy.response.headers['Content-Type'] = 'image/jpeg' cherrypy.response.timeout = 3600 - cover = self.db.cover(id, index_is_id=True, as_file=True) + cover = self.db.cover(id, index_is_id=True) if cover is None: cover = self.default_cover updated = self.build_time else: - with cover as f: - updated = fromtimestamp(os.fstat(f.fileno()).st_mtime) - cover = f.read() + updated = self.db.cover_last_modified(id, index_is_id=True) cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) if thumbnail: @@ -187,9 +186,9 @@ class ContentServer(object): mode='rb') if fmt is None: raise cherrypy.HTTPError(404, 'book: %d does not have format: %s'%(id, format)) + mi = self.db.get_metadata(id, index_is_id=True) if format == 'EPUB': # Get the original metadata - mi = self.db.get_metadata(id, index_is_id=True) # Get any EPUB plugboards for the content server plugboards = self.db.prefs.get('plugboards', {}) @@ -203,24 +202,22 @@ class ContentServer(object): newmi = mi # Write the updated file - from tempfile import TemporaryFile from calibre.ebooks.metadata.meta import set_metadata - raw = fmt.read() - fmt = TemporaryFile() - fmt.write(raw) - fmt.seek(0) set_metadata(fmt, newmi, 'epub') fmt.seek(0) mt = guess_type('dummy.'+format.lower())[0] if mt is None: mt = 'application/octet-stream' + au = authors_to_string(mi.authors if mi.authors else [_('Unknown')]) + title = mi.title if mi.title else _('Unknown') + fname = u'%s - %s_%s.%s'%(title[:30], au[:30], id, format.lower()) + fname = ascii_filename(fname).replace('"', '_') cherrypy.response.headers['Content-Type'] = mt + cherrypy.response.headers['Content-Disposition'] = \ + b'attachment; filename="%s"'%fname cherrypy.response.timeout = 3600 - path = getattr(fmt, 'name', None) - if path and os.path.exists(path): - updated = fromtimestamp(os.stat(path).st_mtime) - cherrypy.response.headers['Last-Modified'] = self.last_modified(updated) + cherrypy.response.headers['Last-Modified'] = self.last_modified(utcnow()) return fmt # }}}