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;}
+
+ '''
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/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: '')
+ ,(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()
diff --git a/recipes/icons/elclubdelebook.png b/recipes/icons/elclubdelebook.png
new file mode 100644
index 0000000000..c43f045484
Binary files /dev/null and b/recipes/icons/elclubdelebook.png differ
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/__init__.py b/src/calibre/__init__.py
index 3a35feb66f..33e80982d1 100644
--- a/src/calibre/__init__.py
+++ b/src/calibre/__init__.py
@@ -578,6 +578,7 @@ def url_slash_cleaner(url):
def get_download_filename(url, cookie_file=None):
'''
Get a local filename for a URL using the content disposition header
+ Returns empty string if no content disposition header present
'''
from contextlib import closing
from urllib2 import unquote as urllib2_unquote
@@ -591,8 +592,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 +615,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
diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py
index d1c5b6ccd5..ce7297ef9a 100644
--- a/src/calibre/customize/builtins.py
+++ b/src/calibre/customize/builtins.py
@@ -762,99 +762,132 @@ 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 ActionQuickview(InterfaceActionBase):
+ name = 'Show Quickview'
+ actual_plugin = 'calibre.gui2.actions.show_quickview:ShowQuickviewAction'
+ description = _('Show a list of related books quickly')
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,13 +903,13 @@ 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,
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,
@@ -1307,6 +1340,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.'
@@ -1457,6 +1500,7 @@ plugins += [
StoreGutenbergStore,
StoreKoboStore,
StoreLegimiStore,
+ StoreLibreDEStore,
StoreManyBooksStore,
StoreMobileReadStore,
StoreNextoStore,
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/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..ba683dde50
--- /dev/null
+++ b/src/calibre/db/backend.py
@@ -0,0 +1,443 @@
+#!/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
+from calibre.db.tables import (OneToOneTable, ManyToOneTable, ManyToManyTable,
+ SizeTable, FormatsTable, AuthorsTable, IdentifiersTable)
+# }}}
+
+'''
+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(object, 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()
+
+ with self.conn:
+ 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)
+
+ 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/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/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]))
+
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/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py
index 04fb3c37b0..650e965941 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, favouritesindex):
changed = False
try:
lpath = path.partition(self.normalize_path(prefix))[2]
@@ -111,12 +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].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
@@ -126,7 +137,13 @@ class KOBO(USBMS):
bl_cache[lpath] = None
if ImageID is not None:
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 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'):
@@ -138,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)
@@ -157,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
@@ -186,24 +203,30 @@ class KOBO(USBMS):
result = cursor.fetchone()
self.dbversion = result[0]
- query= 'select Title, Attribution, DateCreated, ContentID, MimeType, ContentType, ' \
- 'ImageID, ReadStatus from content where BookID is Null'
+ 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)
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)
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], 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])
+ 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
@@ -267,8 +290,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()
@@ -286,7 +313,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
@@ -450,7 +477,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/"):
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'
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/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/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 99f795cdda..d5805a8e09 100644
--- a/src/calibre/gui2/device.py
+++ b/src/calibre/gui2/device.py
@@ -49,16 +49,26 @@ 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)
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=''):
@@ -386,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):
@@ -1062,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
@@ -1305,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 8829dc97c0..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])
@@ -361,7 +362,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
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/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/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)')
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ł'
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..ed93eeff0e
--- /dev/null
+++ b/src/calibre/gui2/store/libri_de_plugin.py
@@ -0,0 +1,90 @@
+# -*- 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)
+ open_url(QUrl(url))
+ else:
+ detail_url = None
+ if detail_item:
+ detail_url = url_details.format(detail_item)
+ 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
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)
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 67c67b1ff7..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):
@@ -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):
@@ -603,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))
@@ -663,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.
@@ -861,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.
@@ -936,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):
@@ -1101,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)
@@ -1121,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,
@@ -1931,13 +2002,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 +2016,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 +2048,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):
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
# }}}