From 63ebe19a5f966b3fbe0ac354fa1ddaf67d6d5d81 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 24 Mar 2009 17:01:55 -0700 Subject: [PATCH] Support for email ebooks from calibre to your device. To use setup Email delivery in the preferences and then click the arrow next to the Send to Device button. Still experimental, so report bugs. --- src/calibre/debug.py | 29 +- src/calibre/devices/kindle/driver.py | 27 +- src/calibre/ebooks/epub/iterator.py | 5 +- src/calibre/ebooks/metadata/isbndb.py | 2 +- src/calibre/ebooks/metadata/opf2.py | 9 +- src/calibre/ezPyCrypto.py | 1334 ----------------- src/calibre/gui2/__init__.py | 139 +- src/calibre/gui2/device.py | 552 ++++++- src/calibre/gui2/dialogs/config.py | 325 +++- src/calibre/gui2/dialogs/config.ui | 284 +++- src/calibre/gui2/dialogs/fetch_metadata.py | 14 +- src/calibre/gui2/dialogs/fetch_metadata.ui | 12 +- src/calibre/gui2/dialogs/metadata_single.py | 4 +- src/calibre/gui2/images/gmail_logo.png | Bin 0 -> 24220 bytes src/calibre/gui2/images/mail.svg | 270 ++++ src/calibre/gui2/library.py | 118 +- src/calibre/gui2/main.py | 920 ++++++------ src/calibre/gui2/main.ui | 509 ++++--- src/calibre/gui2/widgets.py | 153 +- src/calibre/libunrar.py | 17 +- src/calibre/linux.py | 9 +- src/calibre/parallel.py | 2 +- src/calibre/ptempfile.py | 2 +- src/calibre/trac/plugins/download.py | 1 + src/calibre/utils/smtp.py | 232 +++ .../web/feeds/recipes/recipe_spiegelde.py | 1 - upload.py | 206 +-- 27 files changed, 2640 insertions(+), 2536 deletions(-) delete mode 100755 src/calibre/ezPyCrypto.py create mode 100644 src/calibre/gui2/images/gmail_logo.png create mode 100644 src/calibre/gui2/images/mail.svg create mode 100644 src/calibre/utils/smtp.py diff --git a/src/calibre/debug.py b/src/calibre/debug.py index 962681a267..45ce9987e0 100644 --- a/src/calibre/debug.py +++ b/src/calibre/debug.py @@ -17,22 +17,25 @@ def option_parser(): Run an embedded python interpreter. ''') - parser.add_option('--update-module', help='Update the specified module in the frozen library. '+ - 'Module specifications are of the form full.name.of.module,path_to_module.py', default=None + parser.add_option('--update-module', + help='Update the specified module in the frozen library. '+ + 'Module specifications are of the form full.name.of.module,path_to_module.py', + default=None ) parser.add_option('-c', '--command', help='Run python code.', default=None) parser.add_option('-e', '--exec-file', default=None, help='Run the python code in file.') - parser.add_option('-d', '--debug-device-driver', default=False, action='store_true', + parser.add_option('-d', '--debug-device-driver', default=False, action='store_true', help='Debug the specified device driver.') parser.add_option('-g', '--gui', default=False, action='store_true', help='Run the GUI',) - parser.add_option('--migrate', action='store_true', default=False, - help='Migrate old database. Needs two arguments. Path to library1.db and path to new library folder.') + parser.add_option('--migrate', action='store_true', default=False, + help='Migrate old database. Needs two arguments. Path ' + 'to library1.db and path to new library folder.') return parser def update_zipfile(zipfile, mod, path): if 'win32' in sys.platform: - print 'WARNING: On Windows Vista using this option may cause windows to put library.zip into the Virtual Store (typically located in c:\Users\username\AppData\Local\VirtualStore). If it does this you must delete it from there after you\'re done debugging).' + print 'WARNING: On Windows Vista using this option may cause windows to put library.zip into the Virtual Store (typically located in c:\Users\username\AppData\Local\VirtualStore). If it does this you must delete it from there after you\'re done debugging).' pat = re.compile(mod.replace('.', '/')+r'\.py[co]*') name = mod.replace('.', '/') + os.path.splitext(path)[-1] update(zipfile, [pat], [path], [name]) @@ -46,8 +49,8 @@ def update_module(mod, path): zp = os.path.join(os.path.dirname(sys.executable), 'library.zip') elif isosx: zp = os.path.join(os.path.dirname(getattr(sys, 'frameworks_dir')), - 'Resources', 'lib', - 'python'+'.'.join(map(str, sys.version_info[:2])), + 'Resources', 'lib', + 'python'+'.'.join(map(str, sys.version_info[:2])), 'site-packages.zip') else: zp = os.path.join(getattr(sys, 'frozen_path'), 'loader.zip') @@ -71,23 +74,23 @@ def migrate(old, new): self.max = max def setValue(self, val): self.update(float(val)/getattr(self, 'max', 1)) - + db = LibraryDatabase(old) db2 = LibraryDatabase2(new) db2.migrate_old(db, Dummy(terminal_controller, 'Migrating database...')) prefs['library_path'] = os.path.abspath(new) print 'Database migrated to', os.path.abspath(new) - + def debug_device_driver(): from calibre.devices.scanner import DeviceScanner s = DeviceScanner() s.scan() print 'USB devices on system:', repr(s.devices) if iswindows: - wmi = __import__('wmi', globals(), locals(), [], -1) + wmi = __import__('wmi', globals(), locals(), [], -1) drives = [] print 'Drives detected:' - print '\t', '(ID, Partitions, Drive letter)' + print '\t', '(ID, Partitions, Drive letter)' for drive in wmi.WMI().Win32_DiskDrive(): if drive.Partitions == 0: continue @@ -111,7 +114,7 @@ def debug_device_driver(): d.open() print 'Total space:', d.total_space() break - + def main(args=sys.argv): opts, args = option_parser().parse_args(args) diff --git a/src/calibre/devices/kindle/driver.py b/src/calibre/devices/kindle/driver.py index 14b056944b..a5775dec8a 100755 --- a/src/calibre/devices/kindle/driver.py +++ b/src/calibre/devices/kindle/driver.py @@ -4,32 +4,32 @@ __copyright__ = '2009, John Schember ' Device driver for Amazon's Kindle ''' -import os, re +import os, re, sys from calibre.devices.usbms.driver import USBMS, metadata_from_formats class KINDLE(USBMS): # Ordered list of supported formats FORMATS = ['azw', 'mobi', 'prc', 'azw1', 'tpz', 'txt'] - + VENDOR_ID = [0x1949] PRODUCT_ID = [0x0001] BCD = [0x399] - + VENDOR_NAME = 'KINDLE' WINDOWS_MAIN_MEM = 'INTERNAL_STORAGE' WINDOWS_CARD_MEM = 'CARD_STORAGE' - + OSX_MAIN_MEM = 'Kindle Internal Storage Media' OSX_CARD_MEM = 'Kindle Card Storage Media' - + MAIN_MEMORY_VOLUME_LABEL = 'Kindle Main Memory' STORAGE_CARD_VOLUME_LABEL = 'Kindle Storage Card' - + EBOOK_DIR_MAIN = "documents" EBOOK_DIR_CARD = "documents" SUPPORTS_SUB_DIRS = True - + WIRELESS_FILE_NAME_PATTERN = re.compile( r'(?P[^-]+)-asin_(?P<asin>[a-zA-Z\d]{10,})-type_(?P<type>\w{4})-v_(?P<index>\d+).*') @@ -37,13 +37,13 @@ class KINDLE(USBMS): for path in paths: if os.path.exists(path): os.unlink(path) - + filepath = os.path.splitext(path)[0] - + # Delete the ebook auxiliary file if os.path.exists(filepath + '.mbp'): os.unlink(filepath + '.mbp') - + @classmethod def metadata_from_path(cls, path): mi = metadata_from_formats([path]) @@ -51,10 +51,13 @@ class KINDLE(USBMS): match = cls.WIRELESS_FILE_NAME_PATTERN.match(os.path.basename(path)) if match is not None: mi.title = match.group('title') + if not isinstance(mi.title, unicode): + mi.title = mi.title.decode(sys.getfilesystemencoding(), + 'replace') return mi class KINDLE2(KINDLE): - + PRODUCT_ID = [0x0002] - BCD = [0x0100] \ No newline at end of file + BCD = [0x0100] diff --git a/src/calibre/ebooks/epub/iterator.py b/src/calibre/ebooks/epub/iterator.py index 2241483418..a51a67b3a1 100644 --- a/src/calibre/ebooks/epub/iterator.py +++ b/src/calibre/ebooks/epub/iterator.py @@ -38,8 +38,10 @@ class UnsupportedFormatError(Exception): class SpineItem(unicode): def __new__(cls, *args): + args = list(args) + args[0] = args[0].partition('#')[0] obj = super(SpineItem, cls).__new__(cls, *args) - path = args[0].partition('#')[0] + path = args[0] raw = open(path, 'rb').read() raw, obj.encoding = xml_to_unicode(raw) obj.character_count = character_count(raw) @@ -67,6 +69,7 @@ class EbookIterator(object): CHARACTERS_PER_PAGE = 1000 def __init__(self, pathtoebook): + pathtoebook = pathtoebook.strip() self.pathtoebook = os.path.abspath(pathtoebook) self.config = DynamicConfig(name='iterator') ext = os.path.splitext(pathtoebook)[1].replace('.', '').lower() diff --git a/src/calibre/ebooks/metadata/isbndb.py b/src/calibre/ebooks/metadata/isbndb.py index da4dcc6253..1fce2c07ca 100644 --- a/src/calibre/ebooks/metadata/isbndb.py +++ b/src/calibre/ebooks/metadata/isbndb.py @@ -8,7 +8,7 @@ import sys, re, socket from urllib import urlopen, quote from calibre.utils.config import OptionParser -from calibre.ebooks.metadata import MetaInformation, authors_to_sort_string +from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.BeautifulSoup import BeautifulStoneSoup BASE_URL = 'http://isbndb.com/api/books.xml?access_key=%(key)s&page_number=1&results=subjects,authors,texts&' diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 86a1129b54..bc8e9270be 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -9,7 +9,7 @@ lxml based OPF parser. import sys, unittest, functools, os, mimetypes, uuid, glob, cStringIO from urllib import unquote -from urlparse import urlparse +from urlparse import urlparse, urldefrag from lxml import etree from dateutil import parser @@ -444,7 +444,7 @@ class OPF(object): if not hasattr(stream, 'read'): stream = open(stream, 'rb') self.basedir = self.base_dir = basedir - self.path_to_html_toc = None + self.path_to_html_toc = self.html_toc_fragment = None raw, self.encoding = xml_to_unicode(stream.read(), strip_encoding_pats=True, resolve_entities=True) raw = raw[raw.find('<'):] self.root = etree.fromstring(raw, self.PARSER) @@ -496,7 +496,8 @@ class OPF(object): if f: self.toc.read_ncx_toc(f[0]) else: - self.path_to_html_toc = toc + self.path_to_html_toc, self.html_toc_fragment = \ + toc.partition('#')[0], toc.partition('#')[-1] self.toc.read_html_toc(toc) except: pass @@ -627,7 +628,7 @@ class OPF(object): attrib = {'{%s}role'%self.NAMESPACES['opf']: 'aut'} elem = self.create_metadata_element('creator', attrib=attrib) self.set_text(elem, author.strip()) - + return property(fget=fget, fset=fset) @apply diff --git a/src/calibre/ezPyCrypto.py b/src/calibre/ezPyCrypto.py deleted file mode 100755 index a899633958..0000000000 --- a/src/calibre/ezPyCrypto.py +++ /dev/null @@ -1,1334 +0,0 @@ -#@+leo -#@+node:0::@file easy/ezPyCrypto.py -#@+body - - -#@@language python -#@<< ezPyCrypto declarations >> -#@+node:1::<< ezPyCrypto declarations >> -#@+body -""" -ezPyCrypto - very simple API for military-grade cryptography -in Python. - -Designed to be approachable for even total crypto newbies, -this may be the only crypto API for Python you ever need. - -Features: - - Create, Import and Export public keys and public/private keypairs - easy - - Encrypt and Decrypt arbitrary-sized pieces of data, such as - strings or files - - Open up 'streams', so this object can be used as an encrypting/decrypting - filter - good for socket-based comms and crypto of large files - - Sign and Verify documents without fuss - - Create private keys with or without a passphrase - - Export private keys with a different (or no) passphrase - - Sensible defaults - no need to specify a zillion options (or any options - at all) unless you want to - - Algorithms include RSA, ElGamal, DSA, ARC2, Blowfish, CAST, DES3, IDEA and RC5 - (default RSA and Blowfish) - - Choose your own public and session key sizes (or accept defaults) - -Contains an easily-used yet versatile cryptography class, called -L{key}, that performs stream and block encryption. - -Packaged with a suite of very simple example programs, which demonstrate -ezPyCrypto and speed learning. - -ezPyCrypto requires the PyCrypto library (which I have hand-picked from -several different Python crypto APIs, since it's the only -API that doesn't lead its programmers on a wild goose chase -of 3rd party libs, or require dozens/hundreds of lines of -code just to do basic stuff, or lack in documentation. -PyCrypto is available from http://pycrypto.sf.net) - -PyCrypto is a very usable and well implemented lower-level -crypto API for Python. C backends give it speed, while -well designed OO interface makes it relatively fast to learn. -Also, it compiles cleanly and smoothly on Linux and Windows -with no dramas. - -But I've written this module because PyCrypto is relatively -low-level, and does present a harder learning curve for newbies. - -ezPyCrypto is written by David McNab <david@freenet.org.nz> -Released under the GNU General Public License. -No warranty, yada yada - -Refer to the documentation for class 'key' for more info. -""" - -from pdb import set_trace as trace -import pickle -import types -import base64 -import zlib - -import Crypto - -from Crypto.PublicKey import ElGamal, DSA, RSA -from Crypto.Util.randpool import RandomPool -from Crypto.Util.number import getPrime -from Crypto.Cipher import ARC2, Blowfish, CAST, DES3, IDEA, RC5 -from Crypto.Hash import MD5 - -#@-body -#@-node:1::<< ezPyCrypto declarations >> - - -#@+others -#@+node:2::exceptions -#@+body -# Define some exceptions for the various problems that can happen - -class CryptoKeyError(Exception): - "Attempt to import invalid key" - - -#@-body -#@-node:2::exceptions -#@+node:3::class key -#@+body -class key: - """ - This may well be the only crypto class for Python that you'll ever need. - Think of this class, and the ezPyCrypto module, as 'cryptography for - the rest of us'. - - Designed to strike the optimal balance between ease of use, features - and performance. - - Basic High-level methods: - - - L{encString} - encrypt a string - - L{decString} - decrypt a string - - - L{encStringToAscii} - encrypt a string to a printable, mailable format - - L{decStringFromAscii} - decrypt an ascii-format encrypted string - - - L{signString} - produce ascii-format signature of a string - - L{verifyString} - verify a string against a signature - - - L{importKey} - import public key (and possibly private key too) - - L{exportKey} - export public key only, as printable mailable string - - L{exportKeyPrivate} - same, but export private key as well - - L{makeNewKeys} - generate a new, random private/public key pair - - Middle-level (stream-oriented) methods: - - - L{encStart} - start a stream encryption session - - L{encNext} - encrypt another piece of data - - L{encEnd} - finalise stream encryption session - - - L{decStart} - start a stream decryption session - - L{decNext} - decrypt the next piece of available data - - L{decEnd} - finalise stream decryption session - - Low-level methods: - - - refer to the source code - - Principle of operation: - - - Data is encrypted with choice of symmetric block-mode session cipher - (or default Blowfish if user doesn't care) - - CFB block chaining is used for added security - each next block's - key is affected by the previous block - - The session key and initial value (IV) are encrypted against an RSA - or ElGamal public key (user's choice, default RSA) - - Each block in the stream is prepended with a 'length' byte, indicating - how many bytes in the decrypted block are significant - needed when - total data len mod block size is non-zero - - Format of encrypted data is: - - public key len - 2 bytes, little-endian - size of public key in bytes - - public key - public key of recipient - - block cipher len - unencrypted length byte - size of block cipher in bytes - - block cipher - encrypted against public key, index into array - of session algorithms - - block key len - unencrypted length byte - size of block key in bytes - - block key - encrypted against public key - - block IV len - unencrypted length of block cipher IV - IV length in bytes - - block cipher IV - encrypted against public key, prefixed 1-byte length - - block1 len - 1 byte - number of significant chars in block1 * - - block1 data - always 8 bytes, encrypted against session key - - ... - - blockn len - - blockn data - - If last data block is of the same size as the session cipher blocksize, - a final byte 0x00 is sent. - """ - - #@<< class key declarations >> - #@+node:1::<< class key declarations >> - #@+body - # Various lookup tables for encryption algorithms - - _algosPub = {'ElGamal':ElGamal, 'RSA':RSA} - - _algosPub1 = {ElGamal:'ElGamal', RSA:'RSA'} - - _algosSes = { "ARC2":ARC2, "Blowfish":Blowfish, "CAST":CAST, - "DES3":DES3, "IDEA":IDEA, "RC5":RC5} - _algosSes1 = {'ARC2':0, 'Blowfish':1, 'CAST':2, 'DES3':3, 'IDEA':4, 'RC5':5} - - _algosSes2 = [ARC2, Blowfish, CAST, DES3, IDEA, RC5] - - _algosSes3 = {ARC2:'ARC2', Blowfish:'Blowfish', CAST:'CAST', - DES3:'DES3', IDEA:'IDEA', RC5:'RC5'} - - # Generate IV for passphrase encryption - _passIV = "w8Z4(51fKH#p{!29Q05HWcb@K 6(1qdyv{9|4=+gvji$chw!9$38^2cyGK#;}'@DHx%3)q_skvh4#0*=" - - # Buffer for yet-to-be-encrypted stream data - _encBuf = '' - - #@-body - #@-node:1::<< class key declarations >> - - - #@+others - #@+node:2::__init__ - #@+body - def __init__(self, something = 512, algoPub=None, algoSess=None, **kwds): - """Constructor. Creates a key object - - This constructor, when creating the key object, does one of - two things: - 1. Creates a completely new keypair, OR - 2. Imports an existing keypair - - Arguments: - 1. If new keys are desired: - - key size in bits (int), default 512 - advise at least 1536 - - algoPub - either 'RSA' or 'ElGamal' (default 'RSA') - - algoSess - one of 'ARC2', 'Blowfish', 'CAST', 'DES3', 'IDEA', 'RC5', - (default 'Blowfish') - 2. If importing an existing key or keypair: - - keyobj (string) - result of a prior exportKey() call - Keywords: - - passphrase - default '': - - If creating new keypair, this passphrase is used to encrypt privkey when - exporting. - - If importing a new keypair, the passphrase is used to authenticate and - grant/deny access to private key - """ - passphrase = kwds.get('passphrase', '') - - if type(something) is types.IntType: - # which public key algorithm did they choose? - if algoPub == None: - algoPub = 'RSA' - algoP = self._algosPub.get(algoPub, None) - if algoP == None: - # Whoops - don't know that one - raise Exception("AlgoPub must be one of 'ElGamel', 'RSA' or 'DSA'") - self.algoPub = algoP - self.algoPname = algoPub - - # which session key algorithm? - if algoSess == None: - algoSess = 'Blowfish' - algoS = self._algosSes.get(algoSess, None) - if algoS == None: - # Whoops - don't know that session algorithm - raise Exception("AlgoSess must be one of AES/ARC2/Blowfish/CAST/DES/DES3/IDEA/RC5") - self.algoSes = algoS - self.algoSname = algoSess - - # organise random data pool - self.randpool = RandomPool() - self.randfunc = self.randpool.get_bytes - - # now create the keypair - self.makeNewKeys(something, passphrase=passphrase) - - elif type(something) is types.StringType: - if algoPub != None: - raise Exception("Don't specify algoPub if importing a key") - if self.importKey(something, passphrase=passphrase) == False: - raise CryptoKeyError( - "Attempted to import invalid key, or passphrase is bad") - self.randpool = RandomPool() - self.randfunc = self.randpool.get_bytes - else: - raise Exception("Must pass keysize or importable keys") - - #@-body - #@-node:2::__init__ - #@+node:3::makeNewKeys() - #@+body - def makeNewKeys(self, keysize=512, **kwds): - """ - Creates a new keypair in cipher object, and a new session key - - Arguments: - - keysize (default 512), advise at least 1536 - Returns: - - None - Keywords: - - passphrase - used to secure exported private key - default '' (no passphrase) - - Keypair gets stored within the key object. Refer L{exportKey}, - L{exportKeyPrivate} and L{importKey}. - - Generally no need to call this yourself, since the constructor - calls this in cases where you aren't instantiating with an - importable key. - """ - - passphrase = kwds.get('passphrase', '') - if passphrase == None: - passphrase = '' - self.passphrase = passphrase - - # set up a public key object - self.randpool.stir() - self.k = self.algoPub.generate(keysize, self.randfunc) - self.randpool.stir() - self._calcPubBlkSize() - - # Generate random session key - self._genNewSessKey() - - # Create session cipher object - self.randpool.stir() - - #trace() - - # Create a new block cipher object - self._initBlkCipher() - - #@-body - #@-node:3::makeNewKeys() - #@+node:4::importKey() - #@+body - def importKey(self, keystring, **kwds): - """ - Imports a public key or private/public key pair. - - (as previously exported from this object - with the L{exportKey} or L{exportKeyPrivate} methods.) - - Arguments: - - keystring - a string previously imported with - L{exportKey} or L{exportKeyPrivate} - Keywords: - - passphrase - string (default '', meaning 'try to import without passphrase') - Returns: - - True if import successful, False if failed - - You don't have to call this if you instantiate your key object - in 'import' mode - ie, by calling it with a previously exported key. - - Note - you shouldn't give a 'passphrase' when importing a public key. - """ - - passphrase = kwds.get('passphrase', '') - if passphrase == None: - passphrase = '' - - try: - #k1 = keystring.split("<StartPycryptoKey>", 1) - #k2 = k1[1].split("<EndPycryptoKey>") - ##print "decoding:\n", k2[0] - #k = base64.decodestring(k2[0]) - - #trace() - - keypickle = self._unwrap("Key", keystring) - keytuple = pickle.loads(keypickle) - haspass, size, keyobj = keytuple - - if haspass: - # decrypt against passphrase - blksiz = 8 # lazy of me - - # create temporary symmetric cipher object for passphrase - hardwire to Blowfish - ppCipher = Blowfish.new(passphrase, - Blowfish.MODE_CFB, - self._passIV[0:blksiz]) - enclen = len(keyobj) - decpriv = '' - i = 0 - while i < enclen: - decbit = ppCipher.decrypt(keyobj[i:i+blksiz]) - decpriv += decbit - i += blksiz - keyobj = decpriv[0:size] - - self.algoPname, self.k = pickle.loads(keyobj) - self.algoPub = self._algosPub[self.algoPname] - - #raise Exception("Tried to import Invalid Key") - self._calcPubBlkSize() - self.passphrase = passphrase - return True - except: - return False - - #@-body - #@-node:4::importKey() - #@+node:5::exportKey() - #@+body - def exportKey(self): - """ - Exports the public key as a printable string. - - Exported keys can be imported elsewhere into MyCipher instances - with the L{importKey} method. - - Note that this object contains only the public key. If you want to - export the private key as well, call L{exportKeyPrivate} instaead. - - Note also that the exported string is Base64-encoded, and safe for sending - in email. - - Arguments: - - None - Returns: - - a base64-encoded string containing an importable key - """ - rawpub = self._rawPubKey() - expTuple = (False, None, rawpub) - expPickle = pickle.dumps(expTuple, True) - return self._wrap("Key", expPickle) - - #@-body - #@-node:5::exportKey() - #@+node:6::exportKeyPrivate() - #@+body - def exportKeyPrivate(self, **kwds): - """ - Exports public/private key pair as a printable string. - - This string is a binary string consisting of a pickled key object, - that can be imported elsewhere into MyCipher instances - with the L{importKey} method. - - Note that this object contains the public AND PRIVATE keys. - Don't EVER email any keys you export with this function (unless you - know what you're doing, and you encrypt the exported keys against - another key). When in doubt, use L{exportKey} instead. - - Keep your private keys safe at all times. You have been warned. - - Note also that the exported string is Base64-encoded, and safe for sending - in email. - - Arguments: - - None - Keywords: - - passphrase - default (None) to using existing passphrase. Set to '' to export - without passphrase (if this is really what you want to do!) - Returns: - - a base64-encoded string containing an importable key - """ - - passphrase = kwds.get('passphrase', None) - if passphrase == None: - passphrase = self.passphrase - - # exported key is a pickle of the tuple: - # (haspassphrase, keylen, keypickle) - # if using passphrase, 'keypickle' is encrypted against blowfish, and 'keylen' - # indicates the number of significant bytes. - - rawpriv = pickle.dumps((self.algoPname, self.k), True) - - # prepare the key tuple, depending on whether we're using passphrases - if passphrase != '': - blksiz = 8 # i'm getting lazy, assuming 8 for blowfish - - # encrypt this against passphrase - ppCipher = Blowfish.new(passphrase, - Blowfish.MODE_CFB, - self._passIV[0:blksiz]) - keylen = len(rawpriv) - extras = (blksiz - (keylen % blksiz)) % blksiz - rawpriv += self.randfunc(extras) # padd with random bytes - newlen = len(rawpriv) - encpriv = '' - #print "newlen = %d" % newlen - #trace() - i = 0 - while i < newlen: - rawbit = rawpriv[i:i+blksiz] - encbit = ppCipher.encrypt(rawpriv[i:i+blksiz]) - #print "i=%d rawbit len=%d, encbit len=%d" % (i, len(rawbit), len(encbit)) - encpriv += encbit - i += blksiz - #print "keylen=%d, newlen=%d, len(encpriv)=%d" % (keylen, newlen, len(encpriv)) - #trace() - keytuple = (True, keylen, encpriv) - else: - keytuple = (False, None, rawpriv) - - # prepare final pickle, base64 encode, wrap - keypickle = pickle.dumps(keytuple, True) - return self._wrap("Key", keypickle) - - - - #@-body - #@-node:6::exportKeyPrivate() - #@+node:7::encString() - #@+body - def encString(self, raw): - """ - Encrypt a string of data - - High-level func. encrypts an entire string of data, returning the encrypted - string as binary. - - Arguments: - - raw string to encrypt - Returns: - - encrypted string as binary - - Note - the encrypted string can be stored in files, but I'd suggest - not emailing them - use L{encStringToAscii} instead. The sole advantage - of this method is that it produces more compact data, and works a bit faster. - """ - - # All the work gets done by the stream level - self.encStart() - - # carve up into segments, because Python gets really slow - # at manipulating large strings - - size = len(raw) - bits = [] - pos = 0 - chunklen = 1024 - while pos < size: - bits.append(self.encNext(raw[pos:pos+chunklen])) - pos += chunklen - bits.append(self.encEnd()) - - return "".join(bits) - - #@-body - #@-node:7::encString() - #@+node:8::encStringToAscii() - #@+body - def encStringToAscii(self, raw): - """ - Encrypts a string of data to printable ASCII format - - Use this method instead of L{encString}, unless size and speed are - major issues. - - This method returns encrypted data in bracketed base64 format, - safe for sending in email. - - Arguments: - - raw - string to encrypt - Returns: - - enc - encrypted string, text-wrapped and Base-64 encoded, safe for - mailing. - - There's an overhead with base64-encoding. It costs size, bandwidth and - speed. Unless you need ascii-safety, use encString() instead. - """ - enc = self.encString(raw) - return self._wrap("Message", enc) - - #@-body - #@-node:8::encStringToAscii() - #@+node:9::decString() - #@+body - def decString(self, enc): - """ - Decrypts a previously encrypted string. - - Arguments: - - enc - string, previously encrypted in binary mode with encString - Returns: - - dec - raw decrypted string - """ - - chunklen = 1024 - - size = len(enc) - bits = [] - pos = 0 - - self.decStart() - - # carve up into small chunks so we don't get any order n^2 on large strings - while pos < size: - bits.append(self.decNext(enc[pos:pos+chunklen])) - pos += chunklen - - self.decEnd() - - dec = "".join(bits) - return dec - - #@-body - #@-node:9::decString() - #@+node:10::decStringFromAscii() - #@+body - def decStringFromAscii(self, enc): - """ - Decrypts a previously encrypted string in ASCII (base64) - format, as created by encryptAscii() - - Arguments: - - enc - ascii-encrypted string, as previously encrypted with - encStringToAscii() - Returns: - - dec - decrypted string - - May generate an exception if the public key of the encrypted string - doesn't match the public/private keypair in this key object. - - To work around this problem, either instantiate a key object with - the saved keypair, or use the importKey() function. - - Exception will also occur if this object is not holding a private key - (which can happen if you import a key which was previously exported - via exportKey(). If you get this problem, use exportKeyPrivate() instead - to export your keypair. - """ - #trace() - wrapped = self._unwrap("Message", enc) - return self.decString(wrapped) - - #@-body - #@-node:10::decStringFromAscii() - #@+node:11::signString() - #@+body - def signString(self, raw): - """ - Sign a string using private key - - Arguments: - - raw - string to be signed - Returns: - - wrapped, base-64 encoded string of signature - - Note - private key must already be present in the key object. - Call L{importKey} for the right private key first if needed. - """ - - # hash the key with MD5 - m = MD5.new() - m.update(raw) - d = m.digest() - #print "sign: digest" - #print repr(d) - - # sign the hash with our current public key cipher - self.randpool.stir() - k = getPrime(128, self.randfunc) - self.randpool.stir() - s = self.k.sign(d, k) - - # now wrap into a tuple with the public key cipher - tup = (self.algoPname, s) - - # and pickle it - p = pickle.dumps(tup, True) - - # lastly, wrap it into our base64 - w = self._wrap("Signature", p) - - return w - - #@-body - #@-node:11::signString() - #@+node:12::verifyString() - #@+body - def verifyString(self, raw, signature): - """ - Verifies a string against a signature. - - Object must first have the correct public key loaded. (see - L{importKey}). An exception will occur if this is not the case. - - Arguments: - - raw - string to be verified - - signature - as produced when key is signed with L{signString} - Returns: - - True if signature is authentic, or False if not - """ - - # unrwap the signature to a pickled tuple - p = self._unwrap("Signature", signature) - - # unpickle - algoname, rawsig = pickle.loads(p) - - # ensure we've got the right algorithm - if algoname != self.algoPname: - return False # wrong algorithm - automatic fail - - # hash the string - m = MD5.new() - m.update(raw) - d = m.digest() - #print "verify: digest" - #print repr(d) - - # now verify the hash against sig - if self.k.verify(d, rawsig): - return True # signature valid, or very clever forgery - else: - return False # sorry - - #@-body - #@-node:12::verifyString() - #@+node:13::test() - #@+body - def test(self, raw): - """ - Encrypts, then decrypts a string. What you get back should - be the same as what you put in. - - This is totally useless - it just gives a way to test if this API - is doing what it should. - """ - enc = self.encString(raw) - dec = self.decString(enc) - return dec - - #@-body - #@-node:13::test() - #@+node:14::testAscii() - #@+body - def testAscii(self, raw): - """ - Encrypts, then decrypts a string. What you get back should - be the same as what you put in. - - This is totally useless - it just gives a way to test if this API - is doing what it should. - """ - enc = self.encStringToAscii(raw) - dec = self.decStringFromAscii(enc) - return dec - - #@-body - #@-node:14::testAscii() - #@+node:15::Stream Methods - #@+body - # --------------------------------------------- - # - # These methods provide stream-level encryption - # - # --------------------------------------------- - - - #@-body - #@+node:1::encStart() - #@+body - def encStart(self): - """ - Starts a stream encryption session - Sets up internal buffers for accepting ad-hoc data. - - No arguments needed, nothing returned. - """ - - # Create a header block of segments, each segment is - # encrypted against recipient's public key, to enable - # recipient to decrypt the rest of the stream. - - # format of header block is: - # - recipient public key - # - stream algorithm id - # - stream session key - # - stream cipher initial value - - # Take algorithm index and pad it to the max length - - # stick in pubkey - pubkey = self._rawPubKey() - pubkeyLen = len(pubkey) - - self._tstSessKey0 = '' - self._tstSessKey1 = '' - self._tstIV0 = '' - self._tstIV1 = '' - self._tstBlk0 = '' - self._tstBlk1 = '' - - #print "pub key len=%d" % pubkeyLen - - len0 = pubkeyLen % 256 - len1 = pubkeyLen / 256 - - # Create algorithms info blk. Structure is: - # 1byte - index into session ciphers table - # 2bytes - session key len, LSB first - # 1byte - session IV len, LSB first - - while 1: - self._encHdrs = chr(len0) + chr(len1) + pubkey - - # add algorithms index - algInfo = chr(self._algosSes2.index(self.algoSes)) - - # Create new session key - self._genNewSessKey() - - # add session key length - sessKeyLen = len(self.sessKey) - sessKeyLenL = sessKeyLen % 256 - sessKeyLenH = sessKeyLen / 256 - algInfo += chr(sessKeyLenL) + chr(sessKeyLenH) - - # add session IV length - sessIVLen = len(self.sessIV) - algInfo += chr(sessIVLen) - #alg += self.randfunc(self.pubBlkSize - 1) # add random chaff - #encAlgNum = self._encRawPub(alg) - encAlgEnc = self._encRawPub(self._padToPubBlkSize(algInfo)) - if encAlgEnc == None: - continue - #encAlgLen = len(encAlgNum) - #self._encHdrs += chr(encAlgLen) + encAlgNum - self._encHdrs += encAlgEnc - - # ensure we can encrypt session key in one hit - if len(self.sessKey) > self.pubBlkSize: - raise Exception( - "encStart: you need a bigger public key length") - - # encrypt and add session key - sKeyEnc = self._encRawPub(self._padToPubBlkSize(self.sessKey)) - if sKeyEnc == None: - continue - # sKeyLen = len(sKeyEnc) - # self._encHdrs += chr(sKeyLen) + sKeyEnc - self._encHdrs += sKeyEnc - - # encrypt and add session cipher initial value - sCipherInit = self._encRawPub(self._padToPubBlkSize(self.sessIV)) - if sCipherInit == None: - continue - # sCipherIVLen = len(sCipherInit) - # self._encHdrs += chr(sCipherIVLen) + sCipherInit - self._encHdrs += sCipherInit - - self._tstSessKey0 = self.sessKey - self._tstIV0 = self.sessIV - - # Create a new block cipher object - self._initBlkCipher() - - # ready to go! - self._encBuf = '' - - # success - break - - #@-body - #@-node:1::encStart() - #@+node:2::encNext() - #@+body - def encNext(self, raw=''): - """ - Encrypt the next piece of data in a stream. - - Arguments: - - raw - raw piece of data to encrypt - Returns - one of: - - '' - not enough data to encrypt yet - stored for later - - encdata - string of encrypted data - """ - - if raw == '': - return '' - - # grab any headers - enc = self._encHdrs - self._encHdrs = '' - - # add given string to our yet-to-be-encrypted buffer - self._encBuf += raw - - # Loop on data, breaking it up and encrypting it in blocks. Don't - # touch the last (n mod b) bytes in buffer, where n is total size and - # b is blocksize - size = len(self._encBuf) - next = 0 - while next <= size - self.sesBlkSize: # skip trailing bytes for now - # extract next block - blk = self._encBuf[next:next+self.sesBlkSize] - - if self._tstBlk0 == '': - self._tstBlk0 = blk - - # encrypt block against session key - encpart = self.blkCipher.encrypt(blk) - - # add length byte and crypted block to internal buffer - enc += chr(self.sesBlkSize) + encpart - - next += self.sesBlkSize - - # ditch what we've consumed from buffer - self._encBuf = self._encBuf[next:] - - # return whatever we've encrypted so far - return enc - - #@-body - #@-node:2::encNext() - #@+node:3::encEnd() - #@+body - def encEnd(self): - """ - Called to terminate a stream session. - Encrypts any remaining data in buffer. - - Arguments: - - None - Returns - one of: - - last block of data, as a string - """ - - buf = '' - if self._encBuf == '': - # no trailing data - pass back empty packet - return chr(0) - - # break up remaining data into packets, and encrypt - while len(self._encBuf) > 0: - - # extract session blocksize worth of data from buf - blk = self._encBuf[0:self.sesBlkSize] - self._encBuf = self._encBuf[self.sesBlkSize:] - blklen = len(blk) - - # pad if needed - if blklen < self.sesBlkSize: - blk += self.randfunc(self.sesBlkSize - blklen) - - # encrypt against session key, and add - buf += chr(blklen) - buf += self.blkCipher.encrypt(blk) - - # clean up and get out - return buf - - #@-body - #@-node:3::encEnd() - #@+node:4::decStart() - #@+body - def decStart(self): - """ - Start a stream decryption session. - - Call this method first, then feed in pieces of stream data into decNext until - there's no more data to decrypt - - Arguments: - - None - Returns: - - None - """ - - # Start with fresh buffer and initial state - self._decBuf = '' - self._decState = 'p' - self._decEmpty = False - - self._tstSessKey1 = '' - self._tstIV1 = '' - self._tstBlk1 = '' - - # states - 'p'->awaiting public key - # 'c'->awaiting cipher index - # 'k'->awaiting session key - # 'i'->awaiting cipher initial data - # 'd'->awaiting data block - - #@-body - #@-node:4::decStart() - #@+node:5::decNext() - #@+body - def decNext(self, chunk): - """ - Decrypt the next piece of incoming stream data. - - Arguments: - - chunk - some more of the encrypted stream - Returns (depending on state) - - '' - no more decrypted data available just yet - - data - the next available piece of decrypted data - - None - session is complete - no more data available - """ - - if self._decEmpty: - return None - - # add chunk to our buffer - self._decBuf += chunk - - # bail out if nothing to do - chunklen = len(self._decBuf) - if chunklen < 2: - return '' - - # start with empty decryption buffer - decData = '' - - # loop around processing as much data as we can - #print "decNext: started" - while 1: - if self._decState == 'p': - size = ord(self._decBuf[0]) + 256 * ord(self._decBuf[1]) - if chunklen < size + 2: - # don't have full pubkey yet - return '' - else: - pubkey = self._decBuf[2:size+2] - if not self._testPubKey(pubkey): - raise Exception("Can't decrypt - public key mismatch") - - self._decBuf = self._decBuf[size+2:] - self._decState = 'c' - continue - - if self._decState == 'd': - - #trace() - - # awaiting next data chunk - sizeReqd = self.sesBlkSize + 1 - size = len(self._decBuf) - if size < sizeReqd: - return decData - nbytes = ord(self._decBuf[0]) - if nbytes == 0: - self._decEmpty = True - return None - blk = self._decBuf[1:sizeReqd] - self._decBuf = self._decBuf[sizeReqd:] - decBlk = self.blkCipher.decrypt(blk) - if self._tstBlk1 == '': - self._tstBlk1 = decBlk - decBlk = decBlk[0:nbytes] - decData += decBlk - if nbytes < self.sesBlkSize: - self._decEmpty = True - return decData - continue - - if len(self._decBuf) < 2: - return decData - - sizeReqd = ord(self._decBuf[0]) + 256 * ord(self._decBuf[1]) + 2 - size = len(self._decBuf) - - # bail if we have insufficient data - if size < sizeReqd: - return decData - - # extract length byte plus block - #blksize = sizeReqd - 1 - #blk = self._decBuf[1:sizeReqd] - #self._decBuf = self._decBuf[sizeReqd:] - blk = self._decBuf[0:sizeReqd] - self._decBuf = self._decBuf[sizeReqd:] - - # state-dependent processing - if self._decState == 'c': - #print "decrypting cipher info" - # awaiting cipher info - blk = self._decRawPub(blk) - - # session cipher index - c = ord(blk[0]) - self.algoSes = self._algosSes2[c] - - # session key len - self._tmpSessKeyLen = ord(blk[1]) + 256 * ord(blk[2]) - - # session IV len - self._tmpSessIVLen = ord(blk[3]) - - # ignore the rest - it's just chaff - self._decState = 'k' - continue - - elif self._decState == 'k': - # awaiting session key - #print "decrypting session key" - blk = self._decRawPub(blk) - self.sessKey = blk[0:self._tmpSessKeyLen] - self._tstSessKey1 = self.sessKey - self._decState = 'i' - continue - - elif self._decState == 'i': - # awaiting cipher start value - #print "decrypting IV" - blk = self._decRawPub(blk) - self.sessIV = blk[0:self._tmpSessIVLen] - self._tstIV1 = self.sessIV - - # Create cipher object, now we have what we need - self.blkCipher = self.algoSes.new(self.sessKey, - getattr(self.algoSes, "MODE_CFB"), - self.sessIV) - self._calcSesBlkSize() - self._decState = 'd' - continue - - else: - raise Exception( - "decNext: strange state '%s'" % self._decState[0]) - - #@-body - #@-node:5::decNext() - #@+node:6::decEnd() - #@+body - def decEnd(self): - """ - Ends a stream decryption session. - """ - # nothing really to do here - decNext() has taken care of it - # just reset internal state - self._decBuf = '' - self._decState = 'c' - - #@-body - #@-node:6::decEnd() - #@-node:15::Stream Methods - #@+node:16::Low Level - #@+node:1::_wrap() - #@+body - def _wrap(self, type, msg): - """ - Encodes message as base64 and wraps with <StartPyCryptoname>/<EndPycryptoname> - Args: - - type - string to use in header/footer - eg 'Key', 'Message' - - msg - binary string to wrap - """ - return "<StartPycrypto%s>\n%s<EndPycrypto%s>\n" \ - % (type, base64.encodestring(msg), type) - - #@-body - #@-node:1::_wrap() - #@+node:2::_unwrap() - #@+body - def _unwrap(self, type, msg): - """ - Unwraps a previously _wrap()'ed message. - """ - try: - #trace() - k1 = msg.split("<StartPycrypto%s>" % type, 1) - k2 = k1[1].split("<EndPycrypto%s>" % type) - k = k2[0] - #print "raw = " - #print k - bin = base64.decodestring(k) - return bin - except: - raise Exception("Tried to import Invalid %s" % type) - self._calcBlkSize() - - #@-body - #@-node:2::_unwrap() - #@+node:3::_calcPubBlkSize() - #@+body - def _calcPubBlkSize(self): - """ - Determine size of public key - """ - self.pubBlkSize = (self.k.size() - 7) / 8 - - #@-body - #@-node:3::_calcPubBlkSize() - #@+node:4::_encRawPub() - #@+body - def _encRawPub(self, raw): - """ - Encrypt a small raw string using the public key - algorithm. Input must not exceed the allowable - block size. - - Arguments: - - raw - small raw bit of string to encrypt - Returns: - - binary representation of encrypted chunk, or None if verify failed - """ - - if len(raw) > self.pubBlkSize: - raise Exception( - "_encraw: max len %d, passed %d bytes" % (self.pubBlkSize, len(raw))) - - self.randpool.stir() - k = getPrime(128, self.randfunc) - s = self.k.encrypt(raw, k) - #d = self.k.decrypt(s) - #if d != raw: - # #print "_encRawPub: decrypt verify fail" - # return None - - #trace() - - # format this tuple into <len><nitems><item1len><item1bytes><item2len><item2bytes>... - enc = chr(len(s)) - for item in s: - itemLen = len(item) - itemLenL = itemLen % 256 - itemLenH = itemLen / 256 - #enc += chr(len(item)) - enc += chr(itemLenL) + chr(itemLenH) - enc += item - encLen = len(enc) - encLenL = encLen % 256 - encLenH = encLen / 256 - #enc = chr(len(enc)) + enc - enc = chr(encLenL) + chr(encLenH) + enc - - #d = self._decRawPub(enc) - #if d != raw: - # print "panic:_encRawPub: decrypt verify fail!" - - return enc - - - #@-body - #@-node:4::_encRawPub() - #@+node:5::_decRawPub() - #@+body - def _decRawPub(self, enc): - """ - Decrypt a public-key encrypted block, and return the decrypted string - - Arguments: - - enc - the encrypted string, in the format as created by _encRawPub() - Returns: - - decrypted block - """ - - #trace() - - blklen = ord(enc[0]) + 256 * ord(enc[1]) - nparts = ord(enc[2]) - enc = enc[3:] - - if blklen != len(enc)+1: - raise Exception( - "_decRawPub: bad block length %d, should be %d" % (len(enc), blklen)) - parts = [] - for i in range(nparts): - partlen = ord(enc[0]) + 256 * ord(enc[1]) - part = enc[2:partlen+2] - enc = enc[partlen+2:] - parts.append(part) - partsTuple = tuple(parts) - dec = self.k.decrypt(partsTuple) - return dec - - - - #@-body - #@-node:5::_decRawPub() - #@+node:6::_initBlkCipher() - #@+body - def _initBlkCipher(self): - """ - Create a new block cipher object, set up with a new session key - and IV - """ - - self.blkCipher = self.algoSes.new(self.sessKey, - getattr(self.algoSes, "MODE_CFB"), - self.sessIV) - self._calcSesBlkSize() - - #@-body - #@-node:6::_initBlkCipher() - #@+node:7::_calcSesBlkSize() - #@+body - def _calcSesBlkSize(self): - """ - Determine size of session blocks - """ - self.sesBlkSize = (self.blkCipher.block_size) - - #@-body - #@-node:7::_calcSesBlkSize() - #@+node:8::_testPubKey() - #@+body - def _testPubKey(self, k): - """ - Checks if binary-encoded key matches this object's pubkey - """ - - if k == self._rawPubKey(): - return True - else: - return False - - #@-body - #@-node:8::_testPubKey() - #@+node:9::_rawPubKey() - #@+body - def _rawPubKey(self): - """ - Returns a binary-encoded string of public key - """ - return pickle.dumps((self.algoPname, self.k.publickey()), True) - - #@-body - #@-node:9::_rawPubKey() - #@+node:10::_padToPubBlkSize() - #@+body - - def _padToPubBlkSize(self, raw): - """ - padToPubBlkSize - pad a string to max size encryptable by public key - - Defence against factoring attacks that can uplift a session key when - that key is encrypted by itself against public key - - Arguments: - - raw - string to pad with random bytes - returns: - - padded string. Note - it is the responsibility of the decryption - code to know how much of the string to extract once decrypted. - """ - - rawlen = len(raw) - extras = self.randfunc(self.pubBlkSize - rawlen) - #print "padToPubBlkSize: len=%d, added %d bytes of chaff :)" \ - # % (rawlen, len(extras)) - return raw + extras - - #@-body - #@-node:10::_padToPubBlkSize() - #@+node:11::_genNewSessKey() - #@+body - def _genNewSessKey(self): - """ - Generate a new random session key - """ - self.randpool.stir() - self.sessKey = self.randfunc(32) - self.randpool.stir() - self.sessIV = self.randfunc(8) - - #@-body - #@-node:11::_genNewSessKey() - #@-node:16::Low Level - #@-others - - -#@-body -#@-node:3::class key -#@-others - - - -#@-body -#@-node:0::@file easy/ezPyCrypto.py -#@-leo diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index c33036e183..ffea1ddc20 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -1,16 +1,15 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' """ The GUI """ -import sys, os, re, StringIO, traceback, time +import os from PyQt4.QtCore import QVariant, QFileInfo, QObject, SIGNAL, QBuffer, Qt, QSize, \ - QByteArray, QLocale, QUrl, QTranslator, QCoreApplication, \ - QModelIndex + QByteArray, QUrl, QTranslator, QCoreApplication from PyQt4.QtGui import QFileDialog, QMessageBox, QPixmap, QFileIconProvider, \ - QIcon, QTableView, QDialogButtonBox, QApplication, QDialog + QIcon, QTableView, QApplication, QDialog ORG_NAME = 'KovidsBrain' APP_UID = 'libprs500' -from calibre import __author__, islinux, iswindows, isosx +from calibre import islinux, iswindows from calibre.startup import get_lang from calibre.utils.config import Config, ConfigProxy, dynamic import calibre.resources as resources @@ -32,7 +31,7 @@ def _config(): help=_('The format to use when saving single files to disk')) c.add_opt('confirm_delete', default=False, help=_('Confirm before deleting')) - c.add_opt('toolbar_icon_size', default=QSize(48, 48), + c.add_opt('toolbar_icon_size', default=QSize(48, 48), help=_('Toolbar icon size')) # value QVariant.toSize c.add_opt('show_text_in_toolbar', default=True, help=_('Show button labels in the toolbar')) @@ -57,16 +56,19 @@ def _config(): c.add_opt('autolaunch_server', default=False, help=_('Automatically launch content server on application startup')) c.add_opt('oldest_news', default=60, help=_('Oldest news kept in database')) c.add_opt('systray_icon', default=True, help=_('Show system tray icon')) - c.add_opt('upload_news_to_device', default=True, + c.add_opt('upload_news_to_device', default=True, help=_('Upload downloaded news to device')) - c.add_opt('delete_news_from_library_on_upload', default=False, + c.add_opt('delete_news_from_library_on_upload', default=False, help=_('Delete books from library after uploading to device')) - c.add_opt('separate_cover_flow', default=False, + c.add_opt('separate_cover_flow', default=False, help=_('Show the cover flow in a separate window instead of in the main calibre window')) - c.add_opt('disable_tray_notification', default=False, + c.add_opt('disable_tray_notification', default=False, help=_('Disable notifications from the system tray icon')) + c.add_opt('default_send_to_device_action', default=None, + help=_('Default action to perform when send to device button is ' + 'clicked')) return ConfigProxy(c) - + config = _config() # Turn off DeprecationWarnings in windows GUI if iswindows: @@ -139,16 +141,16 @@ def human_readable(size): class Dispatcher(QObject): '''Convenience class to ensure that a function call always happens in the GUI thread''' - + SIGNAL = SIGNAL('dispatcher(PyQt_PyObject,PyQt_PyObject)') + def __init__(self, func): QObject.__init__(self) self.func = func - self.connect(self, SIGNAL('edispatch(PyQt_PyObject, PyQt_PyObject)'), - self.dispatch, Qt.QueuedConnection) - + self.connect(self, self.SIGNAL, self.dispatch, Qt.QueuedConnection) + def __call__(self, *args, **kwargs): - self.emit(SIGNAL('edispatch(PyQt_PyObject, PyQt_PyObject)'), args, kwargs) - + self.emit(self.SIGNAL, args, kwargs) + def dispatch(self, args, kwargs): self.func(*args, **kwargs) @@ -157,29 +159,29 @@ class GetMetadata(QObject): Convenience class to ensure that metadata readers are used only in the GUI thread. Must be instantiated in the GUI thread. ''' - + def __init__(self): QObject.__init__(self) self.connect(self, SIGNAL('edispatch(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), self._get_metadata, Qt.QueuedConnection) self.connect(self, SIGNAL('idispatch(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), self._from_formats, Qt.QueuedConnection) - + def __call__(self, id, *args, **kwargs): self.emit(SIGNAL('edispatch(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), id, args, kwargs) - + def from_formats(self, id, *args, **kwargs): self.emit(SIGNAL('idispatch(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), id, args, kwargs) - + def _from_formats(self, id, args, kwargs): try: mi = metadata_from_formats(*args, **kwargs) except: mi = MetaInformation('', [_('Unknown')]) self.emit(SIGNAL('metadataf(PyQt_PyObject, PyQt_PyObject)'), id, mi) - + def _get_metadata(self, id, args, kwargs): try: mi = get_metadata(*args, **kwargs) @@ -191,27 +193,27 @@ class TableView(QTableView): def __init__(self, parent): QTableView.__init__(self, parent) self.read_settings() - + def read_settings(self): self.cw = dynamic[self.__class__.__name__+'column widths'] - + def write_settings(self): dynamic[self.__class__.__name__+'column widths'] = \ tuple([int(self.columnWidth(i)) for i in range(self.model().columnCount(None))]) - + def restore_column_widths(self): if self.cw and len(self.cw): for i in range(len(self.cw)): self.setColumnWidth(i, self.cw[i]) return True - + class FileIconProvider(QFileIconProvider): - + ICONS = { 'default' : 'unknown', 'dir' : 'dir', 'zero' : 'zero', - + 'jpeg' : 'jpeg', 'jpg' : 'jpeg', 'gif' : 'gif', @@ -234,7 +236,7 @@ class FileIconProvider(QFileIconProvider): 'mobi' : 'mobi', 'epub' : 'epub', } - + def __init__(self): QFileIconProvider.__init__(self) self.icons = {} @@ -242,14 +244,14 @@ class FileIconProvider(QFileIconProvider): self.icons[key] = ':/images/mimetypes/'+self.__class__.ICONS[key]+'.svg' for i in ('dir', 'default', 'zero'): self.icons[i] = QIcon(self.icons[i]) - + def key_from_ext(self, ext): key = ext if ext in self.icons.keys() else 'default' if key == 'default' and ext.count('.') > 0: ext = ext.rpartition('.')[2] key = ext if ext in self.icons.keys() else 'default' return key - + def cached_icon(self, key): candidate = self.icons[key] if isinstance(candidate, QIcon): @@ -257,11 +259,11 @@ class FileIconProvider(QFileIconProvider): icon = QIcon(candidate) self.icons[key] = icon return icon - + def icon_from_ext(self, ext): key = self.key_from_ext(ext.lower() if ext else '') return self.cached_icon(key) - + def load_icon(self, fileinfo): key = 'default' icons = self.icons @@ -275,7 +277,7 @@ class FileIconProvider(QFileIconProvider): ext = qstring_to_unicode(fileinfo.completeSuffix()).lower() key = self.key_from_ext(ext) return self.cached_icon(key) - + def icon(self, arg): if isinstance(arg, QFileInfo): return self.load_icon(arg) @@ -284,13 +286,13 @@ class FileIconProvider(QFileIconProvider): if arg == QFileIconProvider.File: return self.icons['default'] return QFileIconProvider.icon(self, arg) - + _file_icon_provider = None def initialize_file_icon_provider(): global _file_icon_provider if _file_icon_provider is None: _file_icon_provider = FileIconProvider() - + def file_icon_provider(): global _file_icon_provider return _file_icon_provider @@ -299,13 +301,13 @@ _sidebar_directories = [] def set_sidebar_directories(dirs): global _sidebar_directories if dirs is None: - dirs = config['frequently_used_directories'] + dirs = config['frequently_used_directories'] _sidebar_directories = [QUrl.fromLocalFile(i) for i in dirs] - + class FileDialog(QObject): - def __init__(self, title='Choose Files', + def __init__(self, title='Choose Files', filters=[], - add_all_files_filter=True, + add_all_files_filter=True, parent=None, modal = True, name = '', @@ -321,16 +323,16 @@ class FileDialog(QObject): ftext += '%s (%s);;'%(text, ' '.join(extensions)) if add_all_files_filter or not ftext: ftext += 'All files (*)' - + self.dialog_name = name if name else 'dialog_' + title self.selected_files = None self.fd = None - + if islinux: self.fd = QFileDialog(parent) - self.fd.setFileMode(mode) + self.fd.setFileMode(mode) self.fd.setIconProvider(_file_icon_provider) - self.fd.setModal(modal) + self.fd.setModal(modal) self.fd.setNameFilter(ftext) self.fd.setWindowTitle(title) state = dynamic[self.dialog_name] @@ -347,7 +349,7 @@ class FileDialog(QObject): f = qstring_to_unicode( QFileDialog.getSaveFileName(parent, title, dir, ftext, "")) if os.path.exists(f): - self.selected_files.append(f) + self.selected_files.append(f) elif mode == QFileDialog.ExistingFile: f = qstring_to_unicode( QFileDialog.getOpenFileName(parent, title, dir, ftext, "")) @@ -367,44 +369,44 @@ class FileDialog(QObject): if self.selected_files: self.selected_files = [qstring_to_unicode(q) for q in self.selected_files] dynamic[self.dialog_name] = os.path.dirname(self.selected_files[0]) - self.accepted = bool(self.selected_files) - - - + self.accepted = bool(self.selected_files) + + + def get_files(self): if islinux and self.fd.result() != self.fd.Accepted: - return tuple() - if self.selected_files is None: + return tuple() + if self.selected_files is None: return tuple(os.path.abspath(qstring_to_unicode(i)) for i in self.fd.selectedFiles()) return tuple(self.selected_files) - + def save_dir(self): if self.fd: dynamic[self.dialog_name] = self.fd.saveState() - + def choose_dir(window, name, title): - fd = FileDialog(title, [], False, window, name=name, + fd = FileDialog(title, [], False, window, name=name, mode=QFileDialog.DirectoryOnly) dir = fd.get_files() if dir: return dir[0] -def choose_files(window, name, title, +def choose_files(window, name, title, filters=[], all_files=True, select_only_single_file=False): ''' Ask user to choose a bunch of files. @param name: Unique dialog name used to store the opened directory @param title: Title to show in dialogs titlebar @param filters: list of allowable extensions. Each element of the list - must be a 2-tuple with first element a string describing + must be a 2-tuple with first element a string describing the type of files to be filtered and second element a list - of extensions. + of extensions. @param all_files: If True add All files to filters. - @param select_only_single_file: If True only one file can be selected + @param select_only_single_file: If True only one file can be selected ''' mode = QFileDialog.ExistingFile if select_only_single_file else QFileDialog.ExistingFiles - fd = FileDialog(title=title, name=name, filters=filters, + fd = FileDialog(title=title, name=name, filters=filters, parent=window, add_all_files_filter=all_files, mode=mode, ) if fd.accepted: @@ -413,8 +415,8 @@ def choose_files(window, name, title, def choose_images(window, name, title, select_only_single_file=True): mode = QFileDialog.ExistingFile if select_only_single_file else QFileDialog.ExistingFiles - fd = FileDialog(title=title, name=name, - filters=[('Images', ['png', 'gif', 'jpeg', 'jpg', 'svg'])], + fd = FileDialog(title=title, name=name, + filters=[('Images', ['png', 'gif', 'jpeg', 'jpg', 'svg'])], parent=window, add_all_files_filter=False, mode=mode, ) if fd.accepted: @@ -432,7 +434,7 @@ def pixmap_to_data(pixmap, format='JPEG'): return str(ba.data()) class ResizableDialog(QDialog): - + def __init__(self, *args, **kwargs): QDialog.__init__(self, *args) self.setupUi(self) @@ -444,14 +446,15 @@ class ResizableDialog(QDialog): nh = min(self.height(), nh) nw = min(self.width(), nw) self.resize(nw, nh) - + try: from calibre.utils.single_qt_application import SingleApplication + SingleApplication except: SingleApplication = None - + class Application(QApplication): - + def __init__(self, args): qargs = [i.encode('utf-8') if isinstance(i, unicode) else i for i in args] QApplication.__init__(self, qargs) @@ -462,6 +465,6 @@ class Application(QApplication): if data: self.translator.loadFromData(data) self.installTranslator(self.translator) - - - + + + diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index a77ec1beb4..6d6d884807 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -1,19 +1,35 @@ +from __future__ import with_statement __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' -import os, traceback, Queue, time -from threading import Thread +import os, traceback, Queue, time, socket +from threading import Thread, RLock +from itertools import repeat +from functools import partial + +from PyQt4.Qt import QMenu, QAction, QActionGroup, QIcon, SIGNAL, QPixmap, \ + Qt from calibre.devices import devices +from calibre.gui2.dialogs.choose_format import ChooseFormatDialog from calibre.parallel import Job from calibre.devices.scanner import DeviceScanner +from calibre.gui2 import config, error_dialog, Dispatcher, dynamic, \ + pixmap_to_data, warning_dialog +from calibre.ebooks.metadata import authors_to_string +from calibre.gui2.dialogs.conversion_error import ConversionErrorDialog +from calibre.devices.interface import Device +from calibre import sanitize_file_name, preferred_encoding +from calibre.utils.filenames import ascii_filename +from calibre.devices.errors import FreeSpaceError +from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \ + config as email_config - class DeviceJob(Job): - + def __init__(self, func, *args, **kwargs): Job.__init__(self, *args, **kwargs) self.func = func - + def run(self): self.start_work() try: @@ -23,16 +39,12 @@ class DeviceJob(Job): self.traceback = traceback.format_exc() finally: self.job_done() - + class DeviceManager(Thread): - ''' - Worker thread that polls the USB ports for devices. Emits the - signal connected(PyQt_PyObject, PyQt_PyObject) on connection and - disconnection events. - ''' + def __init__(self, connected_slot, job_manager, sleep_time=2): - ''' + ''' @param sleep_time: Time to sleep between device probes in millisecs @type sleep_time: integer ''' @@ -48,7 +60,7 @@ class DeviceManager(Thread): self.job_manager = job_manager self.current_job = None self.scanner = DeviceScanner() - + def detect_device(self): self.scanner.scan() for device in self.devices: @@ -63,8 +75,8 @@ class DeviceManager(Thread): except: print 'Unable to open device' traceback.print_exc() - finally: - device[1] = True + finally: + device[1] = True elif not connected and device[1]: while True: try: @@ -75,14 +87,14 @@ class DeviceManager(Thread): self.device = None self.connected_slot(False) device[1] ^= True - + def next(self): if not self.jobs.empty(): try: return self.jobs.get_nowait() except Queue.Empty: pass - + def run(self): while self.keep_going: self.detect_device() @@ -94,75 +106,80 @@ class DeviceManager(Thread): self.current_job.run() self.current_job = None else: - break + break time.sleep(self.sleep_time) - + def create_job(self, func, done, description, args=[], kwargs={}): - job = DeviceJob(func, done, self.job_manager, + job = DeviceJob(func, done, self.job_manager, args=args, kwargs=kwargs, description=description) self.job_manager.add_job(job) self.jobs.put(job) return job - + + def has_card(self): + try: + return bool(self.device.card_prefix()) + except: + return False + def _get_device_information(self): info = self.device.get_device_information(end_session=False) info = [i.replace('\x00', '').replace('\x01', '') for i in info] cp = self.device.card_prefix(end_session=False) fs = self.device.free_space() return info, cp, fs - + def get_device_information(self, done): '''Get device information and free space on device''' - return self.create_job(self._get_device_information, done, + return self.create_job(self._get_device_information, done, description=_('Get device information')) - - + def _books(self): '''Get metadata from device''' mainlist = self.device.books(oncard=False, end_session=False) cardlist = self.device.books(oncard=True) return (mainlist, cardlist) - + def books(self, done): '''Return callable that returns the list of books on device as two booklists''' return self.create_job(self._books, done, description=_('Get list of books on device')) - + def _sync_booklists(self, booklists): '''Sync metadata to device''' self.device.sync_booklists(booklists, end_session=False) return self.device.card_prefix(end_session=False), self.device.free_space() - + def sync_booklists(self, done, booklists): return self.create_job(self._sync_booklists, done, args=[booklists], description=_('Send metadata to device')) - + def _upload_books(self, files, names, on_card=False, metadata=None): '''Upload books to device: ''' - return self.device.upload_books(files, names, on_card, + return self.device.upload_books(files, names, on_card, metadata=metadata, end_session=False) - - def upload_books(self, done, files, names, on_card=False, titles=None, + + def upload_books(self, done, files, names, on_card=False, titles=None, metadata=None): desc = _('Upload %d books to device')%len(names) if titles: desc += u':' + u', '.join(titles) - return self.create_job(self._upload_books, done, args=[files, names], + return self.create_job(self._upload_books, done, args=[files, names], kwargs={'on_card':on_card,'metadata':metadata}, description=desc) - + def add_books_to_metadata(self, locations, metadata, booklists): self.device.add_books_to_metadata(locations, metadata, booklists) - + def _delete_books(self, paths): '''Remove books from device''' self.device.delete_books(paths, end_session=True) - + def delete_books(self, done, paths): return self.create_job(self._delete_books, done, args=[paths], description=_('Delete books from device')) - + def remove_books_from_metadata(self, paths, booklists): self.device.remove_books_from_metadata(paths, booklists) - + def _save_books(self, paths, target): '''Copy books from device to disk''' for path in paths: @@ -170,18 +187,469 @@ class DeviceManager(Thread): f = open(os.path.join(target, name), 'wb') self.device.get_file(path, f) f.close() - + def save_books(self, done, paths, target): return self.create_job(self._save_books, done, args=[paths, target], description=_('Download books from device')) - + def _view_book(self, path, target): f = open(target, 'wb') self.device.get_file(path, f) f.close() return target - + def view_book(self, done, path, target): return self.create_job(self._view_book, done, args=[path, target], description=_('View book on device')) - \ No newline at end of file + + +class DeviceAction(QAction): + + def __init__(self, dest, delete, specific, icon_path, text, parent=None): + if delete: + text += ' ' + _('and delete from library') + QAction.__init__(self, QIcon(icon_path), text, parent) + self.dest = dest + self.delete = delete + self.specific = specific + self.connect(self, SIGNAL('triggered(bool)'), + lambda x : self.emit(SIGNAL('a_s(QAction)'), self)) + + def __repr__(self): + return self.__class__.__name__ + ':%s:%s:%s'%(self.dest, self.delete, + self.specific) + + +class DeviceMenu(QMenu): + + def __init__(self, parent=None): + QMenu.__init__(self, parent) + self.group = QActionGroup(self) + self.actions = [] + self._memory = [] + + self.set_default_menu = self.addMenu(_('Set default send to device' + ' action')) + opts = email_config().parse() + default_account = None + if opts.accounts: + self.email_to_menu = self.addMenu(_('Email to')+'...') + keys = sorted(opts.accounts.keys()) + for account in keys: + formats, auto, default = opts.accounts[account] + dest = 'mail:'+account+';'+formats + if default: + default_account = (dest, False, False, ':/images/mail.svg', + _('Email to')+' '+account) + action1 = DeviceAction(dest, False, False, ':/images/mail.svg', + _('Email to')+' '+account, self) + action2 = DeviceAction(dest, True, False, ':/images/mail.svg', + _('Email to')+' '+account, self) + map(self.email_to_menu.addAction, (action1, action2)) + map(self._memory.append, (action1, action2)) + self.email_to_menu.addSeparator() + self.connect(action1, SIGNAL('a_s(QAction)'), + self.action_triggered) + self.connect(action2, SIGNAL('a_s(QAction)'), + self.action_triggered) + + + + + _actions = [ + ('main:', False, False, ':/images/reader.svg', + _('Send to main memory')), + ('card:0', False, False, ':/images/sd.svg', + _('Send to storage card')), + '-----', + ('main:', True, False, ':/images/reader.svg', + _('Send to main memory')), + ('card:0', True, False, ':/images/sd.svg', + _('Send to storage card')), + '-----', + ('main:', False, True, ':/images/reader.svg', + _('Send specific format to main memory')), + ('card:0', False, True, ':/images/sd.svg', + _('Send specific format to storage card')), + + ] + if default_account is not None: + _actions.insert(2, default_account) + _actions.insert(6, list(default_account)) + _actions[6][1] = True + for round in (0, 1): + for dest, delete, specific, icon, text in _actions: + if dest == '-': + (self.set_default_menu if round else self).addSeparator() + continue + action = DeviceAction(dest, delete, specific, icon, text, self) + self._memory.append(action) + if round == 1: + action.setCheckable(True) + action.setText(action.text()) + self.group.addAction(action) + self.set_default_menu.addAction(action) + else: + self.connect(action, SIGNAL('a_s(QAction)'), + self.action_triggered) + self.actions.append(action) + self.addAction(action) + + + da = config['default_send_to_device_action'] + done = False + for action in self.group.actions(): + if repr(action) == da: + action.setChecked(True) + done = True + break + if not done: + action = list(self.group.actions())[0] + action.setChecked(True) + config['default_send_to_device_action'] = repr(action) + + self.connect(self.group, SIGNAL('triggered(QAction*)'), + self.change_default_action) + self.enable_device_actions(False) + if opts.accounts: + self.addSeparator() + self.addMenu(self.email_to_menu) + + def change_default_action(self, action): + config['default_send_to_device_action'] = repr(action) + action.setChecked(True) + + def action_triggered(self, action): + self.emit(SIGNAL('sync(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), + action.dest, action.delete, action.specific) + + def trigger_default(self, *args): + r = config['default_send_to_device_action'] + for action in self.actions: + if repr(action) == r: + self.action_triggered(action) + break + + def enable_device_actions(self, enable): + for action in self.actions: + if action.dest[:4] in ('main', 'card'): + action.setEnabled(enable) + +class Emailer(Thread): + + def __init__(self, timeout=10): + Thread.__init__(self) + self.setDaemon(True) + self.job_lock = RLock() + self.jobs = [] + self._run = True + self.timeout = timeout + + def run(self): + while self._run: + job = None + with self.job_lock: + if self.jobs: + job = self.jobs[0] + self.jobs = self.jobs[1:] + if job is not None: + self._send_mails(*job) + time.sleep(1) + + def stop(self): + self._run = False + + def send_mails(self, jobnames, callback, attachments, to_s, subjects, + texts, attachment_names): + job = (jobnames, callback, attachments, to_s, subjects, texts, + attachment_names) + with self.job_lock: + self.jobs.append(job) + + def _send_mails(self, jobnames, callback, attachments, + to_s, subjects, texts, attachment_names): + opts = email_config().parse() + from_ = opts.from_ + if not from_: + from_ = 'calibre <calibre@'+socket.getfqdn()+'>' + results = [] + for i, jobname in enumerate(jobnames): + try: + msg = compose_mail(from_, to_s[i], texts[i], subjects[i], + open(attachments[i], 'rb'), + attachment_name = attachment_names[i]) + efrom, eto = map(extract_email_address, (from_, to_s[i])) + eto = [eto] + sendmail(msg, efrom, eto, localhost=None, verbose=0, + timeout=self.timeout, relay=opts.relay_host, + username=opts.relay_username, + password=opts.relay_password, port=opts.relay_port, + encryption=opts.encryption) + results.append([jobname, None, None]) + except Exception, e: + results.append([jobname, e, traceback.format_exc()]) + callback(results) + + +class DeviceGUI(object): + + def dispatch_sync_event(self, dest, delete, specific): + rows = self.library_view.selectionModel().selectedRows() + if not rows or len(rows) == 0: + error_dialog(self, _('No books'), _('No books')+' '+\ + _('selected to send')).exec_() + return + + fmt = None + if specific: + d = ChooseFormatDialog(self, _('Choose format to send to device'), + self.device_manager.device_class.FORMATS) + d.exec_() + fmt = d.format().lower() + dest, sub_dest = dest.split(':') + if dest in ('main', 'card'): + if not self.device_connected or not self.device_manager: + error_dialog(self, _('No device'), + _('Cannot send: No device is connected')).exec_() + return + on_card = dest == 'card' + if on_card and not self.device_manager.has_card(): + error_dialog(self, _('No card'), + _('Cannot send: Device has no storage card')).exec_() + return + self.sync_to_device(on_card, delete, fmt) + elif dest == 'mail': + to, fmts = sub_dest.split(';') + fmts = [x.strip().lower() for x in fmts.split(',')] + self.send_by_mail(to, fmts, delete) + + def send_by_mail(self, to, fmts, delete_from_library): + rows = self.library_view.selectionModel().selectedRows() + if not rows or len(rows) == 0: + return + ids = iter(self.library_view.model().id(r) for r in rows) + full_metadata = self.library_view.model().get_metadata( + rows, full_metadata=True)[-1] + files = self.library_view.model().get_preferred_formats(rows, + fmts, paths=True, set_metadata=True) + files = [getattr(f, 'name', None) for f in files] + + bad, remove_ids, jobnames = [], [], [] + texts, subjects, attachments, attachment_names = [], [], [], [] + for f, mi, id in zip(files, full_metadata, ids): + t = mi.title + if not t: + t = _('Unknown') + if f is None: + bad.append(t) + else: + remove_ids.append(id) + jobnames.append(u'%s:%s'%(id, t)) + attachments.append(f) + subjects.append(_('E-book:')+ ' '+t) + a = authors_to_string(mi.authors if mi.authors else \ + [_('Unknown')]) + texts.append(_('Attached, you will find the e-book') + \ + '\n\n' + t + '\n\t' + _('by') + ' ' + a + '\n\n' + \ + _('in the %s format.') % + os.path.splitext(f)[1][1:].upper()) + prefix = sanitize_file_name(t+' - '+a) + if not isinstance(prefix, unicode): + prefix = prefix.decode(preferred_encoding, 'replace') + attachment_names.append(prefix + os.path.splitext(f)[1]) + remove = remove_ids if delete_from_library else [] + + to_s = list(repeat(to, len(attachments))) + if attachments: + self.emailer.send_mails(jobnames, + Dispatcher(partial(self.emails_sent, remove=remove)), + attachments, to_s, subjects, texts, attachment_names) + self.status_bar.showMessage(_('Sending email to')+' '+to, 3000) + + if bad: + bad = '\n'.join('<li>%s</li>'%(i,) for i in bad) + d = warning_dialog(self, _('No suitable formats'), + '<p>'+ _('Could not email the following books ' + 'as no suitable formats were found:<br><ul>%s</ul>')%(bad,)) + d.exec_() + + def emails_sent(self, results, remove=[]): + errors, good = [], [] + for jobname, exception, tb in results: + id = jobname.partition(':')[0] + title = jobname.partition(':')[-1] + if exception is not None: + errors.append([title, exception, tb]) + else: + good.append(title) + if errors: + errors = '\n'.join([ + '<li><b>%s</b><br>%s<br>%s<br></li>' % + (title, e, tb.replace('\n', '<br>')) for \ + title, e, tb in errors + ]) + ConversionErrorDialog(self, _('Failed to email books'), + '<p>'+_('Failed to email the following books:')+\ + '<ul>%s</ul>'%errors, + show=True) + else: + self.status_bar.showMessage(_('Sent by email:') + ', '.join(good), + 5000) + + def cover_to_thumbnail(self, data): + p = QPixmap() + p.loadFromData(data) + if not p.isNull(): + ht = self.device_manager.device_class.THUMBNAIL_HEIGHT \ + if self.device_manager else Device.THUMBNAIL_HEIGHT + p = p.scaledToHeight(ht, Qt.SmoothTransformation) + return (p.width(), p.height(), pixmap_to_data(p)) + + def sync_news(self): + if self.device_connected: + ids = list(dynamic.get('news_to_be_synced', set([]))) + ids = [id for id in ids if self.library_view.model().db.has_id(id)] + files = self.library_view.model().get_preferred_formats_from_ids( + ids, self.device_manager.device_class.FORMATS) + files = [f for f in files if f is not None] + if not files: + dynamic.set('news_to_be_synced', set([])) + return + metadata = self.library_view.model().get_metadata(ids, + rows_are_ids=True) + names = [] + for mi in metadata: + prefix = sanitize_file_name(mi['title']) + if not isinstance(prefix, unicode): + prefix = prefix.decode(preferred_encoding, 'replace') + prefix = ascii_filename(prefix) + names.append('%s_%d%s'%(prefix, id, + os.path.splitext(f.name)[1])) + cdata = mi['cover'] + if cdata: + mi['cover'] = self.cover_to_thumbnail(cdata) + dynamic.set('news_to_be_synced', set([])) + if config['upload_news_to_device'] and files: + remove = ids if \ + config['delete_news_from_library_on_upload'] else [] + on_card = self.location_view.model().free[0] < \ + self.location_view.model().free[1] + self.upload_books(files, names, metadata, + on_card=on_card, + memory=[[f.name for f in files], remove]) + self.status_bar.showMessage(_('Sending news to device.'), 5000) + + + def sync_to_device(self, on_card, delete_from_library, + specific_format=None): + rows = self.library_view.selectionModel().selectedRows() + if not self.device_manager or not rows or len(rows) == 0: + return + ids = iter(self.library_view.model().id(r) for r in rows) + metadata = self.library_view.model().get_metadata(rows) + for mi in metadata: + cdata = mi['cover'] + if cdata: + mi['cover'] = self.cover_to_thumbnail(cdata) + metadata = iter(metadata) + _files = self.library_view.model().get_preferred_formats(rows, + self.device_manager.device_class.FORMATS, + paths=True, set_metadata=True, + specific_format=specific_format) + files = [getattr(f, 'name', None) for f in _files] + bad, good, gf, names, remove_ids = [], [], [], [], [] + for f in files: + mi = metadata.next() + id = ids.next() + if f is None: + bad.append(mi['title']) + else: + remove_ids.append(id) + good.append(mi) + gf.append(f) + t = mi['title'] + if not t: + t = _('Unknown') + a = mi['authors'] + if not a: + a = _('Unknown') + prefix = sanitize_file_name(t+' - '+a) + if not isinstance(prefix, unicode): + prefix = prefix.decode(preferred_encoding, 'replace') + prefix = ascii_filename(prefix) + names.append('%s_%d%s'%(prefix, id, os.path.splitext(f)[1])) + remove = remove_ids if delete_from_library else [] + self.upload_books(gf, names, good, on_card, memory=(_files, remove)) + self.status_bar.showMessage(_('Sending books to device.'), 5000) + if bad: + bad = '\n'.join('<li>%s</li>'%(i,) for i in bad) + d = warning_dialog(self, _('No suitable formats'), + _('Could not upload the following books to the device, ' + 'as no suitable formats were found:<br><ul>%s</ul>')%(bad,)) + d.exec_() + + def upload_booklists(self): + ''' + Upload metadata to device. + ''' + self.device_manager.sync_booklists(Dispatcher(self.metadata_synced), + self.booklists()) + + def metadata_synced(self, job): + ''' + Called once metadata has been uploaded. + ''' + if job.exception is not None: + self.device_job_exception(job) + return + cp, fs = job.result + self.location_view.model().update_devices(cp, fs) + + def upload_books(self, files, names, metadata, on_card=False, memory=None): + ''' + Upload books to device. + :param files: List of either paths to files or file like objects + ''' + titles = [i['title'] for i in metadata] + job = self.device_manager.upload_books( + Dispatcher(self.books_uploaded), + files, names, on_card=on_card, + metadata=metadata, titles=titles + ) + self.upload_memory[job] = (metadata, on_card, memory, files) + + def books_uploaded(self, job): + ''' + Called once books have been uploaded. + ''' + metadata, on_card, memory, files = self.upload_memory.pop(job) + + if job.exception is not None: + if isinstance(job.exception, FreeSpaceError): + where = 'in main memory.' if 'memory' in str(job.exception) \ + else 'on the storage card.' + titles = '\n'.join(['<li>'+mi['title']+'</li>' \ + for mi in metadata]) + d = error_dialog(self, _('No space on device'), + _('<p>Cannot upload books to device there ' + 'is no more free space available ')+where+ + '</p>\n<ul>%s</ul>'%(titles,)) + d.exec_() + else: + self.device_job_exception(job) + return + + self.device_manager.add_books_to_metadata(job.result, + metadata, self.booklists()) + + self.upload_booklists() + + view = self.card_view if on_card else self.memory_view + view.model().resort(reset=False) + view.model().research() + for f in files: + getattr(f, 'close', lambda : True)() + if memory and memory[1]: + self.library_view.model().delete_books_by_id(memory[1]) + + diff --git a/src/calibre/gui2/dialogs/config.py b/src/calibre/gui2/dialogs/config.py index 9958ce53fa..8e7898cc21 100644 --- a/src/calibre/gui2/dialogs/config.py +++ b/src/calibre/gui2/dialogs/config.py @@ -1,12 +1,13 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' import os, re, time, textwrap +from binascii import hexlify, unhexlify from PyQt4.Qt import QDialog, QMessageBox, QListWidgetItem, QIcon, \ QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \ - QStringListModel, QAbstractItemModel, \ + QStringListModel, QAbstractItemModel, QFont, \ SIGNAL, QTimer, Qt, QSize, QVariant, QUrl, \ - QModelIndex, QInputDialog + QModelIndex, QInputDialog, QAbstractTableModel from calibre.constants import islinux, iswindows from calibre.gui2.dialogs.config_ui import Ui_Dialog @@ -21,14 +22,15 @@ from calibre.library import server_config from calibre.customize.ui import initialized_plugins, is_disabled, enable_plugin, \ disable_plugin, customize_plugin, \ plugin_customization, add_plugin, remove_plugin +from calibre.utils.smtp import config as smtp_prefs class PluginModel(QAbstractItemModel): - + def __init__(self, *args): QAbstractItemModel.__init__(self, *args) self.icon = QVariant(QIcon(':/images/plugins.svg')) self.populate() - + def populate(self): self._data = {} for plugin in initialized_plugins(): @@ -37,21 +39,21 @@ class PluginModel(QAbstractItemModel): else: self._data[plugin.type].append(plugin) self.categories = sorted(self._data.keys()) - + def index(self, row, column, parent): if not self.hasIndex(row, column, parent): return QModelIndex() - + if parent.isValid(): return self.createIndex(row, column, parent.row()) else: return self.createIndex(row, column, -1) - + def parent(self, index): if not index.isValid() or index.internalId() == -1: return QModelIndex() return self.createIndex(index.internalId(), 0, -1) - + def rowCount(self, parent): if not parent.isValid(): return len(self.categories) @@ -59,14 +61,14 @@ class PluginModel(QAbstractItemModel): category = self.categories[parent.row()] return len(self._data[category]) return 0 - + def columnCount(self, parent): return 1 - + def index_to_plugin(self, index): category = self.categories[index.parent().row()] return self._data[category][index.row()] - + def plugin_to_index(self, plugin): for i, category in enumerate(self.categories): parent = self.index(i, 0, QModelIndex()) @@ -74,13 +76,13 @@ class PluginModel(QAbstractItemModel): if plugin == p: return self.index(j, 0, parent) return QModelIndex() - + def refresh_plugin(self, plugin, rescan=False): if rescan: self.populate() idx = self.plugin_to_index(plugin) self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), idx, idx) - + def flags(self, index): if not index.isValid(): return 0 @@ -90,7 +92,7 @@ class PluginModel(QAbstractItemModel): if not is_disabled(self.data(index, Qt.UserRole)): flags |= Qt.ItemIsEnabled return flags - + def data(self, index, role): if not index.isValid(): return NONE @@ -113,25 +115,140 @@ class PluginModel(QAbstractItemModel): if role == Qt.UserRole: return plugin return NONE - - + + class CategoryModel(QStringListModel): - + def __init__(self, *args): QStringListModel.__init__(self, *args) - self.setStringList([_('General'), _('Interface'), _('Advanced'), - _('Content\nServer'), _('Plugins')]) - self.icons = list(map(QVariant, map(QIcon, - [':/images/dialog_information.svg', ':/images/lookfeel.svg', - ':/images/view.svg', ':/images/network-server.svg', - ':/images/plugins.svg']))) - + self.setStringList([_('General'), _('Interface'), _('Email\nDelivery'), + _('Advanced'), _('Content\nServer'), _('Plugins')]) + self.icons = list(map(QVariant, map(QIcon, + [':/images/dialog_information.svg', ':/images/lookfeel.svg', + ':/images/mail.svg', ':/images/view.svg', + ':/images/network-server.svg', ':/images/plugins.svg']))) + def data(self, index, role): if role == Qt.DecorationRole: return self.icons[index.row()] return QStringListModel.data(self, index, role) - + + +class EmailAccounts(QAbstractTableModel): + + def __init__(self, accounts): + QAbstractTableModel.__init__(self) + self.accounts = accounts + self.account_order = sorted(self.accounts.keys()) + self.headers = map(QVariant, [_('Email'), _('Formats'), _('Auto send')]) + self.default_font = QFont() + self.default_font.setBold(True) + self.default_font = QVariant(self.default_font) + self.tooltips =[NONE] + map(QVariant, + [_('Formats to email. The first matching format will be sent.'), + '<p>'+_('If checked, downloaded news will be automatically ' + 'mailed <br>to this email address ' + '(provided it is in one of the listed formats).')]) + + def rowCount(self, *args): + return len(self.account_order) + + def columnCount(self, *args): + return 3 + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return self.headers[section] + return NONE + + def data(self, index, role): + row, col = index.row(), index.column() + if row < 0 or row >= self.rowCount(): + return NONE + account = self.account_order[row] + if role == Qt.UserRole: + return (account, self.accounts[account]) + if role == Qt.ToolTipRole: + return self.tooltips[col] + if role == Qt.DisplayRole: + if col == 0: + return QVariant(account) + if col == 1: + return QVariant(self.accounts[account][0]) + if role == Qt.FontRole and self.accounts[account][2]: + return self.default_font + if role == Qt.CheckStateRole and col == 2: + return QVariant(Qt.Checked if self.accounts[account][1] else Qt.Unchecked) + return NONE + + def flags(self, index): + if index.column() == 2: + return QAbstractTableModel.flags(self, index)|Qt.ItemIsUserCheckable + else: + return QAbstractTableModel.flags(self, index)|Qt.ItemIsEditable + + def setData(self, index, value, role): + if not index.isValid(): + return False + row, col = index.row(), index.column() + account = self.account_order[row] + if col == 2: + self.accounts[account][1] ^= True + elif col == 1: + self.accounts[account][0] = unicode(value.toString()).upper() + else: + na = unicode(value.toString()) + from email.utils import parseaddr + addr = parseaddr(na)[-1] + if not addr: + return False + self.accounts[na] = self.accounts.pop(account) + self.account_order[row] = na + if '@kindle.com' in addr: + self.accounts[na][0] = 'AZW, MOBI, TPZ, PRC, AZW1' + + self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), + self.index(index.row(), 0), self.index(index.row(), 2)) + return True + + def make_default(self, index): + if index.isValid(): + row = index.row() + for x in self.accounts.values(): + x[2] = False + self.accounts[self.account_order[row]][2] = True + self.reset() + + def add(self): + x = _('new email address') + y = x + c = 0 + while y in self.accounts: + c += 1 + y = x + str(c) + self.accounts[y] = ['MOBI, EPUB', True, + len(self.account_order) == 0] + self.account_order = sorted(self.accounts.keys()) + self.reset() + return self.index(self.account_order.index(y), 0) + + def remove(self, index): + if index.isValid(): + row = self.index.row() + account = self.account_order[row] + self.accounts.pop(account) + self.account_order = sorted(self.accounts.keys()) + has_default = False + for account in self.account_order: + if self.accounts[account][2]: + has_default = True + break + if not has_default and self.account_order: + self.accounts[self.account_order[0]][2] = True + + self.reset() + class ConfigDialog(QDialog, Ui_Dialog): @@ -141,9 +258,9 @@ class ConfigDialog(QDialog, Ui_Dialog): self.ICON_SIZES = {0:QSize(48, 48), 1:QSize(32,32), 2:QSize(24,24)} self.setupUi(self) self._category_model = CategoryModel() - - self.connect(self.category_view, SIGNAL('activated(QModelIndex)'), lambda i: self.stackedWidget.setCurrentIndex(i.row())) - self.connect(self.category_view, SIGNAL('clicked(QModelIndex)'), lambda i: self.stackedWidget.setCurrentIndex(i.row())) + + self.category_view.currentChanged = \ + lambda n, p: self.stackedWidget.setCurrentIndex(n.row()) self.category_view.setModel(self._category_model) self.db = db self.server = server @@ -151,7 +268,7 @@ class ConfigDialog(QDialog, Ui_Dialog): self.location.setText(path if path else '') self.connect(self.browse_button, SIGNAL('clicked(bool)'), self.browse) self.connect(self.compact_button, SIGNAL('clicked(bool)'), self.compact) - + dirs = config['frequently_used_directories'] rn = config['use_roman_numerals_for_series_number'] self.timeout.setValue(prefs['network_timeout']) @@ -162,20 +279,20 @@ class ConfigDialog(QDialog, Ui_Dialog): self.connect(self.remove_button, SIGNAL('clicked(bool)'), self.remove_dir) if not islinux: self.dirs_box.setVisible(False) - + column_map = config['column_map'] for col in column_map + [i for i in ALL_COLUMNS if i not in column_map]: item = QListWidgetItem(BooksModel.headers[col], self.columns) item.setData(Qt.UserRole, QVariant(col)) item.setFlags(Qt.ItemIsEnabled|Qt.ItemIsUserCheckable|Qt.ItemIsSelectable) item.setCheckState(Qt.Checked if col in column_map else Qt.Unchecked) - + self.connect(self.column_up, SIGNAL('clicked()'), self.up_column) self.connect(self.column_down, SIGNAL('clicked()'), self.down_column) self.filename_pattern = FilenamePattern(self) self.metadata_box.layout().insertWidget(0, self.filename_pattern) - + icons = config['toolbar_icon_size'] self.toolbar_button_size.setCurrentIndex(0 if icons == self.ICON_SIZES[0] else 1 if icons == self.ICON_SIZES[1] else 2) self.show_toolbar_text.setChecked(config['show_text_in_toolbar']) @@ -183,7 +300,7 @@ class ConfigDialog(QDialog, Ui_Dialog): self.book_exts = sorted(BOOK_EXTENSIONS) for ext in self.book_exts: self.single_format.addItem(ext.upper(), QVariant(ext)) - + single_format = config['save_to_disk_single_format'] self.single_format.setCurrentIndex(self.book_exts.index(single_format)) self.cover_browse.setValue(config['cover_flow_queue_length']) @@ -204,9 +321,9 @@ class ConfigDialog(QDialog, Ui_Dialog): items.sort(cmp=lambda x, y: cmp(x[1], y[1])) for item in items: self.language.addItem(item[1], QVariant(item[0])) - + self.pdf_metadata.setChecked(prefs['read_file_metadata']) - + added_html = False for ext in self.book_exts: ext = ext.lower() @@ -242,7 +359,6 @@ class ConfigDialog(QDialog, Ui_Dialog): self.priority.setCurrentIndex(p) self.priority.setVisible(iswindows) self.priority_label.setVisible(iswindows) - self.category_view.setCurrentIndex(self._category_model.index(0)) self._plugin_model = PluginModel() self.plugin_view.setModel(self._plugin_model) self.connect(self.toggle_plugin, SIGNAL('clicked()'), lambda : self.modify_plugin(op='toggle')) @@ -251,7 +367,75 @@ class ConfigDialog(QDialog, Ui_Dialog): self.connect(self.button_plugin_browse, SIGNAL('clicked()'), self.find_plugin) self.connect(self.button_plugin_add, SIGNAL('clicked()'), self.add_plugin) self.separate_cover_flow.setChecked(config['separate_cover_flow']) - + self.setup_email_page() + self.category_view.setCurrentIndex(self.category_view.model().index(0)) + + def setup_email_page(self): + opts = smtp_prefs().parse() + if opts.from_: + self.email_from.setText(opts.from_) + self._email_accounts = EmailAccounts(opts.accounts) + self.email_view.setModel(self._email_accounts) + if opts.relay_host: + self.relay_host.setText(opts.relay_host) + self.relay_port.setValue(opts.relay_port) + if opts.relay_username: + self.relay_username.setText(opts.relay_username) + if opts.relay_password: + self.relay_password.setText(unhexlify(opts.relay_password)) + (self.relay_tls if opts.encryption == 'TLS' else self.relay_ssl).setChecked(True) + self.connect(self.relay_use_gmail, SIGNAL('clicked(bool)'), + self.create_gmail_relay) + self.connect(self.relay_show_password, SIGNAL('stateChanged(int)'), + lambda state:self.relay_password.setEchoMode(self.relay_password.Password)) + self.connect(self.email_add, SIGNAL('clicked(bool)'), + self.add_email_account) + self.connect(self.email_make_default, SIGNAL('clicked(bool)'), + lambda c: self._email_accounts.make_default(self.email_view.currentIndex())) + self.email_view.resizeColumnsToContents() + + def add_email_account(self, checked): + index = self._email_accounts.add() + self.email_view.setCurrentIndex(index) + self.email_view.resizeColumnsToContents() + self.email_view.edit(index) + + def create_gmail_relay(self, *args): + self.relay_username.setText('@gmail.com') + self.relay_password.setText('') + self.relay_host.setText('smtp.gmail.com') + self.relay_port.setValue(587) + self.relay_tls.setChecked(True) + + info_dialog(self, _('Finish gmail setup'), + _('Dont forget to enter your gmail username and password')).exec_() + self.relay_username.setFocus(Qt.OtherFocusReason) + self.relay_username.setCursorPosition(0) + + def set_email_settings(self): + from_ = unicode(self.email_from.text()).strip() + if self._email_accounts.accounts and not from_: + error_dialog(self, _('Bad configuration'), + _('You must set the From email address')).exec_() + return False + username = unicode(self.relay_username.text()).strip() + password = unicode(self.relay_password.text()).strip() + host = unicode(self.relay_host.text()).strip() + if host and not (username and password): + error_dialog(self, _('Bad configuration'), + _('You must set the username and password for ' + 'the mail server.')).exec_() + return False + conf = smtp_prefs() + conf.set('from_', from_) + conf.set('accounts', self._email_accounts.accounts) + conf.set('relay_host', host if host else None) + conf.set('relay_port', self.relay_port.value()) + conf.set('relay_username', username if username else None) + conf.set('relay_password', hexlify(password)) + conf.set('encryption', 'TLS' if self.relay_tls.isChecked() else 'SSL') + return True + def add_plugin(self): path = unicode(self.plugin_path.text()) if path and os.access(path, os.R_OK) and path.lower().endswith('.zip'): @@ -259,22 +443,22 @@ class ConfigDialog(QDialog, Ui_Dialog): self._plugin_model.populate() self._plugin_model.reset() else: - error_dialog(self, _('No valid plugin path'), + error_dialog(self, _('No valid plugin path'), _('%s is not a valid plugin path')%path).exec_() - + def find_plugin(self): path = choose_files(self, 'choose plugin dialog', _('Choose plugin'), - filters=[('Plugins', ['zip'])], all_files=False, + filters=[('Plugins', ['zip'])], all_files=False, select_only_single_file=True) if path: self.plugin_path.setText(path[0]) - + def modify_plugin(self, op=''): index = self.plugin_view.currentIndex() if index.isValid(): plugin = self._plugin_model.index_to_plugin(index) if not plugin.can_be_disabled: - error_dialog(self,_('Plugin cannot be disabled'), + error_dialog(self,_('Plugin cannot be disabled'), _('The plugin: %s cannot be disabled')%plugin.name).exec_() return if op == 'toggle': @@ -286,7 +470,7 @@ class ConfigDialog(QDialog, Ui_Dialog): if op == 'customize': if not plugin.is_customizable(): info_dialog(self, _('Plugin not customizable'), - _('Plugin: %s does not need customization')%plugin.name).exec_() + _('Plugin: %s does not need customization')%plugin.name).exec_() return help = plugin.customization_help() text, ok = QInputDialog.getText(self, _('Customize %s')%plugin.name, @@ -299,22 +483,23 @@ class ConfigDialog(QDialog, Ui_Dialog): self._plugin_model.populate() self._plugin_model.reset() else: - error_dialog(self, _('Cannot remove builtin plugin'), - plugin.name + _(' cannot be removed. It is a builtin plugin. Try disabling it instead.')).exec_() - - + error_dialog(self, _('Cannot remove builtin plugin'), + plugin.name + _(' cannot be removed. It is a ' + 'builtin plugin. Try disabling it instead.')).exec_() + + def up_column(self): idx = self.columns.currentRow() if idx > 0: self.columns.insertItem(idx-1, self.columns.takeItem(idx)) self.columns.setCurrentRow(idx-1) - + def down_column(self): idx = self.columns.currentRow() if idx < self.columns.count()-1: self.columns.insertItem(idx+1, self.columns.takeItem(idx)) self.columns.setCurrentRow(idx+1) - + def view_server_logs(self): from calibre.library.server import log_access_file, log_error_file d = QDialog(self) @@ -336,7 +521,7 @@ class ConfigDialog(QDialog, Ui_Dialog): except IOError: el.setPlainText('No access log found') d.show() - + def set_server_options(self): c = server_config() c.set('port', self.port.value()) @@ -345,7 +530,7 @@ class ConfigDialog(QDialog, Ui_Dialog): if not p: p = None c.set('password', p) - + def start_server(self): self.set_server_options() from calibre.library.server import start_threaded_server @@ -353,13 +538,13 @@ class ConfigDialog(QDialog, Ui_Dialog): while not self.server.is_running and self.server.exception is None: time.sleep(1) if self.server.exception is not None: - error_dialog(self, _('Failed to start content server'), + error_dialog(self, _('Failed to start content server'), unicode(self.server.exception)).exec_() return self.start.setEnabled(False) self.test.setEnabled(True) self.stop.setEnabled(True) - + def stop_server(self): from calibre.library.server import stop_threaded_server stop_threaded_server(self.server) @@ -367,16 +552,17 @@ class ConfigDialog(QDialog, Ui_Dialog): self.start.setEnabled(True) self.test.setEnabled(False) self.stop.setEnabled(False) - + def test_server(self): QDesktopServices.openUrl(QUrl('http://127.0.0.1:'+str(self.port.value()))) - + def compact(self, toggled): d = Vacuum(self, self.db) d.exec_() def browse(self): - dir = choose_dir(self, 'database location dialog', 'Select database location') + dir = choose_dir(self, 'database location dialog', + _('Select database location')) if dir: self.location.setText(dir) @@ -393,7 +579,10 @@ class ConfigDialog(QDialog, Ui_Dialog): def accept(self): mcs = unicode(self.max_cover_size.text()).strip() if not re.match(r'\d+x\d+', mcs): - error_dialog(self, _('Invalid size'), _('The size %s is invalid. must be of the form widthxheight')%mcs).exec_() + error_dialog(self, _('Invalid size'), + _('The size %s is invalid. must be of the form widthxheight')%mcs).exec_() + return + if not self.set_email_settings(): return config['use_roman_numerals_for_series_number'] = bool(self.roman_numerals.isChecked()) config['new_version_notification'] = bool(self.new_version_notification.isChecked()) @@ -429,18 +618,21 @@ class ConfigDialog(QDialog, Ui_Dialog): if self.viewer.item(i).checkState() == Qt.Checked: fmts.append(str(self.viewer.item(i).text())) config['internally_viewed_formats'] = fmts - + if not path or not os.path.exists(path) or not os.path.isdir(path): d = error_dialog(self, _('Invalid database location'), - _('Invalid database location ')+path+_('<br>Must be a directory.')) + _('Invalid database location ')+path+ + _('<br>Must be a directory.')) d.exec_() elif not os.access(path, os.W_OK): d = error_dialog(self, _('Invalid database location'), - _('Invalid database location.<br>Cannot write to ')+path) + _('Invalid database location.<br>Cannot write to ')+path) d.exec_() else: self.database_location = os.path.abspath(path) - self.directories = [qstring_to_unicode(self.directory_list.item(i).text()) for i in range(self.directory_list.count())] + self.directories = [ + qstring_to_unicode(self.directory_list.item(i).text()) for i in \ + range(self.directory_list.count())] config['frequently_used_directories'] = self.directories QDialog.accept(self) @@ -448,7 +640,8 @@ class Vacuum(QMessageBox): def __init__(self, parent, db): self.db = db - QMessageBox.__init__(self, QMessageBox.Information, _('Compacting...'), _('Compacting database. This may take a while.'), + QMessageBox.__init__(self, QMessageBox.Information, _('Compacting...'), + _('Compacting database. This may take a while.'), QMessageBox.NoButton, parent) QTimer.singleShot(200, self.vacuum) @@ -456,3 +649,11 @@ class Vacuum(QMessageBox): self.db.vacuum() self.accept() +if __name__ == '__main__': + from calibre.library.database2 import LibraryDatabase2 + from PyQt4.Qt import QApplication + app = QApplication([]) + d=ConfigDialog(None, LibraryDatabase2('/tmp')) + d.category_view.setCurrentIndex(d.category_view.model().index(2)) + d.show() + app.exec_() diff --git a/src/calibre/gui2/dialogs/config.ui b/src/calibre/gui2/dialogs/config.ui index 9f734f9a68..9aa3fd6971 100644 --- a/src/calibre/gui2/dialogs/config.ui +++ b/src/calibre/gui2/dialogs/config.ui @@ -6,7 +6,7 @@ <rect> <x>0</x> <y>0</y> - <width>755</width> + <width>789</width> <height>557</height> </rect> </property> @@ -437,12 +437,6 @@ </widget> </item> </layout> - <zorder>toolbar_button_size</zorder> - <zorder>label_4</zorder> - <zorder>show_toolbar_text</zorder> - <zorder>columns</zorder> - <zorder></zorder> - <zorder>groupBox_3</zorder> </widget> </item> <item> @@ -507,7 +501,6 @@ </layout> </item> </layout> - <zorder>columns</zorder> </widget> </item> <item> @@ -534,16 +527,287 @@ </layout> <zorder>roman_numerals</zorder> <zorder>groupBox_2</zorder> - <zorder>groupBox</zorder> <zorder>systray_icon</zorder> <zorder>sync_news</zorder> <zorder>delete_news</zorder> <zorder>separate_cover_flow</zorder> <zorder>systray_notifications</zorder> - <zorder>groupBox_3</zorder> <zorder></zorder> <zorder></zorder> </widget> + <widget class="QWidget" name="page_6" > + <layout class="QVBoxLayout" name="verticalLayout_9" > + <item> + <widget class="QLabel" name="label_22" > + <property name="text" > + <string>calibre can send your books to you (or your reader) by email</string> + </property> + <property name="wordWrap" > + <bool>true</bool> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_9" > + <item> + <widget class="QLabel" name="label_15" > + <property name="text" > + <string>Send email &from:</string> + </property> + <property name="buddy" > + <cstring>email_from</cstring> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="email_from" > + <property name="toolTip" > + <string><p>This is what will be present in the From: field of emails sent by calibre.<br> Set it to your email address</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_8" > + <item> + <widget class="QTableView" name="email_view" > + <property name="selectionMode" > + <enum>QAbstractItemView::SingleSelection</enum> + </property> + <property name="selectionBehavior" > + <enum>QAbstractItemView::SelectRows</enum> + </property> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_8" > + <item> + <widget class="QToolButton" name="email_add" > + <property name="toolTip" > + <string>Add an email address to which to send books</string> + </property> + <property name="text" > + <string>&Add email</string> + </property> + <property name="icon" > + <iconset resource="../images.qrc" > + <normaloff>:/images/plus.svg</normaloff>:/images/plus.svg</iconset> + </property> + <property name="iconSize" > + <size> + <width>24</width> + <height>24</height> + </size> + </property> + <property name="toolButtonStyle" > + <enum>Qt::ToolButtonTextUnderIcon</enum> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="email_make_default" > + <property name="text" > + <string>Make &default</string> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="email_remove" > + <property name="text" > + <string>&Remove email</string> + </property> + <property name="icon" > + <iconset resource="../images.qrc" > + <normaloff>:/images/minus.svg</normaloff>:/images/minus.svg</iconset> + </property> + <property name="iconSize" > + <size> + <width>24</width> + <height>24</height> + </size> + </property> + <property name="toolButtonStyle" > + <enum>Qt::ToolButtonTextUnderIcon</enum> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_10" > + <item> + <widget class="QGroupBox" name="groupBox_5" > + <property name="toolTip" > + <string><p>A mail server is useful if the service you are sending mail to only accepts email from well know mail services.</string> + </property> + <property name="title" > + <string>Mail &Server</string> + </property> + <layout class="QGridLayout" name="gridLayout_3" > + <item row="0" column="0" colspan="4" > + <widget class="QLabel" name="label_16" > + <property name="text" > + <string>calibre can <b>optionally</b> use a server to send mail</string> + </property> + <property name="wordWrap" > + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="0" > + <widget class="QLabel" name="label_17" > + <property name="text" > + <string>&Hostname:</string> + </property> + <property name="buddy" > + <cstring>relay_host</cstring> + </property> + </widget> + </item> + <item row="1" column="1" colspan="2" > + <widget class="QLineEdit" name="relay_host" > + <property name="toolTip" > + <string>The hostname if your mail server. For e.g. smtp.gmail.com</string> + </property> + </widget> + </item> + <item row="1" column="3" > + <layout class="QHBoxLayout" name="horizontalLayout_11" > + <item> + <widget class="QLabel" name="label_18" > + <property name="text" > + <string>&Port:</string> + </property> + <property name="buddy" > + <cstring>relay_port</cstring> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="relay_port" > + <property name="toolTip" > + <string>The port your mail server listens for connections on. The default is 25</string> + </property> + <property name="minimum" > + <number>1</number> + </property> + <property name="maximum" > + <number>65555</number> + </property> + <property name="value" > + <number>25</number> + </property> + </widget> + </item> + </layout> + </item> + <item row="2" column="0" > + <widget class="QLabel" name="label_19" > + <property name="text" > + <string>&Username:</string> + </property> + <property name="buddy" > + <cstring>relay_username</cstring> + </property> + </widget> + </item> + <item row="2" column="1" colspan="2" > + <widget class="QLineEdit" name="relay_username" > + <property name="toolTip" > + <string>Your username on the mail server</string> + </property> + </widget> + </item> + <item row="3" column="0" > + <widget class="QLabel" name="label_20" > + <property name="text" > + <string>&Password:</string> + </property> + <property name="buddy" > + <cstring>relay_password</cstring> + </property> + </widget> + </item> + <item row="3" column="1" colspan="2" > + <widget class="QLineEdit" name="relay_password" > + <property name="toolTip" > + <string>Your password on the mail server</string> + </property> + <property name="echoMode" > + <enum>QLineEdit::Password</enum> + </property> + </widget> + </item> + <item row="3" column="3" > + <widget class="QCheckBox" name="relay_show_password" > + <property name="text" > + <string>&Show</string> + </property> + </widget> + </item> + <item row="4" column="0" > + <widget class="QLabel" name="label_21" > + <property name="text" > + <string>&Encryption:</string> + </property> + <property name="buddy" > + <cstring>relay_tls</cstring> + </property> + </widget> + </item> + <item row="4" column="1" > + <widget class="QRadioButton" name="relay_tls" > + <property name="toolTip" > + <string>Use TLS encryption when connecting to the mail server. This is the most common.</string> + </property> + <property name="text" > + <string>&TLS</string> + </property> + <property name="checked" > + <bool>true</bool> + </property> + </widget> + </item> + <item row="4" column="2" colspan="2" > + <widget class="QRadioButton" name="relay_ssl" > + <property name="toolTip" > + <string>Use SSL encryption when connecting to the mail server.</string> + </property> + <property name="text" > + <string>&SSL</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QToolButton" name="relay_use_gmail" > + <property name="text" > + <string>Use Gmail</string> + </property> + <property name="icon" > + <iconset resource="../images.qrc" > + <normaloff>:/images/gmail_logo.png</normaloff>:/images/gmail_logo.png</iconset> + </property> + <property name="iconSize" > + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="toolButtonStyle" > + <enum>Qt::ToolButtonTextUnderIcon</enum> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> <widget class="QWidget" name="page_2" > <layout class="QVBoxLayout" > <item> diff --git a/src/calibre/gui2/dialogs/fetch_metadata.py b/src/calibre/gui2/dialogs/fetch_metadata.py index 8531452043..0dc5474b78 100644 --- a/src/calibre/gui2/dialogs/fetch_metadata.py +++ b/src/calibre/gui2/dialogs/fetch_metadata.py @@ -104,14 +104,15 @@ class FetchMetadata(QDialog, Ui_FetchMetadata): self.author = author.strip() self.publisher = publisher self.previous_row = None + self.warning.setVisible(False) self.connect(self.matches, SIGNAL('activated(QModelIndex)'), self.chosen) self.connect(self.matches, SIGNAL('entered(QModelIndex)'), - lambda index:self.matches.setCurrentIndex(index)) + self.show_summary) self.matches.setMouseTracking(True) self.fetch_metadata() - def show_summary(self, current, previous): + def show_summary(self, current, *args): row = current.row() if row != self.previous_row: summ = self.model.summary(row) @@ -119,6 +120,7 @@ class FetchMetadata(QDialog, Ui_FetchMetadata): self.previous_row = row def fetch_metadata(self): + self.warning.setVisible(False) key = str(self.key.text()) if key: prefs['isbndb_com_key'] = key @@ -158,14 +160,14 @@ class FetchMetadata(QDialog, Ui_FetchMetadata): self.fetcher.exceptions if x[1] is not None] if warnings: warnings='<br>'.join(['<b>%s</b>: %s'%(name, exc) for name,exc in warnings]) - warning_dialog(self, _('Warning'), - '<p>'+_('Could not fetch metadata from:')+\ - '<br><br>'+warnings+'</p>').exec_() + self.warning.setText('<p><b>'+ _('Warning')+':</b>'+\ + _('Could not fetch metadata from:')+\ + '<br>'+warnings+'</p>') + self.warning.setVisible(True) if self.model.rowCount() < 1: info_dialog(self, _('No metadata found'), _('No metadata found, try adjusting the title and author ' 'or the ISBN key.')).exec_() - self.reject() return self.matches.setModel(self.model) diff --git a/src/calibre/gui2/dialogs/fetch_metadata.ui b/src/calibre/gui2/dialogs/fetch_metadata.ui index 9adc1fa5e5..653c7005b8 100644 --- a/src/calibre/gui2/dialogs/fetch_metadata.ui +++ b/src/calibre/gui2/dialogs/fetch_metadata.ui @@ -23,7 +23,7 @@ <item> <widget class="QLabel" name="tlabel" > <property name="text" > - <string><p>calibre can find metadata for your books from two locations: <b>Google Books</b> and <b>isbndb.com</b>. <p>To use isbndb.com you must sign up for a <a href="http://www.isbndb.com">free account</a> and exter you access key below.</string> + <string><p>calibre can find metadata for your books from two locations: <b>Google Books</b> and <b>isbndb.com</b>. <p>To use isbndb.com you must sign up for a <a href="http://www.isbndb.com">free account</a> and enter your access key below.</string> </property> <property name="alignment" > <set>Qt::AlignCenter</set> @@ -60,6 +60,16 @@ </item> </layout> </item> + <item> + <widget class="QLabel" name="warning" > + <property name="text" > + <string/> + </property> + <property name="wordWrap" > + <bool>true</bool> + </property> + </widget> + </item> <item> <widget class="QGroupBox" name="groupBox" > <property name="title" > diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py index c1f45128b2..7c5523161e 100644 --- a/src/calibre/gui2/dialogs/metadata_single.py +++ b/src/calibre/gui2/dialogs/metadata_single.py @@ -428,7 +428,9 @@ class MetadataSingleDialog(ResizableDialog, Ui_MetadataSingleDialog): prefix += '\n' self.comments.setText(prefix + summ) else: - error_dialog(self, 'Cannot fetch metadata', 'You must specify at least one of ISBN, Title, Authors or Publisher') + error_dialog(self, _('Cannot fetch metadata'), + _('You must specify at least one of ISBN, Title, ' + 'Authors or Publisher')) def enable_series_index(self, *args): self.series_index.setEnabled(True) diff --git a/src/calibre/gui2/images/gmail_logo.png b/src/calibre/gui2/images/gmail_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e145f6e0f19b6db0123ece78e719db634be99ec8 GIT binary patch literal 24220 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 D7j?RN literal 0 HcmV?d00001 diff --git a/src/calibre/gui2/images/mail.svg b/src/calibre/gui2/images/mail.svg new file mode 100644 index 0000000000..cd3dfa114d --- /dev/null +++ b/src/calibre/gui2/images/mail.svg @@ -0,0 +1,270 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg + xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://web.resource.org/cc/" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="128" + height="128" + id="svg3007" + sodipodi:version="0.32" + inkscape:version="0.45.1" + version="1.0" + sodipodi:docbase="/home/david/Documents/Projects/KDE/Oxygen/kdelibs/scalable/actions" + sodipodi:docname="mail.svgz" + inkscape:output_extension="org.inkscape.output.svgz.inkscape" + inkscape:export-filename="/home/david/Documents/Projects/KDE/Oxygen/kdelibs/scalable/actions/mail.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90"> + <defs + id="defs3009"> + <linearGradient + id="polygon3293_1_" + gradientUnits="userSpaceOnUse" + x1="615.5" + y1="-584.6021" + x2="615.5" + y2="-595.8521" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)"> + <stop + offset="0" + style="stop-color:#6193CF" + id="stop2997" /> + <stop + offset="1" + style="stop-color:#EEEEEE" + id="stop2999" /> + </linearGradient> + <linearGradient + id="polygon3286_1_" + gradientUnits="userSpaceOnUse" + x1="615.5" + y1="-589.8511" + x2="615.5" + y2="-580.6011" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)"> + <stop + offset="0" + style="stop-color:#6193CF" + id="stop2991" /> + <stop + offset="1" + style="stop-color:#D1DFF1" + id="stop2993" /> + </linearGradient> + <linearGradient + id="rect3244_1_" + gradientUnits="userSpaceOnUse" + x1="59.9995" + y1="4" + x2="59.9995" + y2="72.0005" + gradientTransform="matrix(1,0,0,1.0588235,0,-0.2352941)"> + <stop + offset="0" + style="stop-color:#A4C0E4" + id="stop2983" /> + <stop + offset="0.25" + style="stop-color:#D1DFF1" + id="stop2985" /> + <stop + offset="0.85" + style="stop-color:#FFFFFF" + id="stop2987" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#rect3244_1_" + id="linearGradient2212" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1,0,0,1.0588235,0,-0.2352941)" + x1="59.9995" + y1="4" + x2="59.9995" + y2="72.0005" /> + <linearGradient + inkscape:collect="always" + xlink:href="#polygon3286_1_" + id="linearGradient2214" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)" + x1="615.5" + y1="-589.8511" + x2="615.5" + y2="-580.6011" /> + <linearGradient + inkscape:collect="always" + xlink:href="#polygon3293_1_" + id="linearGradient2216" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)" + x1="615.5" + y1="-584.6021" + x2="615.5" + y2="-595.8521" /> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + gridtolerance="10000" + guidetolerance="10" + objecttolerance="10" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1" + inkscape:cx="64" + inkscape:cy="64" + inkscape:document-units="px" + inkscape:current-layer="g2972" + width="128px" + height="128px" + inkscape:showpageshadow="false" + inkscape:window-width="794" + inkscape:window-height="731" + inkscape:window-x="0" + inkscape:window-y="0" + showgrid="true" + gridspacingx="4px" + gridspacingy="4px" + gridempspacing="2" + showborder="false" /> + <metadata + id="metadata3012"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <switch + id="switch2966" + transform="translate(4,12)"> + <foreignObject + requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/" + x="0" + y="0" + width="1" + height="1" + id="foreignObject2968"> + <i:pgfRef + xlink:href="#adobe_illustrator_pgf" /> + </foreignObject> + <g + i:extraneous="self" + id="g2970"> + <g + id="g2972"> + <linearGradient + id="linearGradient3033" + gradientUnits="userSpaceOnUse" + x1="59.9995" + y1="4" + x2="59.9995" + y2="72.000504"> + <stop + offset="0" + style="stop-color:#A4C0E4" + id="stop3035" /> + <stop + offset="0.25" + style="stop-color:#D1DFF1" + id="stop3037" /> + <stop + offset="0.85" + style="stop-color:#FFFFFF" + id="stop3039" /> + </linearGradient> + <linearGradient + id="linearGradient3042" + gradientUnits="userSpaceOnUse" + x1="615.5" + y1="-589.85107" + x2="615.5" + y2="-580.60107" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)"> + <stop + offset="0" + style="stop-color:#6193CF" + id="stop3044" /> + <stop + offset="1" + style="stop-color:#D1DFF1" + id="stop3046" /> + </linearGradient> + <linearGradient + id="linearGradient3049" + gradientUnits="userSpaceOnUse" + x1="615.5" + y1="-584.60211" + x2="615.5" + y2="-595.85211" + gradientTransform="matrix(4,0,0,-4,-2402,-2314.406)"> + <stop + offset="0" + style="stop-color:#6193CF" + id="stop3051" /> + <stop + offset="1" + style="stop-color:#EEEEEE" + id="stop3053" /> + </linearGradient> + <g + id="g2202" + transform="translate(0,8)"> + <path + style="opacity:0.1" + id="path2974" + d="M 4,0 C 1.794,0 0,1.8884211 0,4.2105263 L 0,75.789474 C 0,78.111579 1.794,80 4,80 L 116,80 C 118.206,80 120,78.111579 120,75.789474 L 120,4.2105263 C 120,1.8884211 118.206,0 116,0 L 4,0 z " /> + <path + style="opacity:0.15" + id="path2976" + d="M 4,1 C 2.346,1 1,2.4187568 1,4.1621622 L 1,75.837838 C 1,77.581243 2.346,79 4,79 L 116,79 C 117.654,79 119,77.581243 119,75.837838 L 119,4.1621622 C 119,2.4187568 117.654,1 116,1 L 4,1 z " /> + <path + style="opacity:0.2" + id="path2978" + d="M 4,2 C 2.897,2 2,2.9468333 2,4.1111111 L 2,75.888889 C 2,77.053167 2.897,78 4,78 L 116,78 C 117.103,78 118,77.053167 118,75.888889 L 118,4.1111111 C 118,2.9468333 117.103,2 116,2 L 4,2 z " /> + <path + style="opacity:0.25" + id="path2980" + d="M 4,3 C 3.448,3 3,3.4736 3,4.0571428 L 3,75.942857 C 3,76.527457 3.448,77 4,77 L 116,77 C 116.553,77 117,76.527457 117,75.942857 L 117,4.0571428 C 117,3.4736 116.553,3 116,3 L 4,3 z " /> + <rect + style="fill:url(#linearGradient2212)" + height="72" + width="112" + y="4" + x="4" + id="rect3244_9_" /> + <polygon + style="fill:url(#linearGradient2214)" + points="4,8 4,12 60,45 116,12 116,8 60,41 4,8 " + id="polygon3286_9_" /> + <polygon + style="fill:url(#linearGradient2216)" + points="116,69 116,65 59.997,24 4,65 4,69 59.997,28 116,69 " + id="polygon3293_9_" /> + <polygon + style="fill:#ffffff" + id="polygon3002" + points="4,8 60.004,40.967 116,8 116,4 4,4 4,8 " /> + </g> + </g> + </g> + </switch> + </g> +</svg> diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index f12f560af5..847ce42b5b 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -86,15 +86,15 @@ class LibraryDelegate(QItemDelegate): return sb class DateDelegate(QStyledItemDelegate): - + def displayText(self, val, locale): d = val.toDate() return d.toString('dd MMM yyyy') - + def createEditor(self, parent, option, index): qde = QStyledItemDelegate.createEditor(self, parent, option, index) qde.setDisplayFormat('MM/dd/yyyy') - qde.setMinimumDate(QDate(-4000,1,1)) + qde.setMinimumDate(QDate(101,1,1)) qde.setCalendarPopup(True) return qde @@ -103,7 +103,7 @@ class BooksModel(QAbstractTableModel): [1000,900,500,400,100,90,50,40,10,9,5,4,1], ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"] ) - + headers = { 'title' : _("Title"), 'authors' : _("Author(s)"), @@ -114,7 +114,7 @@ class BooksModel(QAbstractTableModel): 'tags' : _("Tags"), 'series' : _("Series"), } - + @classmethod def roman(cls, num): if num <= 0 or num >= 4000 or int(num) != num: @@ -130,7 +130,7 @@ class BooksModel(QAbstractTableModel): QAbstractTableModel.__init__(self, parent) self.db = None self.column_map = config['column_map'] - self.editable_cols = ['title', 'authors', 'rating', 'publisher', + self.editable_cols = ['title', 'authors', 'rating', 'publisher', 'tags', 'series', 'timestamp'] self.default_image = QImage(':/images/book.svg') self.sorted_on = ('timestamp', Qt.AscendingOrder) @@ -157,10 +157,10 @@ class BooksModel(QAbstractTableModel): tidx = self.column_map.index('timestamp') except ValueError: tidx = -1 - + self.emit(SIGNAL('columns_sorted(int,int)'), idx, tidx) - - + + def set_database(self, db): self.db = db self.build_data_convertors() @@ -169,7 +169,7 @@ class BooksModel(QAbstractTableModel): rows = self.db.refresh_ids(ids) if rows: self.refresh_rows(rows, current_row=current_row) - + def refresh_rows(self, rows, current_row=-1): for row in rows: if self.cover_cache: @@ -191,12 +191,12 @@ class BooksModel(QAbstractTableModel): add_duplicates=add_duplicates) self.count_changed() return ret - + def add_news(self, path, recipe): ret = self.db.add_news(path, recipe) self.count_changed() return ret - + def count_changed(self, *args): self.emit(SIGNAL('count_changed(int)'), self.db.count()) @@ -208,12 +208,12 @@ class BooksModel(QAbstractTableModel): callback=None): rows = [row.row() for row in rows] if single_format is None: - return self.db.export_to_dir(path, rows, - self.sorted_on[0] == 'authors', - single_dir=single_dir, + return self.db.export_to_dir(path, rows, + self.sorted_on[0] == 'authors', + single_dir=single_dir, callback=callback) else: - return self.db.export_single_format_to_dir(path, rows, + return self.db.export_single_format_to_dir(path, rows, single_format, callback=callback) @@ -225,8 +225,8 @@ class BooksModel(QAbstractTableModel): self.count_changed() self.clear_caches() self.reset() - - + + def delete_books_by_id(self, ids): for id in ids: try: @@ -263,18 +263,18 @@ class BooksModel(QAbstractTableModel): self.clear_caches() self.reset() self.sorted_on = (self.column_map[col], order) - - + + def refresh(self, reset=True): try: col = self.column_map.index(self.sorted_on[0]) except: col = 0 - self.db.refresh(field=self.column_map[col], + self.db.refresh(field=self.column_map[col], ascending=self.sorted_on[1]==Qt.AscendingOrder) if reset: self.reset() - + def resort(self, reset=True): try: col = self.column_map.index(self.sorted_on[0]) @@ -412,14 +412,14 @@ class BooksModel(QAbstractTableModel): if format is None: ans.append(format) else: - f = self.db.format(id, format, index_is_id=True, as_file=True, + f = self.db.format(id, format, index_is_id=True, as_file=True, mode=mode) ans.append(f) return ans - - - - def get_preferred_formats(self, rows, formats, paths=False, + + + + def get_preferred_formats(self, rows, formats, paths=False, set_metadata=False, specific_format=None): ans = [] if specific_format is not None: @@ -430,7 +430,7 @@ class BooksModel(QAbstractTableModel): if not fmts: fmts = '' db_formats = set(fmts.lower().split(',')) - available_formats = set([f.lower() for f in formats]) + available_formats = set([f.lower() for f in formats]) u = available_formats.intersection(db_formats) for f in formats: if f.lower() in u: @@ -469,7 +469,7 @@ class BooksModel(QAbstractTableModel): data = self.db.cover(row_number) except IndexError: # Happens if database has not yet been refreshed pass - + if not data: return self.default_image img = QImage() @@ -479,7 +479,7 @@ class BooksModel(QAbstractTableModel): return img def build_data_convertors(self): - + tidx = FIELD_MAP['title'] aidx = FIELD_MAP['authors'] sidx = FIELD_MAP['size'] @@ -489,44 +489,44 @@ class BooksModel(QAbstractTableModel): srdx = FIELD_MAP['series'] tgdx = FIELD_MAP['tags'] siix = FIELD_MAP['series_index'] - + def authors(r): au = self.db.data[r][aidx] if au: au = [a.strip().replace('|', ',') for a in au.split(',')] return ' & '.join(au) - + def timestamp(r): dt = self.db.data[r][tmdx] if dt: dt = dt - timedelta(seconds=time.timezone) + timedelta(hours=time.daylight) return QDate(dt.year, dt.month, dt.day) - + def rating(r): r = self.db.data[r][ridx] r = r/2 if r else 0 return r - + def publisher(r): pub = self.db.data[r][pidx] if pub: return pub - + def tags(r): tags = self.db.data[r][tgdx] if tags: return ', '.join(tags.split(',')) - + def series(r): series = self.db.data[r][srdx] if series: return series + ' [%d]'%self.db.data[r][siix] - + def size(r): size = self.db.data[r][sidx] if size: return '%.1f'%(float(size)/(1024*1024)) - + self.dc = { 'title' : lambda r : self.db.data[r][tidx], 'authors' : authors, @@ -535,7 +535,7 @@ class BooksModel(QAbstractTableModel): 'rating' : rating, 'publisher': publisher, 'tags' : tags, - 'series' : series, + 'series' : series, } def data(self, index, role): @@ -573,7 +573,7 @@ class BooksModel(QAbstractTableModel): val = int(value.toInt()[0]) if column == 'rating' else \ value.toDate() if column == 'timestamp' else \ unicode(value.toString()) - id = self.db.id(row) + id = self.db.id(row) if column == 'rating': val = 0 if val < 0 else 5 if val > 5 else val val *= 2 @@ -598,7 +598,7 @@ class BooksModel(QAbstractTableModel): index, index) if column == self.sorted_on[0]: self.resort() - + return True class BooksView(TableView): @@ -632,7 +632,7 @@ class BooksView(TableView): QObject.connect(self.selectionModel(), SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'), self._model.current_changed) self.connect(self._model, SIGNAL('columns_sorted(int, int)'), self.columns_sorted, Qt.QueuedConnection) - + def columns_sorted(self, rating_col, timestamp_col): for i in range(self.model().columnCount(None)): if self.itemDelegateForColumn(i) == self.rating_delegate: @@ -641,8 +641,8 @@ class BooksView(TableView): self.setItemDelegateForColumn(rating_col, self.rating_delegate) if timestamp_col > -1: self.setItemDelegateForColumn(timestamp_col, self.timestamp_delegate) - - def set_context_menu(self, edit_metadata, send_to_device, convert, view, + + def set_context_menu(self, edit_metadata, send_to_device, convert, view, save, open_folder, book_details, similar_menu=None): self.setContextMenuPolicy(Qt.DefaultContextMenu) self.context_menu = QMenu(self) @@ -660,18 +660,18 @@ class BooksView(TableView): self.context_menu.addAction(book_details) if similar_menu is not None: self.context_menu.addMenu(similar_menu) - + def contextMenuEvent(self, event): self.context_menu.popup(event.globalPos()) event.accept() - + def sortByColumn(self, colname, order): try: idx = self._model.column_map.index(colname) except ValueError: idx = 0 TableView.sortByColumn(self, idx, order) - + @classmethod def paths_from_event(cls, event): ''' @@ -734,23 +734,23 @@ class DeviceBooksView(BooksView): def connect_dirtied_signal(self, slot): QObject.connect(self._model, SIGNAL('booklist_dirtied()'), slot) - + def sortByColumn(self, col, order): TableView.sortByColumn(self, col, order) - + def dropEvent(self, *args): - error_dialog(self, _('Not allowed'), + error_dialog(self, _('Not allowed'), _('Dropping onto a device is not supported. First add the book to the calibre library.')).exec_() class OnDeviceSearch(SearchQueryParser): - + def __init__(self, model): SearchQueryParser.__init__(self) self.model = model - + def universal_set(self): return set(range(0, len(self.model.db))) - + def get_matches(self, location, query): location = location.lower().strip() query = query.lower().strip() @@ -771,7 +771,7 @@ class OnDeviceSearch(SearchQueryParser): matches.add(i) break return matches - + class DeviceBooksModel(BooksModel): @@ -835,10 +835,10 @@ class DeviceBooksModel(BooksModel): if reset: self.reset() self.last_search = text - + def resort(self, reset): self.sort(self.sorted_on[0], self.sorted_on[1], reset=reset) - + def sort(self, col, order, reset=True): descending = order != Qt.AscendingOrder def strcmp(attr): @@ -957,7 +957,7 @@ class DeviceBooksModel(BooksModel): return QVariant('Marked for deletion') col = index.column() if col in [0, 1] or (col == 4 and self.db.supports_tags()): - return QVariant("Double click to <b>edit</b> me<br><br>") + return QVariant(_("Double click to <b>edit</b> me<br><br>")) return NONE def headerData(self, section, orientation, role): @@ -1060,11 +1060,11 @@ class SearchBox(QLineEdit): if not all: ans = '[' + ans + ']' self.set_search_string(ans) - + def search_from_tags(self, tags, all): joiner = ' and ' if all else ' or ' self.set_search_string(joiner.join(tags)) - + def set_search_string(self, txt): self.normalize_state() self.setText(txt) diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index efd4e528ba..0a4be750a2 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -1,6 +1,7 @@ from __future__ import with_statement __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' +'''The main GUI''' import os, sys, textwrap, collections, traceback, time from xml.parsers.expat import ExpatError from functools import partial @@ -12,10 +13,8 @@ from PyQt4.Qt import Qt, SIGNAL, QObject, QCoreApplication, QUrl, QTimer, \ from PyQt4.QtSvg import QSvgRenderer from calibre import __version__, __appname__, islinux, sanitize_file_name, \ - iswindows, isosx, preferred_encoding + iswindows, isosx from calibre.ptempfile import PersistentTemporaryFile -from calibre.devices.errors import FreeSpaceError -from calibre.devices.interface import Device from calibre.utils.config import prefs, dynamic from calibre.gui2 import APP_UID, warning_dialog, choose_files, error_dialog, \ initialize_file_icon_provider, question_dialog,\ @@ -30,7 +29,7 @@ from calibre.gui2.update import CheckForUpdates from calibre.gui2.dialogs.progress import ProgressDialog from calibre.gui2.main_window import MainWindow, option_parser as _option_parser from calibre.gui2.main_ui import Ui_MainWindow -from calibre.gui2.device import DeviceManager +from calibre.gui2.device import DeviceManager, DeviceMenu, DeviceGUI, Emailer from calibre.gui2.status import StatusBar from calibre.gui2.jobs2 import JobManager from calibre.gui2.dialogs.metadata_single import MetadataSingleDialog @@ -43,14 +42,13 @@ from calibre.gui2.dialogs.config import ConfigDialog from calibre.gui2.dialogs.search import SearchDialog from calibre.gui2.dialogs.choose_format import ChooseFormatDialog from calibre.gui2.dialogs.book_info import BookInfo -from calibre.ebooks.metadata.meta import set_metadata from calibre.ebooks import BOOK_EXTENSIONS from calibre.library.database2 import LibraryDatabase2, CoverCache from calibre.parallel import JobKilled -from calibre.utils.filenames import ascii_filename from calibre.gui2.dialogs.confirm_delete import confirm -class Main(MainWindow, Ui_MainWindow): +class Main(MainWindow, Ui_MainWindow, DeviceGUI): + 'The main GUI' def set_default_thumbnail(self, height): r = QSvgRenderer(':/images/book.svg') @@ -59,7 +57,8 @@ class Main(MainWindow, Ui_MainWindow): p = QPainter(pixmap) r.render(p) p.end() - self.default_thumbnail = (pixmap.width(), pixmap.height(), pixmap_to_data(pixmap)) + self.default_thumbnail = (pixmap.width(), pixmap.height(), + pixmap_to_data(pixmap)) def __init__(self, single_instance, opts, actions, parent=None): self.preferences_action, self.quit_action = actions @@ -69,7 +68,8 @@ class Main(MainWindow, Ui_MainWindow): self.fc = __import__('calibre.utils.fontconfig', fromlist=1) self.single_instance = single_instance if self.single_instance is not None: - self.connect(self.single_instance, SIGNAL('message_received(PyQt_PyObject)'), + self.connect(self.single_instance, + SIGNAL('message_received(PyQt_PyObject)'), self.another_instance_wants_to_talk) Ui_MainWindow.__init__(self) @@ -79,6 +79,8 @@ class Main(MainWindow, Ui_MainWindow): self.get_metadata = GetMetadata() self.read_settings() self.job_manager = JobManager() + self.emailer = Emailer() + self.emailer.start() self.jobs_dialog = JobsDialog(self, self.job_manager) self.upload_memory = {} self.delete_memory = {} @@ -86,7 +88,8 @@ class Main(MainWindow, Ui_MainWindow): self.persistent_files = [] self.metadata_dialogs = [] self.default_thumbnail = None - self.device_error_dialog = ConversionErrorDialog(self, _('Error communicating with device'), ' ') + self.device_error_dialog = ConversionErrorDialog(self, + _('Error communicating with device'), ' ') self.device_error_dialog.setModal(Qt.NonModal) self.tb_wrapper = textwrap.TextWrapper(width=40) self.device_connected = False @@ -99,8 +102,10 @@ class Main(MainWindow, Ui_MainWindow): else: self.system_tray_icon.show() self.system_tray_menu = QMenu() - self.restore_action = self.system_tray_menu.addAction(QIcon(':/images/page.svg'), _('&Restore')) - self.donate_action = self.system_tray_menu.addAction(QIcon(':/images/donate.svg'), _('&Donate to support calibre')) + self.restore_action = self.system_tray_menu.addAction( + QIcon(':/images/page.svg'), _('&Restore')) + self.donate_action = self.system_tray_menu.addAction( + QIcon(':/images/donate.svg'), _('&Donate to support calibre')) self.donate_button.setDefaultAction(self.donate_action) self.addAction(self.quit_action) self.action_restart = QAction(_('&Restart'), self) @@ -113,62 +118,52 @@ class Main(MainWindow, Ui_MainWindow): self.system_tray_icon.setContextMenu(self.system_tray_menu) self.connect(self.quit_action, SIGNAL('triggered(bool)'), self.quit) self.connect(self.donate_action, SIGNAL('triggered(bool)'), self.donate) - self.connect(self.restore_action, SIGNAL('triggered(bool)'), lambda c : self.show()) - self.connect(self.action_show_book_details, SIGNAL('triggered(bool)'), self.show_book_info) - self.connect(self.action_restart, SIGNAL('triggered(bool)'), + self.connect(self.restore_action, SIGNAL('triggered(bool)'), + lambda c : self.show()) + self.connect(self.action_show_book_details, + SIGNAL('triggered(bool)'), self.show_book_info) + self.connect(self.action_restart, SIGNAL('triggered(bool)'), lambda c : self.quit(None, restart=True)) - self.connect(self.system_tray_icon, SIGNAL('activated(QSystemTrayIcon::ActivationReason)'), - self.system_tray_icon_activated) + self.connect(self.system_tray_icon, + SIGNAL('activated(QSystemTrayIcon::ActivationReason)'), + self.system_tray_icon_activated) self.tool_bar.contextMenuEvent = self.no_op ####################### Location View ######################## - QObject.connect(self.location_view, SIGNAL('location_selected(PyQt_PyObject)'), + QObject.connect(self.location_view, + SIGNAL('location_selected(PyQt_PyObject)'), self.location_selected) - QObject.connect(self.stack, SIGNAL('currentChanged(int)'), - self.location_view.location_changed) - + self.output_formats = sorted(['EPUB', 'MOBI', 'LRF']) for f in self.output_formats: self.output_format.addItem(f) - self.output_format.setCurrentIndex(self.output_formats.index(prefs['output_format'])) - self.connect(self.output_format, SIGNAL('currentIndexChanged(QString)'), + self.output_format.setCurrentIndex(self.output_formats.index( + prefs['output_format'])) + self.connect(self.output_format, SIGNAL('currentIndexChanged(QString)'), self.change_output_format, Qt.QueuedConnection) ####################### Vanity ######################## - self.vanity_template = _('<p>For help visit <a href="http://%s.kovidgoyal.net/user_manual">%s.kovidgoyal.net</a><br>')%(__appname__, __appname__) - self.vanity_template += _('<b>%s</b>: %s by <b>Kovid Goyal %%(version)s</b><br>%%(device)s</p>')%(__appname__, __version__) + self.vanity_template = _('<p>For help visit <a href="http://%s.' + 'kovidgoyal.net/user_manual">%s.kovidgoyal.net</a>' + '<br>')%(__appname__, __appname__) + self.vanity_template += _('<b>%s</b>: %s by <b>Kovid Goyal ' + '%%(version)s</b><br>%%(device)s</p>')%(__appname__, __version__) self.latest_version = ' ' self.vanity.setText(self.vanity_template%dict(version=' ', device=' ')) self.device_info = ' ' self.update_checker = CheckForUpdates() - QObject.connect(self.update_checker, SIGNAL('update_found(PyQt_PyObject)'), - self.update_found) + QObject.connect(self.update_checker, + SIGNAL('update_found(PyQt_PyObject)'), self.update_found) self.update_checker.start() ####################### Status Bar ##################### self.status_bar = StatusBar(self.jobs_dialog, self.system_tray_icon) self.setStatusBar(self.status_bar) - QObject.connect(self.job_manager, SIGNAL('job_added(int)'), self.status_bar.job_added, - Qt.QueuedConnection) - QObject.connect(self.job_manager, SIGNAL('job_done(int)'), self.status_bar.job_done, - Qt.QueuedConnection) - QObject.connect(self.status_bar, SIGNAL('show_book_info()'), self.show_book_info) + QObject.connect(self.job_manager, SIGNAL('job_added(int)'), + self.status_bar.job_added, Qt.QueuedConnection) + QObject.connect(self.job_manager, SIGNAL('job_done(int)'), + self.status_bar.job_done, Qt.QueuedConnection) + QObject.connect(self.status_bar, SIGNAL('show_book_info()'), + self.show_book_info) ####################### Setup Toolbar ##################### - sm = QMenu() - sm.addAction(QIcon(':/images/reader.svg'), _('Send to main memory')) - sm.addAction(QIcon(':/images/sd.svg'), _('Send to storage card')) - sm.addAction(QIcon(':/images/reader.svg'), _('Send to main memory')+' '+_('and delete from library')) - sm.addAction(QIcon(':/images/sd.svg'), _('Send to storage card')+' '+_('and delete from library')) - sm.addAction(self.action_send_specific_format_to_device) - self.connect(self.action_send_specific_format_to_device, - SIGNAL('triggered()'), self.send_specific_format_to_device) - sm.addSeparator() - sm.addAction(_('Send to storage card by default')) - sm.actions()[-1].setCheckable(True) - QObject.connect(sm.actions()[-1], SIGNAL('toggled(bool)'), - self.do_default_sync) - - sm.actions()[-1].setChecked(config.get('send_to_storage_card_by_default')) - self.do_default_sync(sm.actions()[-1].isChecked()) - self.sync_menu = sm # Needed md = QMenu() md.addAction(_('Edit metadata individually')) md.addSeparator() @@ -176,42 +171,60 @@ class Main(MainWindow, Ui_MainWindow): self.metadata_menu = md self.add_menu = QMenu() self.add_menu.addAction(_('Add books from a single directory')) - self.add_menu.addAction(_('Add books from directories, including sub-directories (One book per directory, assumes every ebook file is the same book in a different format)')) - self.add_menu.addAction(_('Add books from directories, including sub directories (Multiple books per directory, assumes every ebook file is a different book)')) + self.add_menu.addAction(_('Add books from directories, including ' + 'sub-directories (One book per directory, assumes every ebook ' + 'file is the same book in a different format)')) + self.add_menu.addAction(_('Add books from directories, including ' + 'sub directories (Multiple books per directory, assumes every ' + 'ebook file is a different book)')) self.action_add.setMenu(self.add_menu) - QObject.connect(self.action_add, SIGNAL("triggered(bool)"), self.add_books) - QObject.connect(self.add_menu.actions()[0], SIGNAL("triggered(bool)"), self.add_books) - QObject.connect(self.add_menu.actions()[1], SIGNAL("triggered(bool)"), self.add_recursive_single) - QObject.connect(self.add_menu.actions()[2], SIGNAL("triggered(bool)"), self.add_recursive_multiple) - QObject.connect(self.action_del, SIGNAL("triggered(bool)"), self.delete_books) - QObject.connect(self.action_edit, SIGNAL("triggered(bool)"), self.edit_metadata) - QObject.connect(md.actions()[0], SIGNAL('triggered(bool)'), partial(self.edit_metadata, bulk=False)) - QObject.connect(md.actions()[2], SIGNAL('triggered(bool)'), partial(self.edit_metadata, bulk=True)) - QObject.connect(sm.actions()[0], SIGNAL('triggered(bool)'), self.sync_to_main_memory) - QObject.connect(sm.actions()[1], SIGNAL('triggered(bool)'), self.sync_to_card) - QObject.connect(sm.actions()[2], SIGNAL('triggered(bool)'), partial(self.sync_to_main_memory, delete_from_library=True)) - QObject.connect(sm.actions()[3], SIGNAL('triggered(bool)'), partial(self.sync_to_card, delete_from_library=True)) + QObject.connect(self.action_add, SIGNAL("triggered(bool)"), + self.add_books) + QObject.connect(self.add_menu.actions()[0], SIGNAL("triggered(bool)"), + self.add_books) + QObject.connect(self.add_menu.actions()[1], SIGNAL("triggered(bool)"), + self.add_recursive_single) + QObject.connect(self.add_menu.actions()[2], SIGNAL("triggered(bool)"), + self.add_recursive_multiple) + QObject.connect(self.action_del, SIGNAL("triggered(bool)"), + self.delete_books) + QObject.connect(self.action_edit, SIGNAL("triggered(bool)"), + self.edit_metadata) + QObject.connect(md.actions()[0], SIGNAL('triggered(bool)'), + partial(self.edit_metadata, bulk=False)) + QObject.connect(md.actions()[2], SIGNAL('triggered(bool)'), + partial(self.edit_metadata, bulk=True)) self.save_menu = QMenu() self.save_menu.addAction(_('Save to disk')) self.save_menu.addAction(_('Save to disk in a single directory')) - self.save_menu.addAction(_('Save only %s format to disk')%config.get('save_to_disk_single_format').upper()) - + self.save_menu.addAction(_('Save only %s format to disk')%\ + config.get('save_to_disk_single_format').upper()) + self.view_menu = QMenu() self.view_menu.addAction(_('View')) self.view_menu.addAction(_('View specific format')) self.action_view.setMenu(self.view_menu) - QObject.connect(self.action_save, SIGNAL("triggered(bool)"), self.save_to_disk) - QObject.connect(self.save_menu.actions()[0], SIGNAL("triggered(bool)"), self.save_to_disk) - QObject.connect(self.save_menu.actions()[1], SIGNAL("triggered(bool)"), self.save_to_single_dir) - QObject.connect(self.save_menu.actions()[2], SIGNAL("triggered(bool)"), self.save_single_format_to_disk) - QObject.connect(self.action_view, SIGNAL("triggered(bool)"), self.view_book) - QObject.connect(self.view_menu.actions()[0], SIGNAL("triggered(bool)"), self.view_book) - QObject.connect(self.view_menu.actions()[1], SIGNAL("triggered(bool)"), self.view_specific_format) - self.connect(self.action_open_containing_folder, SIGNAL('triggered(bool)'), self.view_folder) + QObject.connect(self.action_save, SIGNAL("triggered(bool)"), + self.save_to_disk) + QObject.connect(self.save_menu.actions()[0], SIGNAL("triggered(bool)"), + self.save_to_disk) + QObject.connect(self.save_menu.actions()[1], SIGNAL("triggered(bool)"), + self.save_to_single_dir) + QObject.connect(self.save_menu.actions()[2], SIGNAL("triggered(bool)"), + self.save_single_format_to_disk) + QObject.connect(self.action_view, SIGNAL("triggered(bool)"), + self.view_book) + QObject.connect(self.view_menu.actions()[0], + SIGNAL("triggered(bool)"), self.view_book) + QObject.connect(self.view_menu.actions()[1], + SIGNAL("triggered(bool)"), self.view_specific_format) + self.connect(self.action_open_containing_folder, + SIGNAL('triggered(bool)'), self.view_folder) self.action_open_containing_folder.setShortcut(Qt.Key_O) self.addAction(self.action_open_containing_folder) self.action_sync.setShortcut(Qt.Key_D) - self.action_sync.setMenu(sm) + self.action_sync.setEnabled(True) + self.create_device_menu() self.action_edit.setMenu(md) self.action_save.setMenu(self.save_menu) cm = QMenu() @@ -221,26 +234,42 @@ class Main(MainWindow, Ui_MainWindow): cm.addAction(_('Set defaults for conversion')) cm.addAction(_('Set defaults for conversion of comics')) self.action_convert.setMenu(cm) - QObject.connect(cm.actions()[0], SIGNAL('triggered(bool)'), self.convert_single) - QObject.connect(cm.actions()[1], SIGNAL('triggered(bool)'), self.convert_bulk) - QObject.connect(cm.actions()[3], SIGNAL('triggered(bool)'), self.set_conversion_defaults) - QObject.connect(cm.actions()[4], SIGNAL('triggered(bool)'), self.set_comic_conversion_defaults) - QObject.connect(self.action_convert, SIGNAL('triggered(bool)'), self.convert_single) + QObject.connect(cm.actions()[0], + SIGNAL('triggered(bool)'), self.convert_single) + QObject.connect(cm.actions()[1], + SIGNAL('triggered(bool)'), self.convert_bulk) + QObject.connect(cm.actions()[3], + SIGNAL('triggered(bool)'), self.set_conversion_defaults) + QObject.connect(cm.actions()[4], + SIGNAL('triggered(bool)'), self.set_comic_conversion_defaults) + QObject.connect(self.action_convert, + SIGNAL('triggered(bool)'), self.convert_single) self.convert_menu = cm - self.tool_bar.widgetForAction(self.action_news).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_edit).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_sync).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_convert).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_save).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_add).setPopupMode(QToolButton.MenuButtonPopup) - self.tool_bar.widgetForAction(self.action_view).setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_news).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_edit).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_sync).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_convert).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_save).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_add).\ + setPopupMode(QToolButton.MenuButtonPopup) + self.tool_bar.widgetForAction(self.action_view).\ + setPopupMode(QToolButton.MenuButtonPopup) self.tool_bar.setContextMenuPolicy(Qt.PreventContextMenu) - QObject.connect(self.config_button, SIGNAL('clicked(bool)'), self.do_config) - self.connect(self.preferences_action, SIGNAL('triggered(bool)'), self.do_config) - self.connect(self.action_preferences, SIGNAL('triggered(bool)'), self.do_config) - QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'), self.do_advanced_search) - + QObject.connect(self.config_button, + SIGNAL('clicked(bool)'), self.do_config) + self.connect(self.preferences_action, SIGNAL('triggered(bool)'), + self.do_config) + self.connect(self.action_preferences, SIGNAL('triggered(bool)'), + self.do_config) + QObject.connect(self.advanced_search_button, SIGNAL('clicked(bool)'), + self.do_advanced_search) + ####################### Library view ######################## similar_menu = QMenu(_('Similar books...')) similar_menu.addAction(self.action_books_by_same_author) @@ -260,22 +289,28 @@ class Main(MainWindow, Ui_MainWindow): lambda : self.show_similar_books('author')) self.connect(self.action_books_in_this_series, SIGNAL('triggered()'), lambda : self.show_similar_books('series')) - self.connect(self.action_books_with_the_same_tags, SIGNAL('triggered()'), + self.connect(self.action_books_with_the_same_tags, + SIGNAL('triggered()'), lambda : self.show_similar_books('tag')) self.connect(self.action_books_by_this_publisher, SIGNAL('triggered()'), lambda : self.show_similar_books('publisher')) - self.library_view.set_context_menu(self.action_edit, self.action_sync, - self.action_convert, self.action_view, - self.action_save, self.action_open_containing_folder, - self.action_show_book_details, - similar_menu=similar_menu) - self.memory_view.set_context_menu(None, None, None, self.action_view, self.action_save, None, None) - self.card_view.set_context_menu(None, None, None, self.action_view, self.action_save, None, None) - QObject.connect(self.library_view, SIGNAL('files_dropped(PyQt_PyObject)'), + self.library_view.set_context_menu(self.action_edit, self.action_sync, + self.action_convert, self.action_view, + self.action_save, + self.action_open_containing_folder, + self.action_show_book_details, + similar_menu=similar_menu) + self.memory_view.set_context_menu(None, None, None, + self.action_view, self.action_save, None, None) + self.card_view.set_context_menu(None, None, None, + self.action_view, self.action_save, None, None) + QObject.connect(self.library_view, + SIGNAL('files_dropped(PyQt_PyObject)'), self.files_dropped) for func, target in [ ('connect_to_search_box', self.search), - ('connect_to_book_display', self.status_bar.book_info.show_data), + ('connect_to_book_display', + self.status_bar.book_info.show_data), ]: for view in (self.library_view, self.memory_view, self.card_view): getattr(view, func)(target) @@ -290,9 +325,11 @@ class Main(MainWindow, Ui_MainWindow): try: db = LibraryDatabase2(self.library_path) except Exception, err: - error_dialog(self, _('Bad database location'), unicode(err)).exec_() + error_dialog(self, _('Bad database location'), + unicode(err)).exec_() dir = unicode(QFileDialog.getExistingDirectory(self, - _('Choose a location for your ebook library.'), os.path.expanduser('~'))) + _('Choose a location for your ebook library.'), + os.path.expanduser('~'))) if not dir: QCoreApplication.exit(1) else: @@ -300,7 +337,8 @@ class Main(MainWindow, Ui_MainWindow): db = LibraryDatabase2(self.library_path) self.library_view.set_database(db) prefs['library_path'] = self.library_path - self.library_view.sortByColumn(*dynamic.get('sort_column', ('timestamp', Qt.DescendingOrder))) + self.library_view.sortByColumn(*dynamic.get('sort_column', + ('timestamp', Qt.DescendingOrder))) if not self.library_view.restore_column_widths(): self.library_view.resizeColumnsToContents() self.library_view.resizeRowsToContents() @@ -313,12 +351,16 @@ class Main(MainWindow, Ui_MainWindow): self.match_any.setVisible(False) self.popularity.setVisible(False) self.tags_view.set_database(db, self.match_all, self.popularity) - self.connect(self.tags_view, SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), + self.connect(self.tags_view, + SIGNAL('tags_marked(PyQt_PyObject, PyQt_PyObject)'), self.search.search_from_tags) - self.connect(self.status_bar.tag_view_button, SIGNAL('toggled(bool)'), self.toggle_tags_view) - self.connect(self.search, SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), + self.connect(self.status_bar.tag_view_button, + SIGNAL('toggled(bool)'), self.toggle_tags_view) + self.connect(self.search, + SIGNAL('search(PyQt_PyObject, PyQt_PyObject)'), self.tags_view.model().reinit) - self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), self.location_view.count_changed) + self.connect(self.library_view.model(), + SIGNAL('count_changed(int)'), self.location_view.count_changed) self.connect(self.library_view.model(), SIGNAL('count_changed(int)'), self.tags_view.recount) self.library_view.model().count_changed() @@ -335,16 +377,16 @@ class Main(MainWindow, Ui_MainWindow): self.cover_flow.setVisible(False) if not config['separate_cover_flow']: self.library.layout().addWidget(self.cover_flow) - self.connect(self.cover_flow, SIGNAL('currentChanged(int)'), + self.connect(self.cover_flow, SIGNAL('currentChanged(int)'), self.sync_cf_to_listview) - self.connect(self.cover_flow, SIGNAL('itemActivated(int)'), + self.connect(self.cover_flow, SIGNAL('itemActivated(int)'), self.show_book_info) - self.connect(self.status_bar.cover_flow_button, + self.connect(self.status_bar.cover_flow_button, SIGNAL('toggled(bool)'), self.toggle_cover_flow) - self.connect(self.cover_flow, SIGNAL('stop()'), + self.connect(self.cover_flow, SIGNAL('stop()'), self.status_bar.cover_flow_button.toggle) - QObject.connect(self.library_view.selectionModel(), - SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'), + QObject.connect(self.library_view.selectionModel(), + SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'), self.sync_cf_to_listview) self.db_images = DatabaseImages(self.library_view.model()) self.cover_flow.setImages(self.db_images) @@ -355,30 +397,45 @@ class Main(MainWindow, Ui_MainWindow): self.setMaximumHeight(max_available_height()) ####################### Setup device detection ######################## - self.device_manager = DeviceManager(Dispatcher(self.device_detected), self.job_manager) + self.device_manager = DeviceManager(Dispatcher(self.device_detected), + self.job_manager) self.device_manager.start() - - + + if config['autolaunch_server']: from calibre.library.server import start_threaded_server from calibre.library import server_config - self.content_server = start_threaded_server(db, server_config().parse()) + self.content_server = start_threaded_server( + db, server_config().parse()) self.test_server_timer = QTimer.singleShot(10000, self.test_server) - - + + self.scheduler = Scheduler(self) self.action_news.setMenu(self.scheduler.news_menu) - self.connect(self.action_news, SIGNAL('triggered(bool)'), self.scheduler.show_dialog) + self.connect(self.action_news, SIGNAL('triggered(bool)'), + self.scheduler.show_dialog) self.location_view.setCurrentIndex(self.location_view.model().index(0)) - + + def create_device_menu(self): + self._sync_menu = DeviceMenu(self) + self.action_sync.setMenu(self._sync_menu) + self.connect(self._sync_menu, + SIGNAL('sync(PyQt_PyObject, PyQt_PyObject, PyQt_PyObject)'), + self.dispatch_sync_event) + self.connect(self.action_sync, SIGNAL('triggered(bool)'), + self._sync_menu.trigger_default) + + + def no_op(self, *args): pass - + def system_tray_icon_activated(self, r): if r == QSystemTrayIcon.Trigger: if self.isVisible(): for window in QApplication.topLevelWidgets(): - if isinstance(window, (MainWindow, QDialog)) and window.isVisible(): + if isinstance(window, (MainWindow, QDialog)) and \ + window.isVisible(): window.hide() setattr(window, '__systray_minimized', True) else: @@ -386,29 +443,22 @@ class Main(MainWindow, Ui_MainWindow): if getattr(window, '__systray_minimized', False): window.show() setattr(window, '__systray_minimized', False) - - - def do_default_sync(self, checked): - config.set('send_to_storage_card_by_default', bool(checked)) - QObject.disconnect(self.action_sync, SIGNAL("triggered(bool)"), - self.sync_to_main_memory) - QObject.disconnect(self.action_sync, SIGNAL("triggered(bool)"), - self.sync_to_card) - QObject.connect(self.action_sync, SIGNAL("triggered(bool)"), - self.sync_to_card if checked else self.sync_to_main_memory) - + + def change_output_format(self, x): of = unicode(x).strip() if of != prefs['output_format']: if of not in ('LRF', 'EPUB', 'MOBI'): - warning_dialog(self, 'Warning', - '<p>%s support is still in beta. If you find bugs, please report them by opening a <a href="http://calibre.kovidgoyal.net">ticket</a>.'%of).exec_() + warning_dialog(self, 'Warning', + ('<p>%s support is still in beta. If you find bugs, ' + 'please report them by opening a <a href="http://cal' + 'ibre.kovidgoyal.net">ticket</a>.')%of).exec_() prefs.set('output_format', of) - - + + def test_server(self, *args): if self.content_server.exception is not None: - error_dialog(self, _('Failed to start content server'), + error_dialog(self, _('Failed to start content server'), unicode(self.content_server.exception)).exec_() def show_similar_books(self, type): @@ -432,13 +482,14 @@ class Main(MainWindow, Ui_MainWindow): elif type == 'author': authors = idx.model().db.authors(row) if authors: - search = ['author:'+a.strip().replace('|', ',') for a in authors.split(',')] + search = ['author:'+a.strip().replace('|', ',') \ + for a in authors.split(',')] join = ' or ' if search: self.search.set_search_string(join.join(search)) - - - + + + def toggle_cover_flow(self, show): if config['separate_cover_flow']: if show: @@ -453,8 +504,9 @@ class Main(MainWindow, Ui_MainWindow): self.cover_flow.setFocus(Qt.OtherFocusReason) self.library_view.scrollTo(self.library_view.currentIndex()) d.show() - self.connect(d, SIGNAL('finished(int)'), - lambda x: self.status_bar.cover_flow_button.setChecked(False)) + self.connect(d, SIGNAL('finished(int)'), + lambda x: self.status_bar.\ + cover_flow_button.setChecked(False)) self.cf_dialog = d else: cfd = getattr(self, 'cf_dialog', None) @@ -464,7 +516,8 @@ class Main(MainWindow, Ui_MainWindow): self.cf_dialog = None else: if show: - self.library_view.setCurrentIndex(self.library_view.currentIndex()) + self.library_view.setCurrentIndex( + self.library_view.currentIndex()) self.cover_flow.setVisible(True) self.cover_flow.setFocus(Qt.OtherFocusReason) #self.status_bar.book_info.book_data.setMaximumHeight(100) @@ -489,7 +542,8 @@ class Main(MainWindow, Ui_MainWindow): self.popularity.setVisible(False) def sync_cf_to_listview(self, index, *args): - if not hasattr(index, 'row') and self.library_view.currentIndex().row() != index: + if not hasattr(index, 'row') and \ + self.library_view.currentIndex().row() != index: index = self.library_view.model().index(index, 0) self.library_view.setCurrentIndex(index) if hasattr(index, 'row') and self.cover_flow.isVisible() and \ @@ -503,7 +557,8 @@ class Main(MainWindow, Ui_MainWindow): path = os.path.abspath(argv[1]) if os.access(path, os.R_OK): self.add_filesystem_book(path) - self.setWindowState(self.windowState() & ~Qt.WindowMinimized|Qt.WindowActive) + self.setWindowState(self.windowState() & \ + ~Qt.WindowMinimized|Qt.WindowActive) self.show() self.raise_() self.activateWindow() @@ -535,22 +590,26 @@ class Main(MainWindow, Ui_MainWindow): Called when a device is connected to the computer. ''' if connected: - self.device_manager.get_device_information(Dispatcher(self.info_read)) - self.set_default_thumbnail(self.device_manager.device.THUMBNAIL_HEIGHT) + self.device_manager.get_device_information(\ + Dispatcher(self.info_read)) + self.set_default_thumbnail(\ + self.device_manager.device.THUMBNAIL_HEIGHT) self.status_bar.showMessage(_('Device: ')+\ - self.device_manager.device.__class__.__name__+_(' detected.'), 3000) - self.action_sync.setEnabled(True) + self.device_manager.device.__class__.__name__+\ + _(' detected.'), 3000) self.device_connected = True + self._sync_menu.enable_device_actions(True) else: self.device_connected = False + self._sync_menu.enable_device_actions(False) self.location_view.model().update_devices() - self.action_sync.setEnabled(False) - self.vanity.setText(self.vanity_template%dict(version=self.latest_version, device=' ')) + self.vanity.setText(self.vanity_template%\ + dict(version=self.latest_version, device=' ')) self.device_info = ' ' if self.current_view() != self.library_view: self.status_bar.reset_info() self.location_selected('library') - + def info_read(self, job): ''' Called once device information has been read. @@ -561,10 +620,11 @@ class Main(MainWindow, Ui_MainWindow): info, cp, fs = job.result self.location_view.model().update_devices(cp, fs) self.device_info = _('Connected ')+info[0] - self.vanity.setText(self.vanity_template%dict(version=self.latest_version, device=self.device_info)) + self.vanity.setText(self.vanity_template%\ + dict(version=self.latest_version, device=self.device_info)) self.device_manager.books(Dispatcher(self.metadata_downloaded)) - + def metadata_downloaded(self, job): ''' Called once metadata has been read for all books on the device. @@ -595,41 +655,22 @@ class Main(MainWindow, Ui_MainWindow): ############################################################################ - ############################# Upload booklists ############################# - def upload_booklists(self): - ''' - Upload metadata to device. - ''' - self.device_manager.sync_booklists(Dispatcher(self.metadata_synced), - self.booklists()) - - def metadata_synced(self, job): - ''' - Called once metadata has been uploaded. - ''' - if job.exception is not None: - self.device_job_exception(job) - return - cp, fs = job.result - self.location_view.model().update_devices(cp, fs) - ############################################################################ - ################################# Add books ################################ def add_recursive(self, single): - root = choose_dir(self, 'recursive book import root dir dialog', + root = choose_dir(self, 'recursive book import root dir dialog', 'Select root folder') if not root: return from calibre.gui2.add import AddRecursive - self._add_recursive_thread = AddRecursive(root, + self._add_recursive_thread = AddRecursive(root, self.library_view.model().db, self.get_metadata, single, self) self.connect(self._add_recursive_thread, SIGNAL('finished()'), self._recursive_files_added) self._add_recursive_thread.start() - + def _recursive_files_added(self): self._add_recursive_thread.process_duplicates() if self._add_recursive_thread.number_of_books_added > 0: @@ -637,7 +678,7 @@ class Main(MainWindow, Ui_MainWindow): self.library_view.model().research() self.library_view.model().count_changed() self._add_recursive_thread = None - + def add_recursive_single(self, checked): ''' Add books from the local filesystem to either the library or the device @@ -663,7 +704,8 @@ class Main(MainWindow, Ui_MainWindow): to_device = self.stack.currentIndex() != 0 self._add_books(books, to_device) if to_device: - self.status_bar.showMessage(_('Uploading books to device.'), 2000) + self.status_bar.showMessage(\ + _('Uploading books to device.'), 2000) def add_books(self, checked): ''' @@ -671,22 +713,22 @@ class Main(MainWindow, Ui_MainWindow): ''' books = choose_files(self, 'add books dialog dir', 'Select books', filters=[ - (_('Books'), BOOK_EXTENSIONS), - (_('EPUB Books'), ['epub']), - (_('LRF Books'), ['lrf']), - (_('HTML Books'), ['htm', 'html', 'xhtm', 'xhtml']), - (_('LIT Books'), ['lit']), - (_('MOBI Books'), ['mobi', 'prc']), - (_('Text books'), ['txt', 'rtf']), - (_('PDF Books'), ['pdf']), - (_('Comics'), ['cbz', 'cbr']), - (_('Archives'), ['zip', 'rar']), - ]) + (_('Books'), BOOK_EXTENSIONS), + (_('EPUB Books'), ['epub']), + (_('LRF Books'), ['lrf']), + (_('HTML Books'), ['htm', 'html', 'xhtm', 'xhtml']), + (_('LIT Books'), ['lit']), + (_('MOBI Books'), ['mobi', 'prc']), + (_('Text books'), ['txt', 'rtf']), + (_('PDF Books'), ['pdf']), + (_('Comics'), ['cbz', 'cbr']), + (_('Archives'), ['zip', 'rar']), + ]) if not books: return to_device = self.stack.currentIndex() != 0 self._add_books(books, to_device) - + def _add_books(self, paths, to_device, on_card=None): if on_card is None: @@ -694,7 +736,7 @@ class Main(MainWindow, Ui_MainWindow): if not paths: return from calibre.gui2.add import AddFiles - self._add_files_thread = AddFiles(paths, self.default_thumbnail, + self._add_files_thread = AddFiles(paths, self.default_thumbnail, self.get_metadata, None if to_device else \ self.library_view.model().db @@ -706,63 +748,23 @@ class Main(MainWindow, Ui_MainWindow): self.connect(self._add_files_thread, SIGNAL('finished()'), self._files_added) self._add_files_thread.start() - + def _files_added(self): t = self._add_files_thread self._add_files_thread = None if not t.canceled: if t.send_to_device: - self.upload_books(t.paths, - list(map(sanitize_file_name, t.names)), + self.upload_books(t.paths, + list(map(sanitize_file_name, t.names)), t.infos, on_card=t.on_card) - self.status_bar.showMessage(_('Uploading books to device.'), 2000) + self.status_bar.showMessage( + _('Uploading books to device.'), 2000) else: t.process_duplicates() if t.number_of_books_added > 0: self.library_view.model().books_added(t.number_of_books_added) - self.db_images.reset() - - def upload_books(self, files, names, metadata, on_card=False, memory=None): - ''' - Upload books to device. - :param files: List of either paths to files or file like objects - ''' - titles = [i['title'] for i in metadata] - job = self.device_manager.upload_books(Dispatcher(self.books_uploaded), - files, names, on_card=on_card, - metadata=metadata, titles=titles - ) - self.upload_memory[job] = (metadata, on_card, memory, files) - - def books_uploaded(self, job): - ''' - Called once books have been uploaded. - ''' - metadata, on_card, memory, files = self.upload_memory.pop(job) - - if job.exception is not None: - if isinstance(job.exception, FreeSpaceError): - where = 'in main memory.' if 'memory' in str(job.exception) else 'on the storage card.' - titles = '\n'.join(['<li>'+mi['title']+'</li>' for mi in metadata]) - d = error_dialog(self, _('No space on device'), - _('<p>Cannot upload books to device there is no more free space available ')+where+ - '</p>\n<ul>%s</ul>'%(titles,)) - d.exec_() - else: - self.device_job_exception(job) - return - - self.device_manager.add_books_to_metadata(job.result, metadata, self.booklists()) - - self.upload_booklists() - - view = self.card_view if on_card else self.memory_view - view.model().resort(reset=False) - view.model().research() - for f in files: - getattr(f, 'close', lambda : True)() - if memory and memory[1]: - self.library_view.model().delete_books_by_id(memory[1]) + if hasattr(self, 'db_images'): + self.db_images.reset() ############################################################################ @@ -784,7 +786,8 @@ class Main(MainWindow, Ui_MainWindow): return view.model().delete_books(rows) else: - view = self.memory_view if self.stack.currentIndex() == 1 else self.card_view + view = self.memory_view if self.stack.currentIndex() == 1 \ + else self.card_view paths = view.model().paths(rows) job = self.remove_paths(paths) self.delete_memory[job] = (paths, view.model()) @@ -792,8 +795,9 @@ class Main(MainWindow, Ui_MainWindow): self.status_bar.showMessage(_('Deleting books from device.'), 1000) def remove_paths(self, paths): - return self.device_manager.delete_books(Dispatcher(self.books_deleted), paths) - + return self.device_manager.delete_books(\ + Dispatcher(self.books_deleted), paths) + def books_deleted(self, job): ''' Called once deletion is done on the device @@ -801,12 +805,13 @@ class Main(MainWindow, Ui_MainWindow): for view in (self.memory_view, self.card_view): view.model().deletion_done(job, bool(job.exception)) if job.exception is not None: - self.device_job_exception(job) + self.device_job_exception(job) return - + if self.delete_memory.has_key(job): paths, model = self.delete_memory.pop(job) - self.device_manager.remove_books_from_metadata(paths, self.booklists()) + self.device_manager.remove_books_from_metadata(paths, + self.booklists()) model.paths_deleted(paths) self.upload_booklists() @@ -820,14 +825,14 @@ class Main(MainWindow, Ui_MainWindow): rows = self.library_view.selectionModel().selectedRows() previous = self.library_view.currentIndex() if not rows or len(rows) == 0: - d = error_dialog(self, _('Cannot edit metadata'), + d = error_dialog(self, _('Cannot edit metadata'), _('No books selected')) d.exec_() return if bulk or (bulk is None and len(rows) > 1): return self.edit_bulk_metadata(checked) - + def accepted(id): self.library_view.model().refresh_ids([id]) @@ -839,149 +844,45 @@ class Main(MainWindow, Ui_MainWindow): if rows: current = self.library_view.currentIndex() self.library_view.model().current_changed(current, previous) - + def edit_bulk_metadata(self, checked): ''' Edit metadata of selected books in library in bulk. ''' - rows = [r.row() for r in self.library_view.selectionModel().selectedRows()] + rows = [r.row() for r in \ + self.library_view.selectionModel().selectedRows()] if not rows or len(rows) == 0: - d = error_dialog(self, _('Cannot edit metadata'), _('No books selected')) + d = error_dialog(self, _('Cannot edit metadata'), + _('No books selected')) d.exec_() return - if MetadataBulkDialog(self, rows, self.library_view.model().db).changed: + if MetadataBulkDialog(self, rows, + self.library_view.model().db).changed: self.library_view.model().resort(reset=False) self.library_view.model().research() ############################################################################ - ############################# Syncing to device############################# - def sync_to_main_memory(self, checked, delete_from_library=False): - self.sync_to_device(False, delete_from_library) - - def sync_to_card(self, checked, delete_from_library=False): - self.sync_to_device(True, delete_from_library) - - def cover_to_thumbnail(self, data): - p = QPixmap() - p.loadFromData(data) - if not p.isNull(): - ht = self.device_manager.device_class.THUMBNAIL_HEIGHT if self.device_manager else \ - Device.THUMBNAIL_HEIGHT - p = p.scaledToHeight(ht, Qt.SmoothTransformation) - return (p.width(), p.height(), pixmap_to_data(p)) - - def sync_news(self): - if self.device_connected: - ids = list(dynamic.get('news_to_be_synced', set([]))) - ids = [id for id in ids if self.library_view.model().db.has_id(id)] - files = self.library_view.model().get_preferred_formats_from_ids( - ids, self.device_manager.device_class.FORMATS) - files = [f for f in files if f is not None] - if not files: - dynamic.set('news_to_be_synced', set([])) - return - metadata = self.library_view.model().get_metadata(ids, rows_are_ids=True) - names = [] - for mi in metadata: - prefix = sanitize_file_name(mi['title']) - if not isinstance(prefix, unicode): - prefix = prefix.decode(preferred_encoding, 'replace') - prefix = ascii_filename(prefix) - names.append('%s_%d%s'%(prefix, id, os.path.splitext(f.name)[1])) - cdata = mi['cover'] - if cdata: - mi['cover'] = self.cover_to_thumbnail(cdata) - dynamic.set('news_to_be_synced', set([])) - if config['upload_news_to_device'] and files: - remove = ids if config['delete_news_from_library_on_upload'] else [] - on_card = self.location_view.model().free[0] < self.location_view.model().free[1] - self.upload_books(files, names, metadata, on_card=on_card, memory=[[f.name for f in files], remove]) - self.status_bar.showMessage(_('Sending news to device.'), 5000) - - def send_specific_format_to_device(self): - d = ChooseFormatDialog(self, _('Choose format to send to device'), - self.device_manager.device_class.FORMATS) - d.exec_() - fmt = d.format().lower() - on_card = config['send_to_storage_card_by_default'] - self.sync_to_device(on_card, False, specific_format=fmt) - - - def sync_to_device(self, on_card, delete_from_library, specific_format=None): - rows = self.library_view.selectionModel().selectedRows() - if not self.device_manager or not rows or len(rows) == 0: - return - ids = iter(self.library_view.model().id(r) for r in rows) - metadata, full_metadata = self.library_view.model().get_metadata( - rows, full_metadata=True) - for mi in metadata: - cdata = mi['cover'] - if cdata: - mi['cover'] = self.cover_to_thumbnail(cdata) - metadata, full_metadata = iter(metadata), iter(full_metadata) - _files = self.library_view.model().get_preferred_formats(rows, - self.device_manager.device_class.FORMATS, - paths=True, set_metadata=True, - specific_format=specific_format) - files = [getattr(f, 'name', None) for f in _files] - bad, good, gf, names, remove_ids = [], [], [], [], [] - for f in files: - mi, smi = metadata.next(), full_metadata.next() - id = ids.next() - if f is None: - bad.append(mi['title']) - else: - remove_ids.append(id) - try: - with open(f, 'r+b') as _f: - set_metadata(_f, smi, f.rpartition('.')[2]) - except: - print 'Error setting metadata in book:', mi['title'] - traceback.print_exc() - good.append(mi) - gf.append(f) - t = mi['title'] - if not t: - t = _('Unknown') - a = mi['authors'] - if not a: - a = _('Unknown') - prefix = sanitize_file_name(t+' - '+a) - if not isinstance(prefix, unicode): - prefix = prefix.decode(preferred_encoding, 'replace') - prefix = ascii_filename(prefix) - names.append('%s_%d%s'%(prefix, id, os.path.splitext(f)[1])) - remove = remove_ids if delete_from_library else [] - self.upload_books(gf, names, good, on_card, memory=(_files, remove)) - self.status_bar.showMessage(_('Sending books to device.'), 5000) - if bad: - bad = '\n'.join('<li>%s</li>'%(i,) for i in bad) - d = warning_dialog(self, _('No suitable formats'), - _('Could not upload the following books to the device, as no suitable formats were found:<br><ul>%s</ul>')%(bad,)) - d.exec_() - - - ############################################################################ ############################## Save to disk ################################ def save_single_format_to_disk(self, checked): self.save_to_disk(checked, True, config['save_to_disk_single_format']) - + def save_to_single_dir(self, checked): self.save_to_disk(checked, True) def save_to_disk(self, checked, single_dir=False, single_format=None): - + rows = self.current_view().selectionModel().selectedRows() if not rows or len(rows) == 0: - d = error_dialog(self, _('Cannot save to disk'), _('No books selected')) + d = error_dialog(self, _('Cannot save to disk'), + _('No books selected')) d.exec_() return - + progress = ProgressDialog(_('Saving to disk...'), min=0, max=len(rows), parent=self) - + def callback(count, msg): progress.set_value(count) progress.set_msg(_('Saved')+' '+msg) @@ -989,11 +890,12 @@ class Main(MainWindow, Ui_MainWindow): QApplication.sendPostedEvents() QApplication.flush() return not progress.canceled - - dir = choose_dir(self, 'save to disk dialog', _('Choose destination directory')) + + dir = choose_dir(self, 'save to disk dialog', + _('Choose destination directory')) if not dir: return - + progress.show() QApplication.processEvents() QApplication.sendPostedEvents() @@ -1001,24 +903,29 @@ class Main(MainWindow, Ui_MainWindow): try: if self.current_view() == self.library_view: failures = self.current_view().model().save_to_disk(rows, dir, - single_dir=single_dir, callback=callback, + single_dir=single_dir, + callback=callback, single_format=single_format) if failures and single_format is not None: - msg = _('<p>Could not save the following books to disk, because the %s format is not available for them:<ul>')%single_format.upper() + msg = _('<p>Could not save the following books to disk, ' + 'because the %s format is not available for them:<ul>')\ + %single_format.upper() for f in failures: msg += '<li>%s</li>'%f[1] msg += '</ul>' - warning_dialog(self, _('Could not save some ebooks'), msg).exec_() + warning_dialog(self, _('Could not save some ebooks'), + msg).exec_() QDesktopServices.openUrl(QUrl('file:'+dir)) else: paths = self.current_view().model().paths(rows) - self.device_manager.save_books(Dispatcher(self.books_saved), paths, dir) + self.device_manager.save_books( + Dispatcher(self.books_saved), paths, dir) finally: progress.hide() - + def books_saved(self, job): if job.exception is not None: - self.device_job_exception(job) + self.device_job_exception(job) return ############################################################################ @@ -1026,12 +933,14 @@ class Main(MainWindow, Ui_MainWindow): ############################### Fetch news ################################# def download_scheduled_recipe(self, recipe, script, callback): - func, args, desc, fmt, temp_files = fetch_scheduled_recipe(recipe, script) - job = self.job_manager.run_job(Dispatcher(self.scheduled_recipe_fetched), func, args=args, - description=desc) + func, args, desc, fmt, temp_files = \ + fetch_scheduled_recipe(recipe, script) + job = self.job_manager.run_job( + Dispatcher(self.scheduled_recipe_fetched), func, args=args, + description=desc) self.conversion_jobs[job] = (temp_files, fmt, recipe, callback) self.status_bar.showMessage(_('Fetching news from ')+recipe.title, 2000) - + def scheduled_recipe_fetched(self, job): temp_files, fmt, recipe, callback = self.conversion_jobs.pop(job) pt = temp_files[0] @@ -1046,15 +955,17 @@ class Main(MainWindow, Ui_MainWindow): callback(recipe) self.status_bar.showMessage(recipe.title + _(' fetched.'), 3000) self.sync_news() - + ############################################################################ ############################### Convert #################################### - + def get_books_for_conversion(self): - rows = [r.row() for r in self.library_view.selectionModel().selectedRows()] + rows = [r.row() for r in \ + self.library_view.selectionModel().selectedRows()] if not rows or len(rows) == 0: - d = error_dialog(self, _('Cannot convert'), _('No books selected')) + d = error_dialog(self, _('Cannot convert'), + _('No books selected')) d.exec_() return [], [] comics, others = [], [] @@ -1068,50 +979,53 @@ class Main(MainWindow, Ui_MainWindow): else: others.append(r) return comics, others - - + + def convert_bulk(self, checked): r = self.get_books_for_conversion() if r is None: return - comics, others = r - - res = convert_bulk_ebooks(self, self.library_view.model().db, comics, others) + comics, others = r + + res = convert_bulk_ebooks(self, + self.library_view.model().db, comics, others) if res is None: return - jobs, changed = res + jobs, changed = res for func, args, desc, fmt, id, temp_files in jobs: - job = self.job_manager.run_job(Dispatcher(self.book_converted), + job = self.job_manager.run_job(Dispatcher(self.book_converted), func, args=args, description=desc) self.conversion_jobs[job] = (temp_files, fmt, id) - + if changed: self.library_view.model().resort(reset=False) self.library_view.model().research() - + def set_conversion_defaults(self, checked): set_conversion_defaults(False, self, self.library_view.model().db) - + def set_comic_conversion_defaults(self, checked): set_conversion_defaults(True, self, self.library_view.model().db) - + def convert_single(self, checked): r = self.get_books_for_conversion() if r is None: return previous = self.library_view.currentIndex() - rows = [x.row() for x in self.library_view.selectionModel().selectedRows()] + rows = [x.row() for x in \ + self.library_view.selectionModel().selectedRows()] comics, others = r - jobs, changed = convert_single_ebook(self, self.library_view.model().db, comics, others) + jobs, changed = convert_single_ebook(self, + self.library_view.model().db, comics, others) for func, args, desc, fmt, id, temp_files in jobs: - job = self.job_manager.run_job(Dispatcher(self.book_converted), + job = self.job_manager.run_job(Dispatcher(self.book_converted), func, args=args, description=desc) self.conversion_jobs[job] = (temp_files, fmt, id) - + if changed: self.library_view.model().refresh_rows(rows) current = self.library_view.currentIndex() self.library_view.model().current_changed(current, previous) - + def book_converted(self, job): temp_files, fmt, book_id = self.conversion_jobs.pop(job) try: @@ -1119,9 +1033,11 @@ class Main(MainWindow, Ui_MainWindow): self.job_exception(job) return data = open(temp_files[-1].name, 'rb') - self.library_view.model().db.add_format(book_id, fmt, data, index_is_id=True) + self.library_view.model().db.add_format(book_id, \ + fmt, data, index_is_id=True) data.close() - self.status_bar.showMessage(job.description + (' completed'), 2000) + self.status_bar.showMessage(job.description + \ + (' completed'), 2000) finally: for f in temp_files: try: @@ -1133,18 +1049,19 @@ class Main(MainWindow, Ui_MainWindow): if self.current_view() is self.library_view: current = self.library_view.currentIndex() self.library_view.model().current_changed(current, QModelIndex()) - + #############################View book###################################### def view_format(self, row, format): - self._view_file(self.library_view.model().db.format(row, format, as_file=True).name) - + self._view_file(self.library_view.model().db.format(row, + format, as_file=True).name) + def book_downloaded_for_viewing(self, job): if job.exception: - self.device_job_exception(job) + self.device_job_exception(job) return self._view_file(job.result) - + def _view_file(self, name): self.setCursor(Qt.BusyCursor) try: @@ -1152,13 +1069,13 @@ class Main(MainWindow, Ui_MainWindow): if ext in config['internally_viewed_formats']: if ext == 'LRF': args = ['lrfviewer', name] - self.job_manager.server.run_free_job('lrfviewer', + self.job_manager.server.run_free_job('lrfviewer', kwdargs=dict(args=args)) else: args = ['ebook-viewer', name] if isosx: args.append('--raise-window') - self.job_manager.server.run_free_job('ebook-viewer', + self.job_manager.server.run_free_job('ebook-viewer', kwdargs=dict(args=args)) else: QDesktopServices.openUrl(QUrl('file:'+name))#launch(name) @@ -1182,19 +1099,20 @@ class Main(MainWindow, Ui_MainWindow): self.view_format(row, format) else: return - + def view_folder(self, *args): rows = self.current_view().selectionModel().selectedRows() if self.current_view() is self.library_view: if not rows or len(rows) == 0: - d = error_dialog(self, _('Cannot open folder'), _('No book selected')) + d = error_dialog(self, _('Cannot open folder'), + _('No book selected')) d.exec_() return for row in rows: path = self.library_view.model().db.abspath(row.row()) QDesktopServices.openUrl(QUrl('file:'+path)) - - + + def view_book(self, triggered): rows = self.current_view().selectionModel().selectedRows() if self.current_view() is self.library_view: @@ -1204,7 +1122,8 @@ class Main(MainWindow, Ui_MainWindow): return row = rows[0].row() - formats = self.library_view.model().db.formats(row).upper().split(',') + formats = self.library_view.model().db.formats(row).upper() + formats = formats.split(',') title = self.library_view.model().db.title(row) id = self.library_view.model().db.id(row) format = None @@ -1222,7 +1141,8 @@ class Main(MainWindow, Ui_MainWindow): d.exec_() return if format is None: - d = ChooseFormatDialog(self, _('Choose the format to view'), formats) + d = ChooseFormatDialog(self, _('Choose the format to view'), + formats) d.exec_() if d.result() == QDialog.Accepted: format = d.format() @@ -1233,14 +1153,16 @@ class Main(MainWindow, Ui_MainWindow): else: paths = self.current_view().model().paths(rows) if paths: - pt = PersistentTemporaryFile('_viewer_'+os.path.splitext(paths[0])[1]) + pt = PersistentTemporaryFile('_viewer_'+\ + os.path.splitext(paths[0])[1]) self.persistent_files.append(pt) pt.close() - self.device_manager.view_book(Dispatcher(self.book_downloaded_for_viewing), + self.device_manager.view_book(\ + Dispatcher(self.book_downloaded_for_viewing), paths[0], pt.name) - - - + + + ############################################################################ ########################### Do advanced search ############################# @@ -1256,16 +1178,23 @@ class Main(MainWindow, Ui_MainWindow): def do_config(self, *args): if self.job_manager.has_jobs(): - d = error_dialog(self, _('Cannot configure'), _('Cannot configure while there are running jobs.')) + d = error_dialog(self, _('Cannot configure'), + _('Cannot configure while there are running jobs.')) d.exec_() return - d = ConfigDialog(self, self.library_view.model().db, server=self.content_server) + d = ConfigDialog(self, self.library_view.model().db, + server=self.content_server) d.exec_() self.content_server = d.server if d.result() == d.Accepted: self.tool_bar.setIconSize(config['toolbar_icon_size']) - self.tool_bar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if config['show_text_in_toolbar'] else Qt.ToolButtonIconOnly) - self.save_menu.actions()[2].setText(_('Save only %s format to disk')%config.get('save_to_disk_single_format').upper()) + self.tool_bar.setToolButtonStyle( + Qt.ToolButtonTextUnderIcon if \ + config['show_text_in_toolbar'] else \ + Qt.ToolButtonIconOnly) + self.save_menu.actions()[2].setText( + _('Save only %s format to disk')%config.get( + 'save_to_disk_single_format').upper()) if self.library_path != d.database_location: try: newloc = d.database_location @@ -1276,10 +1205,12 @@ class Main(MainWindow, Ui_MainWindow): pd.setCancelButton(None) pd.setWindowTitle(_('Copying database')) pd.show() - self.status_bar.showMessage(_('Copying library to ')+newloc) + self.status_bar.showMessage( + _('Copying library to ')+newloc) self.setCursor(Qt.BusyCursor) self.library_view.setEnabled(False) - self.library_view.model().db.move_library_to(newloc, pd) + self.library_view.model().db.move_library_to( + newloc, pd) else: try: db = LibraryDatabase2(newloc) @@ -1287,13 +1218,18 @@ class Main(MainWindow, Ui_MainWindow): except Exception, err: traceback.print_exc() d = error_dialog(self, _('Invalid database'), - _('<p>An invalid database already exists at %s, delete it before trying to move the existing database.<br>Error: %s')%(newloc, str(err))) + _('<p>An invalid database already exists at ' + '%s, delete it before trying to move the ' + 'existing database.<br>Error: %s')%(newloc, + str(err))) d.exec_() - self.library_path = self.library_view.model().db.library_path + self.library_path = \ + self.library_view.model().db.library_path prefs['library_path'] = self.library_path except Exception, err: traceback.print_exc() - d = error_dialog(self, _('Could not move database'), unicode(err)) + d = error_dialog(self, _('Could not move database'), + unicode(err)) d.exec_() finally: self.unsetCursor() @@ -1306,6 +1242,8 @@ class Main(MainWindow, Ui_MainWindow): if hasattr(d, 'directories'): set_sidebar_directories(d.directories) self.library_view.model().read_config() + self.create_device_menu() + ############################################################################ @@ -1314,7 +1252,8 @@ class Main(MainWindow, Ui_MainWindow): def show_book_info(self, *args): if self.current_view() is not self.library_view: error_dialog(self, _('No detailed info available'), - _('No detailed information is available for books on the device.')).exec_() + _('No detailed information is available for books' + 'on the device.')).exec_() return index = self.library_view.currentIndex() if index.isValid(): @@ -1329,7 +1268,8 @@ class Main(MainWindow, Ui_MainWindow): ''' page = 0 if location == 'library' else 1 if location == 'main' else 2 self.stack.setCurrentIndex(page) - view = self.memory_view if page == 1 else self.card_view if page == 2 else None + view = self.memory_view if page == 1 else \ + self.card_view if page == 2 else None if view: if view.resize_on_select: view.resizeRowsToContents() @@ -1339,27 +1279,30 @@ class Main(MainWindow, Ui_MainWindow): self.status_bar.reset_info() self.current_view().clearSelection() if location == 'library': - if self.device_connected: - self.action_sync.setEnabled(True) self.action_edit.setEnabled(True) self.action_convert.setEnabled(True) self.view_menu.actions()[1].setEnabled(True) self.action_open_containing_folder.setEnabled(True) + self.action_sync.setEnabled(True) else: - self.action_sync.setEnabled(False) self.action_edit.setEnabled(False) self.action_convert.setEnabled(False) self.view_menu.actions()[1].setEnabled(False) self.action_open_containing_folder.setEnabled(False) - + self.action_sync.setEnabled(False) + + def device_job_exception(self, job): ''' Handle exceptions in threaded device jobs. ''' try: - if 'Could not read 32 bytes on the control bus.' in unicode(job.exception): - error_dialog(self, _('Error talking to device'), - _('There was a temporary error talking to the device. Please unplug and reconnect the device and or reboot.')).show() + if 'Could not read 32 bytes on the control bus.' in \ + unicode(job.exception): + error_dialog(self, _('Error talking to device'), + _('There was a temporary error talking to the ' + 'device. Please unplug and reconnect the device ' + 'and or reboot.')).show() return except: pass @@ -1370,12 +1313,16 @@ class Main(MainWindow, Ui_MainWindow): if not self.device_error_dialog.isVisible(): self.device_error_dialog.set_message(job.gui_text()) self.device_error_dialog.show() - + def job_exception(self, job): try: if job.exception[0] == 'DRMError': - error_dialog(self, _('Conversion Error'), - _('<p>Could not convert: %s<p>It is a <a href="%s">DRM</a>ed book. You must first remove the DRM using 3rd party tools.')%(job.description.split(':')[-1], 'http://wiki.mobileread.com/wiki/DRM')).exec_() + error_dialog(self, _('Conversion Error'), + _('<p>Could not convert: %s<p>It is a ' + '<a href="%s">DRM</a>ed book. You must first remove the ' + 'DRM using 3rd party tools.')%\ + (job.description.split(':')[-1], + 'http://wiki.mobileread.com/wiki/DRM')).exec_() return except: pass @@ -1404,11 +1351,12 @@ class Main(MainWindow, Ui_MainWindow): if iswindows: from calibre import plugins from PyQt4.Qt import QDir - base = plugins['winutil'][0].special_folder_path(plugins['winutil'][0].CSIDL_PERSONAL) + base = plugins['winutil'][0].special_folder_path( + plugins['winutil'][0].CSIDL_PERSONAL) if not base or not os.path.exists(base): base = unicode(QDir.homePath()).replace('/', os.sep) - dir = unicode(QFileDialog.getExistingDirectory(self, - _('Choose a location for your ebook library.'), base)) + dir = unicode(QFileDialog.getExistingDirectory(self, + _('Choose a location for your ebook library.'), base)) if not dir: dir = os.path.expanduser('~/Library') self.library_path = os.path.abspath(dir) @@ -1417,9 +1365,9 @@ class Main(MainWindow, Ui_MainWindow): os.makedirs(self.library_path) except: self.library_path = os.path.expanduser('~/Library') - error_dialog(self, _('Invalid library location'), + error_dialog(self, _('Invalid library location'), _('Could not access %s. Using %s as the library.')% - (repr(self.library_path), repr(self.library_path)) + (repr(self.library_path), repr(self.library_path)) ).exec_() os.makedirs(self.library_path) @@ -1431,16 +1379,19 @@ class Main(MainWindow, Ui_MainWindow): self.restoreGeometry(geometry) set_sidebar_directories(None) self.tool_bar.setIconSize(config['toolbar_icon_size']) - self.tool_bar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon if config['show_text_in_toolbar'] else Qt.ToolButtonIconOnly) - - + self.tool_bar.setToolButtonStyle( + Qt.ToolButtonTextUnderIcon if \ + config['show_text_in_toolbar'] else \ + Qt.ToolButtonIconOnly) + + def write_settings(self): config.set('main_window_geometry', self.saveGeometry()) dynamic.set('sort_column', self.library_view.model().sorted_on) self.library_view.write_settings() if self.device_connected: self.memory_view.write_settings() - + def quit(self, checked, restart=False): if not self.confirm_quit(): return @@ -1450,7 +1401,7 @@ class Main(MainWindow, Ui_MainWindow): pass self.restart_after_quit = restart QApplication.instance().quit() - + def donate(self, *args): BUTTON = ''' <form action="https://www.paypal.com/cgi-bin/webscr" method="post"> @@ -1460,7 +1411,9 @@ class Main(MainWindow, Ui_MainWindow): <img alt="" border="0" src="https://www.paypal.com/en_US/i/scr/pixel.gif" width="1" height="1" /> </form> ''' - MSG = _('is the result of the efforts of many volunteers from all over the world. If you find it useful, please consider donating to support its development.') + MSG = _('is the result of the efforts of many volunteers from all ' + 'over the world. If you find it useful, please consider ' + 'donating to support its development.') HTML = u''' <html> <head> @@ -1478,16 +1431,17 @@ class Main(MainWindow, Ui_MainWindow): pt.write(HTML.encode('utf-8')) pt.close() QDesktopServices.openUrl(QUrl.fromLocalFile(pt.name)) - - + + def confirm_quit(self): if self.job_manager.has_jobs(): msg = _('There are active jobs. Are you sure you want to quit?') if self.job_manager.has_device_jobs(): - msg = '<p>'+__appname__ + _(''' is communicating with the device!<br> + msg = '<p>'+__appname__ + \ + _(''' is communicating with the device!<br> 'Quitting may cause corruption on the device.<br> 'Are you sure you want to quit?''')+'</p>' - + d = QMessageBox(QMessageBox.Warning, _('WARNING: Active jobs'), msg, QMessageBox.Yes|QMessageBox.No, self) d.setIconPixmap(QPixmap(':/images/dialog_warning.svg')) @@ -1496,7 +1450,7 @@ class Main(MainWindow, Ui_MainWindow): return False return True - + def shutdown(self, write_settings=True): if write_settings: self.write_settings() @@ -1505,6 +1459,7 @@ class Main(MainWindow, Ui_MainWindow): self.cover_cache.stop() self.hide() self.cover_cache.terminate() + self.emailer.stop() try: try: if self.content_server is not None: @@ -1517,12 +1472,15 @@ class Main(MainWindow, Ui_MainWindow): self.hide() return True - + def closeEvent(self, e): self.write_settings() if self.system_tray_icon.isVisible(): if not dynamic['systray_msg'] and not isosx: - info_dialog(self, 'calibre', 'calibre '+_('will keep running in the system tray. To close it, choose <b>Quit</b> in the context menu of the system tray.')).exec_() + info_dialog(self, 'calibre', 'calibre '+\ + _('will keep running in the system tray. To close it, ' + 'choose <b>Quit</b> in the context menu of the ' + 'system tray.')).exec_() dynamic['systray_msg'] = True self.hide() e.ignore() @@ -1539,17 +1497,25 @@ class Main(MainWindow, Ui_MainWindow): def update_found(self, version): os = 'windows' if iswindows else 'osx' if isosx else 'linux' url = 'http://%s.kovidgoyal.net/download_%s'%(__appname__, os) - self.latest_version = _('<span style="color:red; font-weight:bold">Latest version: <a href="%s">%s</a></span>')%(url, version) - self.vanity.setText(self.vanity_template%(dict(version=self.latest_version, - device=self.device_info))) + self.latest_version = _('<span style="color:red; font-weight:bold">' + 'Latest version: <a href="%s">%s</a></span>')%(url, version) + self.vanity.setText(self.vanity_template%\ + (dict(version=self.latest_version, + device=self.device_info))) self.vanity.update() - if config.get('new_version_notification') and dynamic.get('update to version %s'%version, True): - d = question_dialog(self, _('Update available'), _('%s has been updated to version %s. See the <a href="http://calibre.kovidgoyal.net/wiki/Changelog">new features</a>. Visit the download page?')%(__appname__, version)) + if config.get('new_version_notification') and \ + dynamic.get('update to version %s'%version, True): + d = question_dialog(self, _('Update available'), + _('%s has been updated to version %s. ' + 'See the <a href="http://calibre.kovidgoyal.net/wiki/' + 'Changelog">new features</a>. Visit the download pa' + 'ge?')%(__appname__, version)) if d.exec_() == QMessageBox.Yes: - url = 'http://calibre.kovidgoyal.net/download_'+('windows' if iswindows else 'osx' if isosx else 'linux') + url = 'http://calibre.kovidgoyal.net/download_'+\ + ('windows' if iswindows else 'osx' if isosx else 'linux') QDesktopServices.openUrl(QUrl(url)) dynamic.set('update to version %s'%version, False) - + def option_parser(): parser = _option_parser('''\ @@ -1558,7 +1524,7 @@ def option_parser(): Launch the main calibre Graphical User Interface and optionally add the ebook at path_to_ebook to the database. ''') - parser.add_option('--with-library', default=None, action='store', + parser.add_option('--with-library', default=None, action='store', help=_('Use the library located at the specified path.')) parser.add_option('--start-in-tray', default=False, action='store_true', help=_('Start minimized to system tray.')) @@ -1568,7 +1534,7 @@ path_to_ebook to the database. def main(args=sys.argv): from calibre.utils.lock import singleinstance - + pid = os.fork() if False and islinux else -1 if pid <= 0: parser = option_parser() @@ -1581,17 +1547,20 @@ def main(args=sys.argv): app.setWindowIcon(QIcon(':/library')) QCoreApplication.setOrganizationName(ORG_NAME) QCoreApplication.setApplicationName(APP_UID) - single_instance = None if SingleApplication is None else SingleApplication('calibre GUI') + single_instance = None if SingleApplication is None else \ + SingleApplication('calibre GUI') if not singleinstance('calibre GUI'): if len(args) > 1: args[1] = os.path.abspath(args[1]) - if single_instance is not None and single_instance.is_running() and \ + if single_instance is not None and \ + single_instance.is_running() and \ single_instance.send_message('launched:'+repr(args)): - return 0 + return 0 extra = '' if iswindows else \ - ('If you\'re sure it is not running, delete the file %s.'%os.path.expanduser('~/.calibre_calibre GUI.lock')) + ('If you\'re sure it is not running, delete the file ' + '%s.'%os.path.expanduser('~/.calibre_calibre GUI.lock')) QMessageBox.critical(None, 'Cannot Start '+__appname__, - '<p>%s is already running. %s</p>'%(__appname__, extra)) + '<p>%s is already running. %s</p>'%(__appname__, extra)) return 1 initialize_file_icon_provider() main = Main(single_instance, opts, actions) @@ -1600,7 +1569,7 @@ def main(args=sys.argv): main.add_filesystem_book(args[1]) ret = app.exec_() if getattr(main, 'restart_after_quit', False): - e = sys.executable if getattr(sys, 'froze', False) else sys.argv[0] + e = sys.executable if getattr(sys, 'froze', False) else sys.argv[0] print 'Restarting with:', e, sys.argv os.execvp(e, sys.argv) else: @@ -1623,5 +1592,6 @@ if __name__ == '__main__': logfile = os.path.join(os.path.expanduser('~'), 'calibre.log') if os.path.exists(logfile): log = open(logfile).read().decode('utf-8', 'ignore') - d = QErrorMessage('<b>Error:</b>%s<br><b>Traceback:</b><br>%s<b>Log:</b><br>'%(unicode(err), unicode(tb), log)) + d = QErrorMessage(('<b>Error:</b>%s<br><b>Traceback:</b><br>' + '%s<b>Log:</b><br>')%(unicode(err), unicode(tb), log)) d.exec_() diff --git a/src/calibre/gui2/main.ui b/src/calibre/gui2/main.ui index c4bea8d886..fbae01d3e6 100644 --- a/src/calibre/gui2/main.ui +++ b/src/calibre/gui2/main.ui @@ -1,9 +1,8 @@ -<?xml version="1.0" encoding="UTF-8"?> -<ui version="4.0"> +<ui version="4.0" > <author>Kovid Goyal</author> <class>MainWindow</class> - <widget class="QMainWindow" name="MainWindow"> - <property name="geometry"> + <widget class="QMainWindow" name="MainWindow" > + <property name="geometry" > <rect> <x>0</x> <y>0</y> @@ -11,140 +10,149 @@ <height>822</height> </rect> </property> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <property name="sizePolicy" > + <sizepolicy vsizetype="Preferred" hsizetype="Preferred" > <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> - <property name="contextMenuPolicy"> + <property name="contextMenuPolicy" > <enum>Qt::NoContextMenu</enum> </property> - <property name="windowTitle"> + <property name="windowTitle" > <string>__appname__</string> </property> - <property name="windowIcon"> - <iconset resource="images.qrc"> + <property name="windowIcon" > + <iconset resource="images.qrc" > <normaloff>:/library</normaloff>:/library</iconset> </property> - <widget class="QWidget" name="centralwidget"> - <layout class="QGridLayout" name="gridLayout"> - <item row="0" column="0"> - <layout class="QHBoxLayout" name="horizontalLayout_3"> + <widget class="QWidget" name="centralwidget" > + <layout class="QGridLayout" name="gridLayout" > + <item row="0" column="0" > + <layout class="QHBoxLayout" name="horizontalLayout_3" > <item> - <widget class="LocationView" name="location_view"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <widget class="LocationView" name="location_view" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Expanding" hsizetype="Expanding" > <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> - <property name="maximumSize"> + <property name="maximumSize" > <size> <width>16777215</width> <height>100</height> </size> </property> - <property name="verticalScrollBarPolicy"> + <property name="verticalScrollBarPolicy" > <enum>Qt::ScrollBarAlwaysOff</enum> </property> - <property name="horizontalScrollBarPolicy"> + <property name="horizontalScrollBarPolicy" > <enum>Qt::ScrollBarAsNeeded</enum> </property> - <property name="tabKeyNavigation"> + <property name="editTriggers" > + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <property name="tabKeyNavigation" > <bool>true</bool> </property> - <property name="showDropIndicator" stdset="0"> + <property name="showDropIndicator" stdset="0" > <bool>true</bool> </property> - <property name="iconSize"> + <property name="selectionMode" > + <enum>QAbstractItemView::NoSelection</enum> + </property> + <property name="selectionBehavior" > + <enum>QAbstractItemView::SelectRows</enum> + </property> + <property name="iconSize" > <size> <width>40</width> <height>40</height> </size> </property> - <property name="movement"> + <property name="movement" > <enum>QListView::Static</enum> </property> - <property name="flow"> + <property name="flow" > <enum>QListView::LeftToRight</enum> </property> - <property name="gridSize"> + <property name="gridSize" > <size> <width>175</width> <height>90</height> </size> </property> - <property name="viewMode"> + <property name="viewMode" > <enum>QListView::ListMode</enum> </property> - <property name="wordWrap"> + <property name="wordWrap" > <bool>true</bool> </property> </widget> </item> <item> - <widget class="QToolButton" name="donate_button"> - <property name="cursor"> + <widget class="QToolButton" name="donate_button" > + <property name="cursor" > <cursorShape>PointingHandCursor</cursorShape> </property> - <property name="text"> + <property name="text" > <string>...</string> </property> - <property name="icon"> - <iconset resource="images.qrc"> + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/donate.svg</normaloff>:/images/donate.svg</iconset> </property> - <property name="iconSize"> + <property name="iconSize" > <size> <width>64</width> <height>64</height> </size> </property> - <property name="autoRaise"> + <property name="autoRaise" > <bool>true</bool> </property> </widget> </item> <item> - <layout class="QVBoxLayout" name="verticalLayout_3"> + <layout class="QVBoxLayout" name="verticalLayout_3" > <item> - <widget class="QLabel" name="vanity"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <widget class="QLabel" name="vanity" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Preferred" hsizetype="Preferred" > <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> - <property name="maximumSize"> + <property name="maximumSize" > <size> <width>16777215</width> <height>90</height> </size> </property> - <property name="text"> + <property name="text" > <string/> </property> - <property name="textFormat"> + <property name="textFormat" > <enum>Qt::RichText</enum> </property> - <property name="openExternalLinks"> + <property name="openExternalLinks" > <bool>true</bool> </property> </widget> </item> <item> - <layout class="QHBoxLayout" name="horizontalLayout_2"> + <layout class="QHBoxLayout" name="horizontalLayout_2" > <item> - <widget class="QLabel" name="label_2"> - <property name="text"> + <widget class="QLabel" name="label_2" > + <property name="text" > <string>Output:</string> </property> </widget> </item> <item> - <widget class="QComboBox" name="output_format"> - <property name="toolTip"> + <widget class="QComboBox" name="output_format" > + <property name="toolTip" > <string>Set the output format that is used when converting ebooks and downloading news</string> </property> </widget> @@ -155,99 +163,99 @@ </item> </layout> </item> - <item row="1" column="0"> - <layout class="QHBoxLayout"> - <property name="spacing"> + <item row="1" column="0" > + <layout class="QHBoxLayout" > + <property name="spacing" > <number>6</number> </property> - <property name="margin"> + <property name="margin" > <number>0</number> </property> <item> - <widget class="QToolButton" name="advanced_search_button"> - <property name="toolTip"> + <widget class="QToolButton" name="advanced_search_button" > + <property name="toolTip" > <string>Advanced search</string> </property> - <property name="text"> + <property name="text" > <string>...</string> </property> - <property name="icon"> - <iconset resource="images.qrc"> + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/search.svg</normaloff>:/images/search.svg</iconset> </property> - <property name="shortcut"> + <property name="shortcut" > <string>Alt+S</string> </property> </widget> </item> <item> - <widget class="QLabel" name="label"> - <property name="text"> + <widget class="QLabel" name="label" > + <property name="text" > <string>&Search:</string> </property> - <property name="buddy"> + <property name="buddy" > <cstring>search</cstring> </property> </widget> </item> <item> - <widget class="SearchBox" name="search"> - <property name="enabled"> + <widget class="SearchBox" name="search" > + <property name="enabled" > <bool>true</bool> </property> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <property name="sizePolicy" > + <sizepolicy vsizetype="Fixed" hsizetype="Expanding" > <horstretch>1</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> - <property name="acceptDrops"> + <property name="acceptDrops" > <bool>false</bool> </property> - <property name="toolTip"> - <string>Search the list of books by title or author<br><br>Words separated by spaces are ANDed</string> + <property name="toolTip" > + <string>Search the list of books by title or author<br><br>Words separated by spaces are ANDed</string> </property> - <property name="whatsThis"> - <string>Search the list of books by title, author, publisher, tags and comments<br><br>Words separated by spaces are ANDed</string> + <property name="whatsThis" > + <string>Search the list of books by title, author, publisher, tags and comments<br><br>Words separated by spaces are ANDed</string> </property> - <property name="autoFillBackground"> + <property name="autoFillBackground" > <bool>false</bool> </property> - <property name="text"> + <property name="text" > <string/> </property> - <property name="frame"> + <property name="frame" > <bool>true</bool> </property> </widget> </item> <item> - <widget class="QToolButton" name="clear_button"> - <property name="toolTip"> + <widget class="QToolButton" name="clear_button" > + <property name="toolTip" > <string>Reset Quick Search</string> </property> - <property name="text"> + <property name="text" > <string>...</string> </property> - <property name="icon"> - <iconset resource="images.qrc"> + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/clear_left.svg</normaloff>:/images/clear_left.svg</iconset> </property> </widget> </item> <item> - <widget class="Line" name="line"> - <property name="orientation"> + <widget class="Line" name="line" > + <property name="orientation" > <enum>Qt::Vertical</enum> </property> </widget> </item> <item> <spacer> - <property name="orientation"> + <property name="orientation" > <enum>Qt::Horizontal</enum> </property> - <property name="sizeHint" stdset="0"> + <property name="sizeHint" stdset="0" > <size> <width>20</width> <height>20</height> @@ -256,77 +264,77 @@ </spacer> </item> <item> - <widget class="QToolButton" name="config_button"> - <property name="toolTip"> + <widget class="QToolButton" name="config_button" > + <property name="toolTip" > <string>Configuration</string> </property> - <property name="text"> + <property name="text" > <string>...</string> </property> - <property name="icon"> - <iconset resource="images.qrc"> + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/config.svg</normaloff>:/images/config.svg</iconset> </property> </widget> </item> </layout> </item> - <item row="2" column="0"> - <widget class="QStackedWidget" name="stack"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <item row="2" column="0" > + <widget class="QStackedWidget" name="stack" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Expanding" hsizetype="Expanding" > <horstretch>100</horstretch> <verstretch>100</verstretch> </sizepolicy> </property> - <property name="currentIndex"> + <property name="currentIndex" > <number>0</number> </property> - <widget class="QWidget" name="library"> - <layout class="QVBoxLayout" name="verticalLayout_2"> + <widget class="QWidget" name="library" > + <layout class="QVBoxLayout" name="verticalLayout_2" > <item> - <layout class="QHBoxLayout" name="horizontalLayout"> + <layout class="QHBoxLayout" name="horizontalLayout" > <item> - <layout class="QVBoxLayout" name="verticalLayout"> + <layout class="QVBoxLayout" name="verticalLayout" > <item> - <widget class="QRadioButton" name="match_any"> - <property name="text"> + <widget class="QRadioButton" name="match_any" > + <property name="text" > <string>Match any</string> </property> - <property name="checked"> + <property name="checked" > <bool>false</bool> </property> </widget> </item> <item> - <widget class="QRadioButton" name="match_all"> - <property name="text"> + <widget class="QRadioButton" name="match_all" > + <property name="text" > <string>Match all</string> </property> - <property name="checked"> + <property name="checked" > <bool>true</bool> </property> </widget> </item> <item> - <widget class="QCheckBox" name="popularity"> - <property name="text"> + <widget class="QCheckBox" name="popularity" > + <property name="text" > <string>Sort by &popularity</string> </property> </widget> </item> <item> - <widget class="TagsView" name="tags_view"> - <property name="tabKeyNavigation"> + <widget class="TagsView" name="tags_view" > + <property name="tabKeyNavigation" > <bool>true</bool> </property> - <property name="alternatingRowColors"> + <property name="alternatingRowColors" > <bool>true</bool> </property> - <property name="animated"> + <property name="animated" > <bool>true</bool> </property> - <property name="headerHidden"> + <property name="headerHidden" > <bool>true</bool> </property> </widget> @@ -334,35 +342,35 @@ </layout> </item> <item> - <widget class="BooksView" name="library_view"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <widget class="BooksView" name="library_view" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Expanding" hsizetype="Expanding" > <horstretch>100</horstretch> <verstretch>10</verstretch> </sizepolicy> </property> - <property name="acceptDrops"> + <property name="acceptDrops" > <bool>true</bool> </property> - <property name="dragEnabled"> + <property name="dragEnabled" > <bool>true</bool> </property> - <property name="dragDropOverwriteMode"> + <property name="dragDropOverwriteMode" > <bool>false</bool> </property> - <property name="dragDropMode"> + <property name="dragDropMode" > <enum>QAbstractItemView::DragDrop</enum> </property> - <property name="alternatingRowColors"> + <property name="alternatingRowColors" > <bool>true</bool> </property> - <property name="selectionBehavior"> + <property name="selectionBehavior" > <enum>QAbstractItemView::SelectRows</enum> </property> - <property name="showGrid"> + <property name="showGrid" > <bool>false</bool> </property> - <property name="wordWrap"> + <property name="wordWrap" > <bool>false</bool> </property> </widget> @@ -371,76 +379,76 @@ </item> </layout> </widget> - <widget class="QWidget" name="main_memory"> - <layout class="QGridLayout"> - <item row="0" column="0"> - <widget class="DeviceBooksView" name="memory_view"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <widget class="QWidget" name="main_memory" > + <layout class="QGridLayout" > + <item row="0" column="0" > + <widget class="DeviceBooksView" name="memory_view" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Expanding" hsizetype="Expanding" > <horstretch>100</horstretch> <verstretch>10</verstretch> </sizepolicy> </property> - <property name="acceptDrops"> + <property name="acceptDrops" > <bool>true</bool> </property> - <property name="dragEnabled"> + <property name="dragEnabled" > <bool>true</bool> </property> - <property name="dragDropOverwriteMode"> + <property name="dragDropOverwriteMode" > <bool>false</bool> </property> - <property name="dragDropMode"> + <property name="dragDropMode" > <enum>QAbstractItemView::DragDrop</enum> </property> - <property name="alternatingRowColors"> + <property name="alternatingRowColors" > <bool>true</bool> </property> - <property name="selectionBehavior"> + <property name="selectionBehavior" > <enum>QAbstractItemView::SelectRows</enum> </property> - <property name="showGrid"> + <property name="showGrid" > <bool>false</bool> </property> - <property name="wordWrap"> + <property name="wordWrap" > <bool>false</bool> </property> </widget> </item> </layout> </widget> - <widget class="QWidget" name="page"> - <layout class="QGridLayout"> - <item row="0" column="0"> - <widget class="DeviceBooksView" name="card_view"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Preferred" vsizetype="Expanding"> + <widget class="QWidget" name="page" > + <layout class="QGridLayout" > + <item row="0" column="0" > + <widget class="DeviceBooksView" name="card_view" > + <property name="sizePolicy" > + <sizepolicy vsizetype="Expanding" hsizetype="Preferred" > <horstretch>10</horstretch> <verstretch>10</verstretch> </sizepolicy> </property> - <property name="acceptDrops"> + <property name="acceptDrops" > <bool>true</bool> </property> - <property name="dragEnabled"> + <property name="dragEnabled" > <bool>true</bool> </property> - <property name="dragDropOverwriteMode"> + <property name="dragDropOverwriteMode" > <bool>false</bool> </property> - <property name="dragDropMode"> + <property name="dragDropMode" > <enum>QAbstractItemView::DragDrop</enum> </property> - <property name="alternatingRowColors"> + <property name="alternatingRowColors" > <bool>true</bool> </property> - <property name="selectionBehavior"> + <property name="selectionBehavior" > <enum>QAbstractItemView::SelectRows</enum> </property> - <property name="showGrid"> + <property name="showGrid" > <bool>false</bool> </property> - <property name="wordWrap"> + <property name="wordWrap" > <bool>false</bool> </property> </widget> @@ -451,234 +459,225 @@ </item> </layout> </widget> - <widget class="QToolBar" name="tool_bar"> - <property name="minimumSize"> + <widget class="QToolBar" name="tool_bar" > + <property name="minimumSize" > <size> <width>0</width> <height>0</height> </size> </property> - <property name="contextMenuPolicy"> + <property name="contextMenuPolicy" > <enum>Qt::PreventContextMenu</enum> </property> - <property name="movable"> + <property name="movable" > <bool>false</bool> </property> - <property name="orientation"> + <property name="orientation" > <enum>Qt::Horizontal</enum> </property> - <property name="iconSize"> + <property name="iconSize" > <size> <width>48</width> <height>48</height> </size> </property> - <property name="toolButtonStyle"> + <property name="toolButtonStyle" > <enum>Qt::ToolButtonTextUnderIcon</enum> </property> - <attribute name="toolBarArea"> + <attribute name="toolBarArea" > <enum>TopToolBarArea</enum> </attribute> - <attribute name="toolBarBreak"> + <attribute name="toolBarBreak" > <bool>false</bool> </attribute> - <addaction name="action_add"/> - <addaction name="action_edit"/> - <addaction name="action_convert"/> - <addaction name="action_view"/> - <addaction name="action_news"/> - <addaction name="separator"/> - <addaction name="action_sync"/> - <addaction name="action_save"/> - <addaction name="action_del"/> - <addaction name="separator"/> - <addaction name="action_preferences"/> + <addaction name="action_add" /> + <addaction name="action_edit" /> + <addaction name="action_convert" /> + <addaction name="action_view" /> + <addaction name="action_news" /> + <addaction name="separator" /> + <addaction name="action_sync" /> + <addaction name="action_save" /> + <addaction name="action_del" /> + <addaction name="separator" /> + <addaction name="action_preferences" /> </widget> - <widget class="QStatusBar" name="statusBar"> - <property name="mouseTracking"> + <widget class="QStatusBar" name="statusBar" > + <property name="mouseTracking" > <bool>true</bool> </property> </widget> - <action name="action_add"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_add" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/add_book.svg</normaloff>:/images/add_book.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Add books</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>A</string> </property> - <property name="autoRepeat"> + <property name="autoRepeat" > <bool>false</bool> </property> </action> - <action name="action_del"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_del" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/trash.svg</normaloff>:/images/trash.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Remove books</string> </property> - <property name="toolTip"> + <property name="toolTip" > <string>Remove books</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>Del</string> </property> </action> - <action name="action_edit"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_edit" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/edit_input.svg</normaloff>:/images/edit_input.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Edit meta information</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>E</string> </property> - <property name="autoRepeat"> + <property name="autoRepeat" > <bool>false</bool> </property> </action> - <action name="action_sync"> - <property name="enabled"> + <action name="action_sync" > + <property name="enabled" > <bool>false</bool> </property> - <property name="icon"> - <iconset resource="images.qrc"> + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/sync.svg</normaloff>:/images/sync.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Send to device</string> </property> </action> - <action name="action_save"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_save" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/save.svg</normaloff>:/images/save.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Save to disk</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>S</string> </property> </action> - <action name="action_news"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_news" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/news.svg</normaloff>:/images/news.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Fetch news</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>F</string> </property> </action> - <action name="action_convert"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_convert" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/convert.svg</normaloff>:/images/convert.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Convert E-books</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>C</string> </property> </action> - <action name="action_view"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_view" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/view.svg</normaloff>:/images/view.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>View</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>V</string> </property> </action> - <action name="action_open_containing_folder"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_open_containing_folder" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/document_open.svg</normaloff>:/images/document_open.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Open containing folder</string> </property> </action> - <action name="action_show_book_details"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_show_book_details" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/dialog_information.svg</normaloff>:/images/dialog_information.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Show book details</string> </property> </action> - <action name="action_books_by_same_author"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_books_by_same_author" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/user_profile.svg</normaloff>:/images/user_profile.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Books by same author</string> </property> </action> - <action name="action_books_in_this_series"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_books_in_this_series" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/books_in_series.svg</normaloff>:/images/books_in_series.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Books in this series</string> </property> </action> - <action name="action_books_by_this_publisher"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_books_by_this_publisher" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/publisher.png</normaloff>:/images/publisher.png</iconset> </property> - <property name="text"> + <property name="text" > <string>Books by this publisher</string> </property> </action> - <action name="action_books_with_the_same_tags"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_books_with_the_same_tags" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/tags.svg</normaloff>:/images/tags.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Books with the same tags</string> </property> </action> - <action name="action_send_specific_format_to_device"> - <property name="icon"> - <iconset resource="images.qrc"> - <normaloff>:/images/book.svg</normaloff>:/images/book.svg</iconset> - </property> - <property name="text"> - <string>Send specific format to device</string> - </property> - </action> - <action name="action_preferences"> - <property name="icon"> - <iconset resource="images.qrc"> + <action name="action_preferences" > + <property name="icon" > + <iconset resource="images.qrc" > <normaloff>:/images/config.svg</normaloff>:/images/config.svg</iconset> </property> - <property name="text"> + <property name="text" > <string>Preferences</string> </property> - <property name="toolTip"> + <property name="toolTip" > <string>Configure calibre</string> </property> - <property name="shortcut"> + <property name="shortcut" > <string>Ctrl+P</string> </property> </action> @@ -711,7 +710,7 @@ </customwidget> </customwidgets> <resources> - <include location="images.qrc"/> + <include location="images.qrc" /> </resources> <connections> <connection> @@ -720,11 +719,11 @@ <receiver>search</receiver> <slot>clear()</slot> <hints> - <hint type="sourcelabel"> + <hint type="sourcelabel" > <x>787</x> <y>215</y> </hint> - <hint type="destinationlabel"> + <hint type="destinationlabel" > <x>755</x> <y>213</y> </hint> diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py index c6fa1723bd..4795341511 100644 --- a/src/calibre/gui2/widgets.py +++ b/src/calibre/gui2/widgets.py @@ -9,7 +9,7 @@ from PyQt4.QtGui import QListView, QIcon, QFont, QLabel, QListWidget, \ QSyntaxHighlighter, QCursor, QColor, QWidget, QDialog, \ QPixmap, QMovie from PyQt4.QtCore import QAbstractListModel, QVariant, Qt, SIGNAL, \ - QObject, QRegExp, QSettings, QSize + QRegExp, QSettings, QSize, QModelIndex from calibre.gui2.jobs2 import DetailView from calibre.gui2 import human_readable, NONE, TableView, \ @@ -22,7 +22,7 @@ from calibre.utils.config import prefs from calibre.gui2.dialogs.warning_ui import Ui_Dialog as Ui_WarningDialog class ProgressIndicator(QWidget): - + def __init__(self, *args): QWidget.__init__(self, *args) self.setGeometry(0, 0, 300, 350) @@ -37,7 +37,7 @@ class ProgressIndicator(QWidget): self.status.font().setBold(True) self.status.font().setPointSize(self.font().pointSize()+6) self.setVisible(False) - + def start(self, msg=''): view = self.parent() pwidth, pheight = view.size().width(), view.size().height() @@ -50,15 +50,15 @@ class ProgressIndicator(QWidget): self.status.setText(msg) self.setVisible(True) self.movie.setPaused(False) - + def stop(self): if self.movie.state() == self.movie.Running: self.movie.setPaused(True) self.setVisible(False) - + class WarningDialog(QDialog, Ui_WarningDialog): - + def __init__(self, title, msg, details, parent=None): QDialog.__init__(self, parent) self.setupUi(self) @@ -67,20 +67,20 @@ class WarningDialog(QDialog, Ui_WarningDialog): self.details.setText(details) class FilenamePattern(QWidget, Ui_Form): - + def __init__(self, parent): QWidget.__init__(self, parent) self.setupUi(self) - + self.connect(self.test_button, SIGNAL('clicked()'), self.do_test) self.connect(self.re, SIGNAL('returnPressed()'), self.do_test) self.re.setText(prefs['filename_pattern']) - + def do_test(self): try: pat = self.pattern() except Exception, err: - error_dialog(self, _('Invalid regular expression'), + error_dialog(self, _('Invalid regular expression'), _('Invalid regular expression: %s')%err).exec_() return mi = metadata_from_filename(qstring_to_unicode(self.filename.text()), pat) @@ -92,49 +92,49 @@ class FilenamePattern(QWidget, Ui_Form): self.authors.setText(', '.join(mi.authors)) else: self.authors.setText(_('No match')) - + if mi.series: self.series.setText(mi.series) else: self.series.setText(_('No match')) - + if mi.series_index is not None: self.series_index.setText(str(mi.series_index)) else: self.series_index.setText(_('No match')) - + self.isbn.setText(_('No match') if mi.isbn is None else str(mi.isbn)) - - + + def pattern(self): pat = qstring_to_unicode(self.re.text()) return re.compile(pat) - + def commit(self): pat = self.pattern().pattern prefs['filename_pattern'] = pat return pat - - - + + + class ImageView(QLabel): - + MAX_WIDTH = 400 MAX_HEIGHT = 300 DROPABBLE_EXTENSIONS = ('jpg', 'jpeg', 'gif', 'png', 'bmp') - + @classmethod def paths_from_event(cls, event): - ''' - Accept a drop event and return a list of paths that can be read from + ''' + Accept a drop event and return a list of paths that can be read from and represent files with extensions. ''' if event.mimeData().hasFormat('text/uri-list'): urls = [qstring_to_unicode(u.toLocalFile()) for u in event.mimeData().urls()] urls = [u for u in urls if os.path.splitext(u)[1] and os.access(u, os.R_OK)] return [u for u in urls if os.path.splitext(u)[1][1:].lower() in cls.DROPABBLE_EXTENSIONS] - + def dragEnterEvent(self, event): if int(event.possibleActions() & Qt.CopyAction) + \ int(event.possibleActions() & Qt.MoveAction) == 0: @@ -142,7 +142,7 @@ class ImageView(QLabel): paths = self.paths_from_event(event) if paths: event.acceptProposedAction() - + def dropEvent(self, event): paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) @@ -154,19 +154,19 @@ class ImageView(QLabel): event.accept() self.emit(SIGNAL('cover_changed()'), paths, Qt.QueuedConnection) break - + def dragMoveEvent(self, event): event.acceptProposedAction() - + def setPixmap(self, pixmap): QLabel.setPixmap(self, pixmap) width, height = fit_image(pixmap.width(), pixmap.height(), self.MAX_WIDTH, self.MAX_HEIGHT)[1:] self.setMaximumWidth(width) - self.setMaximumHeight(height) + self.setMaximumHeight(height) + - class LocationModel(QAbstractListModel): - + def __init__(self, parent): QAbstractListModel.__init__(self, parent) self.icons = [QVariant(QIcon(':/library')), @@ -182,19 +182,19 @@ class LocationModel(QAbstractListModel): _('Click to see the list of books available on your computer'), _('Click to see the list of books in the main memory of your reader'), _('Click to see the list of books on the storage card in your reader') - ] - + ] + def rowCount(self, parent): return 1 + sum([1 for i in self.free if i >= 0]) - + def data(self, index, role): - row = index.row() + row = index.row() data = NONE if role == Qt.DisplayRole: text = self.text[row]%(human_readable(self.free[row-1])) if row > 0 \ else self.text[row]%self.count data = QVariant(text) - elif role == Qt.DecorationRole: + elif role == Qt.DecorationRole: data = self.icons[row] elif role == Qt.ToolTipRole: data = QVariant(self.tooltips[row]) @@ -205,59 +205,62 @@ class LocationModel(QAbstractListModel): font.setBold(row == self.highlight_row) data = QVariant(font) return data - + def headerData(self, section, orientation, role): return NONE - + def update_devices(self, cp=None, fs=[-1, -1, -1]): self.free[0] = fs[0] self.free[1] = max(fs[1:]) if cp == None: self.free[1] = -1 - self.reset() - - def location_changed(self, row): - self.highlight_row = row self.reset() + def location_changed(self, row): + self.highlight_row = row + self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), + self.index(0), self.index(self.rowCount(QModelIndex())-1)) + class LocationView(QListView): - + def __init__(self, parent): QListView.__init__(self, parent) self.setModel(LocationModel(self)) self.reset() - QObject.connect(self.selectionModel(), SIGNAL('currentChanged(QModelIndex, QModelIndex)'), self.current_changed) - self.setCursor(Qt.PointingHandCursor) - + self.setCursor(Qt.PointingHandCursor) + self.currentChanged = self.current_changed + def count_changed(self, new_count): self.model().count = new_count self.model().reset() - + def current_changed(self, current, previous): - i = current.row() - location = 'library' if i == 0 else 'main' if i == 1 else 'card' - self.emit(SIGNAL('location_selected(PyQt_PyObject)'), location) - + if current.isValid(): + i = current.row() + location = 'library' if i == 0 else 'main' if i == 1 else 'card' + self.emit(SIGNAL('location_selected(PyQt_PyObject)'), location) + self.model().location_changed(i) + def location_changed(self, row): if 0 <= row and row <= 2: self.model().location_changed(row) - + class JobsView(TableView): - + def __init__(self, parent): TableView.__init__(self, parent) self.connect(self, SIGNAL('doubleClicked(QModelIndex)'), self.show_details) - + def show_details(self, index): row = index.row() job = self.model().row_to_job(row) d = DetailView(self, job) self.connect(self.model(), SIGNAL('output_received()'), d.update) d.exec_() - + class FontFamilyModel(QAbstractListModel): - + def __init__(self, *args): QAbstractListModel.__init__(self, *args) try: @@ -268,10 +271,10 @@ class FontFamilyModel(QAbstractListModel): traceback.print_exc() self.families.sort() self.families[:0] = ['None'] - + def rowCount(self, *args): return len(self.families) - + def data(self, index, role): try: family = self.families[index.row()] @@ -283,52 +286,52 @@ class FontFamilyModel(QAbstractListModel): if role == Qt.FontRole: return QVariant(QFont(family)) return NONE - + def index_of(self, family): return self.families.index(family.strip()) - + class BasicListItem(QListWidgetItem): - + def __init__(self, text, user_data=None): QListWidgetItem.__init__(self, text) self.user_data = user_data - + def __eq__(self, other): if hasattr(other, 'text'): return self.text() == other.text() return False class BasicList(QListWidget): - + def add_item(self, text, user_data=None, replace=False): item = BasicListItem(text, user_data) - + for oitem in self.items(): if oitem == item: if replace: self.takeItem(self.row(oitem)) else: raise ValueError('Item already in list') - + self.addItem(item) - + def remove_selected_items(self, *args): for item in self.selectedItems(): self.takeItem(self.row(item)) - + def items(self): for i in range(self.count()): yield self.item(i) - + class PythonHighlighter(QSyntaxHighlighter): Rules = [] Formats = {} Config = {} - + KEYWORDS = ["and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except", "exec", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "not", "or", @@ -344,7 +347,7 @@ class PythonHighlighter(QSyntaxHighlighter): "open", "ord", "pow", "property", "range", "reduce", "repr", "reversed", "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super", "tuple", "type", "unichr", - "unicode", "vars", "xrange", "zip"] + "unicode", "vars", "xrange", "zip"] CONSTANTS = ["False", "True", "None", "NotImplemented", "Ellipsis"] @@ -353,7 +356,7 @@ class PythonHighlighter(QSyntaxHighlighter): super(PythonHighlighter, self).__init__(parent) if not self.Config: self.loadConfig() - + self.initializeFormats() @@ -392,7 +395,7 @@ class PythonHighlighter(QSyntaxHighlighter): if value.isEmpty(): value = default Config[name] = value - + for name in ("window", "shell"): Config["%swidth" % name] = settings.value("%swidth" % name, QVariant(QApplication.desktop() \ @@ -421,9 +424,9 @@ class PythonHighlighter(QSyntaxHighlighter): sys.stdout = codecs.getwriter("UTF8")(sys.stdout)""") setDefaultString("newfile", """\ #!/usr/bin/env python - + from __future__ import division - + import sys """) Config["backupsuffix"] = settings.value("backupsuffix", @@ -435,7 +438,7 @@ class PythonHighlighter(QSyntaxHighlighter): QVariant(150)).toInt()[0] Config["maxlinestoscan"] = settings.value("maxlinestoscan", QVariant(5000)).toInt()[0] - Config["pythondocpath"] = settings.value("pythondocpath", + Config["pythondocpath"] = settings.value("pythondocpath", QVariant("http://docs.python.org")).toString() Config["autohidefinddialog"] = settings.value("autohidefinddialog", QVariant(True)).toBool() @@ -548,7 +551,7 @@ class PythonHighlighter(QSyntaxHighlighter): if i == -1: i = text.length() self.setCurrentBlockState(state) - self.setFormat(0, i + 3, + self.setFormat(0, i + 3, PythonHighlighter.Formats["string"]) elif i > -1: self.setCurrentBlockState(state) diff --git a/src/calibre/libunrar.py b/src/calibre/libunrar.py index 96ba08cea5..22f03731e8 100644 --- a/src/calibre/libunrar.py +++ b/src/calibre/libunrar.py @@ -25,7 +25,8 @@ if iswindows: else: Structure = _Structure if hasattr(sys, 'frozen') and iswindows: - _libunrar = cdll.LoadLibrary(os.path.join(os.path.dirname(sys.executable), 'unrar.dll')) + _libunrar = cdll.LoadLibrary(os.path.join(os.path.dirname(sys.executable), + 'unrar.dll')) _libunrar = load_library(_librar_name, cdll) RAR_OM_LIST = 0 @@ -95,7 +96,7 @@ class RARHeaderDataEx(Structure): # Define a callback function #CALLBACK_FUNC = CFUNCTYPE(c_int, c_uint, c_long, c_char_p, c_long) -#def py_callback_func(msg, user_data, p1, p2): +#def py_callback_func(msg, user_data, p1, p2): # return 0 #callback_func = CALLBACK_FUNC(py_callback_func) @@ -123,7 +124,7 @@ def _interpret_open_error(code, path): elif code == ERAR_EOPEN: msg = 'Cannot open ' + path return msg - + def _interpret_process_file_error(code): msg = 'Unknown Error' if code == ERAR_UNKNOWN_FORMAT: @@ -145,7 +146,7 @@ def _interpret_process_file_error(code): elif code == ERAR_MISSING_PASSWORD: msg = 'Password is required.' return msg - + def get_archive_info(flags): ios = StringIO() print >>ios, 'Volume:\t\t', 'yes' if (flags & 1) else 'no' @@ -162,7 +163,7 @@ def get_archive_info(flags): def extract(path, dir): """ Extract archive C{filename} into directory C{dir} - """ + """ open_archive_data = RAROpenArchiveDataEx(ArcName=path, OpenMode=RAR_OM_EXTRACT, CmtBuf=None) arc_data = _libunrar.RAROpenArchiveEx(byref(open_archive_data)) cwd = os.getcwd() @@ -173,7 +174,7 @@ def extract(path, dir): if open_archive_data.OpenResult != 0: raise UnRARException(_interpret_open_error(open_archive_data.OpenResult, path)) print 'Archive:', path - #print get_archive_info(open_archive_data.Flags) + #print get_archive_info(open_archive_data.Flags) header_data = RARHeaderDataEx(CmtBuf=None) #_libunrar.RARSetCallback(arc_data, callback_func, mode) while True: @@ -240,5 +241,5 @@ def extract_member(path, match=re.compile(r'\.(jpg|jpeg|gif|png)\s*$', re.I), na open(os.path.join(dir, *header_data.FileNameW.split('/')), 'rb').read() finally: _libunrar.RARCloseArchive(arc_data) - - + + diff --git a/src/calibre/linux.py b/src/calibre/linux.py index 949bb3160b..afb8a6e892 100644 --- a/src/calibre/linux.py +++ b/src/calibre/linux.py @@ -61,14 +61,15 @@ entry_points = { 'comic2lrf = calibre.ebooks.lrf.comic.convert_from:main', 'comic2epub = calibre.ebooks.epub.from_comic:main', 'comic2mobi = calibre.ebooks.mobi.from_comic:main', - 'comic2pdf = calibre.ebooks.pdf.from_comic:main', + 'comic2pdf = calibre.ebooks.pdf.from_comic:main', 'calibre-debug = calibre.debug:main', 'calibredb = calibre.library.cli:main', 'calibre-fontconfig = calibre.utils.fontconfig:main', 'calibre-parallel = calibre.parallel:main', - 'calibre-customize = calibre.customize.ui:main', - 'pdftrim = calibre.ebooks.pdf.pdftrim:main' , + 'calibre-customize = calibre.customize.ui:main', + 'pdftrim = calibre.ebooks.pdf.pdftrim:main' , 'fetch-ebook-metadata = calibre.ebooks.metadata.fetch:main', + 'calibre-smtp = calibre.utils.smtp:main', ], 'gui_scripts' : [ __appname__+' = calibre.gui2.main:main', @@ -198,6 +199,7 @@ def setup_completion(fatal_errors): from calibre.ebooks.metadata.fetch import option_parser as fem_op from calibre.ebooks.mobi.writer import option_parser as oeb2mobi from calibre.gui2.main import option_parser as guiop + from calibre.utils.email import option_parser as smtp_op any_formats = ['epub', 'htm', 'html', 'xhtml', 'xhtm', 'rar', 'zip', 'txt', 'lit', 'rtf', 'pdf', 'prc', 'mobi', 'fb2', 'odt'] f = open_file('/etc/bash_completion.d/libprs500') @@ -246,6 +248,7 @@ def setup_completion(fatal_errors): f.write(opts_and_words('feeds2epub', feeds2epub, feed_titles)) f.write(opts_and_words('feeds2mobi', feeds2mobi, feed_titles)) f.write(opts_and_words('fetch-ebook-metadata', fem_op, [])) + f.write(opts_and_words('calibre-smtp', smtp_op, [])) f.write(opts_and_exts('html2epub', html2epub, ['html', 'htm', 'xhtm', 'xhtml', 'opf'])) f.write(opts_and_exts('html2oeb', html2oeb, ['html', 'htm', 'xhtm', 'xhtml'])) f.write(opts_and_exts('odt2oeb', odt2oeb, ['odt'])) diff --git a/src/calibre/parallel.py b/src/calibre/parallel.py index 9a812ff028..c827bfd1af 100644 --- a/src/calibre/parallel.py +++ b/src/calibre/parallel.py @@ -45,7 +45,7 @@ PARALLEL_FUNCS = { ('calibre.gui2.lrf_renderer.main', 'main', {}, None), 'ebook-viewer' : - ('calibre.gui2.viewer.main', 'main', {}, None), + ('calibre.gui2.viewer.main', 'main', {}, None), 'feeds2lrf' : ('calibre.ebooks.lrf.feeds.convert_from', 'main', {}, 'notification'), diff --git a/src/calibre/ptempfile.py b/src/calibre/ptempfile.py index 8a1cac4a54..f850820749 100644 --- a/src/calibre/ptempfile.py +++ b/src/calibre/ptempfile.py @@ -13,7 +13,7 @@ def cleanup(path): try: import os if os.path.exists(path): - os.remove(path) + os.remove(path) except: pass diff --git a/src/calibre/trac/plugins/download.py b/src/calibre/trac/plugins/download.py index 014a26bff3..a050cb6ea2 100644 --- a/src/calibre/trac/plugins/download.py +++ b/src/calibre/trac/plugins/download.py @@ -18,6 +18,7 @@ DEPENDENCIES = [ ('lxml', '2.1.5', 'lxml', 'python-lxml', 'python-lxml'), ('python-dateutil', '1.4.1', 'python-dateutil', 'python-dateutil', 'python-dateutil'), ('BeautifulSoup', '3.0.5', 'beautifulsoup', 'python-beautifulsoup', 'python-BeautifulSoup'), + ('dnspython', '1.6.0', 'dnspython', 'dnspython', 'dnspython', 'dnspython'), ] diff --git a/src/calibre/utils/smtp.py b/src/calibre/utils/smtp.py new file mode 100644 index 0000000000..04a4231e13 --- /dev/null +++ b/src/calibre/utils/smtp.py @@ -0,0 +1,232 @@ +from __future__ import with_statement +__license__ = 'GPL 3' +__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>' +__docformat__ = 'restructuredtext en' +''' +This module implements a simple commandline SMTP client that supports: + + * Delivery via an SMTP relay with SSL or TLS + * Background delivery with failures being saved in a maildir mailbox +''' + +import sys, traceback, os +from email import encoders + +def create_mail(from_, to, subject, text=None, attachment_data=None, + attachment_type=None, attachment_name=None): + assert text or attachment_data + + from email.mime.multipart import MIMEMultipart + + outer = MIMEMultipart() + outer['Subject'] = subject + outer['To'] = to + outer['From'] = from_ + outer.preamble = 'You will not see this in a MIME-aware mail reader.\n' + + if text is not None: + from email.mime.text import MIMEText + msg = MIMEText(text) + outer.attach(msg) + + if attachment_data is not None: + from email.mime.base import MIMEBase + assert attachment_data and attachment_name + maintype, subtype = attachment_type.split('/', 1) + msg = MIMEBase(maintype, subtype) + msg.set_payload(attachment_data) + encoders.encode_base64(msg) + msg.add_header('Content-Disposition', 'attachment', + filename=attachment_name) + outer.attach(msg) + + return outer.as_string() + +def get_mx(host): + import dns.resolver + answers = list(dns.resolver.query(host, 'MX')) + answers.sort(cmp=lambda x, y: cmp(int(x.preference), int(y.preference))) + return [str(x.exchange) for x in answers] + +def sendmail_direct(from_, to, msg, timeout, localhost, verbose): + import smtplib + s = smtplib.SMTP(timeout=timeout, local_hostname=localhost) + s.set_debuglevel(verbose) + hosts = get_mx(to.split('@')[-1].strip()) + if not hosts: + raise ValueError('No mail server found for address: %s'%to) + last_error = last_traceback = None + for host in hosts: + try: + s.connect(host, 25) + s.sendmail(from_, [to], msg) + return s.quit() + except Exception, e: + last_error, last_traceback = e, traceback.format_exc() + if last_error is not None: + print last_traceback + raise IOError('Failed to send mail: '+repr(last_error)) + + +def sendmail(msg, from_, to, localhost=None, verbose=0, timeout=30, + relay=None, username=None, password=None, encryption='TLS', + port=-1): + if relay is None: + for x in to: + return sendmail_direct(from_, x, msg, timeout, localhost, verbose) + import smtplib + cls = smtplib.SMTP if encryption == 'TLS' else smtplib.SMTP_SSL + s = cls(timeout=timeout, local_hostname=localhost) + s.set_debuglevel(verbose) + if port < 0: + port = 25 if encryption == 'TLS' else 465 + s.connect(relay, port) + if encryption == 'TLS': + s.starttls() + s.ehlo() + if username is not None and password is not None: + s.login(username, password) + s.sendmail(from_, to, msg) + return s.quit() + +def option_parser(): + try: + from calibre.utils.config import OptionParser + OptionParser + except ImportError: + from optparse import OptionParser + import textwrap + parser = OptionParser(textwrap.dedent('''\ + %prog [options] [from to text] + + Send mail using the SMTP protocol. %prog has two modes of operation. In the + compose mode you specify from to and text and these are used to build and + send an email message. In the filter mode, %prog reads a complete email + message from STDIN and sends it. + + text is the body of the email message. + If text is not specified, a complete email message is read from STDIN. + from is the email address of the sender and to is the email address + of the recipient. When a complete email is read from STDIN, from and to + are only used in the SMTP negotiation, the message headers are not modified. + ''')) + c=parser.add_option_group('COMPOSE MAIL', + 'Options to compose an email. Ignored if text is not specified').add_option + c('-a', '--attachment', help='File to attach to the email') + c('-s', '--subject', help='Subject of the email') + + parser.add_option('-l', '--localhost', + help=('Host name of localhost. Used when connecting ' + 'to SMTP server.')) + r=parser.add_option_group('SMTP RELAY', + 'Options to use an SMTP relay server to send mail. ' + '%prog will try to send the email directly unless --relay is ' + 'specified.').add_option + r('-r', '--relay', help=('An SMTP relay server to use to send mail.')) + r('-p', '--port', default=-1, + help='Port to connect to on relay server. Default is to use 465 if ' + 'encryption method is SSL and 25 otherwise.') + r('-u', '--username', help='Username for relay') + r('-p', '--password', help='Password for relay') + r('-e', '--encryption-method', default='TLS', + choices=['TLS', 'SSL'], + help='Encryption method to use when connecting to relay. Choices are ' + 'TLS and SSL. Default is TLS.') + parser.add_option('-o', '--outbox', help='Path to maildir folder to store ' + 'failed email messages in.') + parser.add_option('-f', '--fork', default=False, action='store_true', + help='Fork and deliver message in background. ' + 'If you use this option, you should also use --outbox ' + 'to handle delivery failures.') + parser.add_option('-t', '--timeout', help='Timeout for connection') + parser.add_option('-v', '--verbose', default=0, action='count', + help='Be more verbose') + return parser + +def extract_email_address(raw): + from email.utils import parseaddr + return parseaddr(raw)[-1] + +def compose_mail(from_, to, text, subject=None, attachment=None, + attachment_name=None): + attachment_type = attachment_data = None + if attachment is not None: + try: + from calibre import guess_type + guess_type + except ImportError: + from mimetypes import guess_type + attachment_data = attachment.read() if hasattr(attachment, 'read') \ + else open(attachment, 'rb').read() + attachment_type = guess_type(getattr(attachment, 'name', attachment))[0] + if attachment_name is None: + attachment_name = os.path.basename(getattr(attachment, + 'name', attachment)) + subject = subject if subject else 'no subject' + return create_mail(from_, to, subject, text=text, + attachment_data=attachment_data, attachment_type=attachment_type, + attachment_name=attachment_name) + +def main(args=sys.argv): + parser = option_parser() + opts, args = parser.parse_args(args) + + + if len(args) > 1: + msg = compose_mail(args[1], args[2], args[3], subject=opts.subject, + attachment=opts.attachment) + from_, to = args[1:3] + efrom, eto = map(extract_email_address, (from_, to)) + eto = [eto] + else: + msg = sys.stdin.read() + from email.parser import Parser + from email.utils import getaddresses + eml = Parser.parsestr(msg, headersonly=True) + tos = eml.get_all('to', []) + ccs = eml.get_all('cc', []) + eto = getaddresses(tos + ccs) + if not eto: + raise ValueError('Email from STDIN does not specify any recipients') + efrom = getaddresses(eml.get_all('from', [])) + if not efrom: + raise ValueError('Email from STDIN does not specify a sender') + efrom = efrom[0] + + + outbox = None + if opts.outbox is not None: + outbox = os.path.abspath(os.path.expanduser(opts.outbox)) + from mailbox import Maildir + outbox = Maildir(opts.outbox, factory=None) + if opts.fork: + if os.fork() != 0: + return 0 + try: + sendmail(msg, efrom, eto, localhost=opts.localhost, verbose=opts.verbose, + timeout=opts.timeout, relay=opts.relay, username=opts.username, + password=opts.password, port=opts.port, + encryption=opts.encryption_method) + except: + if outbox is not None: + outbox.add(msg) + print 'Delivery failed. Message saved to', opts.outbox + raise + return 0 + +def config(defaults=None): + from calibre.utils.config import Config, StringConfig + desc = _('Control email delivery') + c = Config('smtp',desc) if defaults is None else StringConfig(defaults,desc) + c.add_opt('from_') + c.add_opt('accounts', default={}) + c.add_opt('relay_host') + c.add_opt('relay_port', default=25) + c.add_opt('relay_username') + c.add_opt('relay_password') + c.add_opt('encryption', default='TLS', choices=['TLS', 'SSL']) + return c + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/calibre/web/feeds/recipes/recipe_spiegelde.py b/src/calibre/web/feeds/recipes/recipe_spiegelde.py index 2bcb308695..0b1503f944 100644 --- a/src/calibre/web/feeds/recipes/recipe_spiegelde.py +++ b/src/calibre/web/feeds/recipes/recipe_spiegelde.py @@ -43,6 +43,5 @@ class SpeigelOnline(BasicNewsRecipe): for y in reversed(soup.contents): x.contents[0].insert(0, y) soup = x - print 1111111 return soup diff --git a/upload.py b/upload.py index 001e34041a..0834adceeb 100644 --- a/upload.py +++ b/upload.py @@ -40,11 +40,11 @@ except: HOST=get_ip_address('wlan0') except: HOST='unknown' - + def newer(targets, sources): ''' Return True is sources is newer that targets or if targets - does not exist. + does not exist. ''' for f in targets: if not os.path.exists(f): @@ -53,22 +53,22 @@ def newer(targets, sources): stimes = map(lambda x: os.stat(x).st_mtime, sources) newest_source, oldest_target = max(stimes), min(ttimes) return newest_source > oldest_target - + class OptionlessCommand(Command): user_options = [] def initialize_options(self): pass def finalize_options(self): pass - + def run(self): for cmd_name in self.get_sub_commands(): self.run_command(cmd_name) class sdist(OptionlessCommand): - + description = 'Create a source distribution using bzr' - + def run(self): name = os.path.join('dist', '%s-%s.tar.gz'%(__appname__, __version__)) check_call(('bzr export '+name).split()) @@ -77,9 +77,9 @@ class sdist(OptionlessCommand): class pot(OptionlessCommand): description = '''Create the .pot template for all translatable strings''' - + PATH = os.path.join('src', __appname__, 'translations') - + def source_files(self): ans = [] for root, _, files in os.walk(os.path.dirname(self.PATH)): @@ -88,7 +88,7 @@ class pot(OptionlessCommand): ans.append(os.path.abspath(os.path.join(root, name))) return ans - + def run(self): sys.path.insert(0, os.path.abspath(self.PATH)) try: @@ -109,7 +109,7 @@ class pot(OptionlessCommand): sys.path.remove(os.path.abspath(self.PATH)) class manual(OptionlessCommand): - + description='''Build the User Manual ''' def run(self): @@ -122,20 +122,20 @@ class manual(OptionlessCommand): os.makedirs(d) if not os.path.exists('.build'+os.sep+'html'): os.makedirs('.build'+os.sep+'html') - check_call(['sphinx-build', '-b', 'custom', '-d', + check_call(['sphinx-build', '-b', 'custom', '-d', '.build/doctrees', '.', '.build/html']) finally: os.chdir(cwd) - + @classmethod def clean(cls): path = os.path.join('src', 'calibre', 'manual', '.build') if os.path.exists(path): shutil.rmtree(path) - + class resources(OptionlessCommand): description='''Compile various resource files used in calibre. ''' - + RESOURCES = dict( opf_template = 'ebooks/metadata/opf.xml', ncx_template = 'ebooks/metadata/ncx.xml', @@ -145,9 +145,9 @@ class resources(OptionlessCommand): jquery_scrollTo = 'gui2/viewer/jquery_scrollTo.js', html_css = 'ebooks/oeb/html.css', ) - + DEST = os.path.join('src', __appname__, 'resources.py') - + def get_qt_translations(self): data = {} translations_found = False @@ -162,7 +162,7 @@ class resources(OptionlessCommand): if not translations_found: print 'WARNING: Could not find Qt transations' return data - + def get_static_resources(self): sdir = os.path.join('src', 'calibre', 'library', 'static') resources, max = {}, 0 @@ -171,7 +171,7 @@ class resources(OptionlessCommand): mtime = os.stat(os.path.join(sdir, f)).st_mtime max = mtime if mtime > max else max return resources, max - + def get_recipes(self): sdir = os.path.join('src', 'calibre', 'web', 'feeds', 'recipes') resources, max = {}, 0 @@ -181,7 +181,7 @@ class resources(OptionlessCommand): mtime = os.stat(os.path.join(sdir, f)).st_mtime max = mtime if mtime > max else max return resources, max - + def run(self): data, dest, RESOURCES = {}, self.DEST, self.RESOURCES for key in RESOURCES: @@ -204,7 +204,7 @@ class resources(OptionlessCommand): f.write('build_time = "%s"\n\n'%time.strftime('%d %m %Y %H%M%S')) else: print 'Resources are up to date' - + @classmethod def clean(cls): path = cls.DEST @@ -216,7 +216,7 @@ class translations(OptionlessCommand): description='''Compile the translations''' PATH = os.path.join('src', __appname__, 'translations') DEST = os.path.join(PATH, 'compiled.py') - + def run(self): sys.path.insert(0, os.path.abspath(self.PATH)) try: @@ -236,21 +236,21 @@ class translations(OptionlessCommand): print 'Translations up to date' finally: sys.path.remove(os.path.abspath(self.PATH)) - - + + @classmethod def clean(cls): path = cls.DEST if os.path.exists(path): os.remove(path) - + class gui(OptionlessCommand): description='''Compile all GUI forms and images''' PATH = os.path.join('src', __appname__, 'gui2') IMAGES_DEST = os.path.join(PATH, 'images_rc.py') QRC = os.path.join(PATH, 'images.qrc') - + @classmethod def find_forms(cls): forms = [] @@ -258,17 +258,17 @@ class gui(OptionlessCommand): for name in files: if name.endswith('.ui'): forms.append(os.path.abspath(os.path.join(root, name))) - + return forms - + @classmethod def form_to_compiled_form(cls, form): return form.rpartition('.')[0]+'_ui.py' - + def run(self): self.build_forms() self.build_images() - + def build_images(self): cwd, images = os.getcwd(), os.path.basename(self.IMAGES_DEST) try: @@ -296,13 +296,13 @@ class gui(OptionlessCommand): print 'Images are up to date' finally: os.chdir(cwd) - - + + def build_forms(self): from PyQt4.uic import compileUi forms = self.find_forms() for form in forms: - compiled_form = self.form_to_compiled_form(form) + compiled_form = self.form_to_compiled_form(form) if not os.path.exists(compiled_form) or os.stat(form).st_mtime > os.stat(compiled_form).st_mtime: print 'Compiling form', form buf = cStringIO.StringIO() @@ -313,21 +313,21 @@ class gui(OptionlessCommand): dat = dat.replace('from library import', 'from calibre.gui2.library import') dat = dat.replace('from widgets import', 'from calibre.gui2.widgets import') dat = re.compile(r'QtGui.QApplication.translate\(.+?,\s+"(.+?)(?<!\\)",.+?\)', re.DOTALL).sub(r'_("\1")', dat) - + # Workaround bug in Qt 4.4 on Windows if form.endswith('dialogs%sconfig.ui'%os.sep) or form.endswith('dialogs%slrf_single.ui'%os.sep): print 'Implementing Workaround for buggy pyuic in form', form - dat = re.sub(r'= QtGui\.QTextEdit\(self\..*?\)', '= QtGui.QTextEdit()', dat) + dat = re.sub(r'= QtGui\.QTextEdit\(self\..*?\)', '= QtGui.QTextEdit()', dat) dat = re.sub(r'= QtGui\.QListWidget\(self\..*?\)', '= QtGui.QListWidget()', dat) - + if form.endswith('viewer%smain.ui'%os.sep): print 'Promoting WebView' dat = dat.replace('self.view = QtWebKit.QWebView(', 'self.view = DocumentView(') dat += '\n\nfrom calibre.gui2.viewer.documentview import DocumentView' - + open(compiled_form, 'wb').write(dat) - + @classmethod def clean(cls): forms = cls.find_forms() @@ -340,16 +340,16 @@ class gui(OptionlessCommand): os.remove(x) class clean(OptionlessCommand): - + description='''Delete all computer generated files in the source tree''' - + def run(self): print 'Cleaning...' manual.clean() gui.clean() translations.clean() resources.clean() - + for f in glob.glob(os.path.join('src', 'calibre', 'plugins', '*')): os.remove(f) for root, _, files in os.walk('.'): @@ -358,12 +358,12 @@ class clean(OptionlessCommand): if name.endswith(t): os.remove(os.path.join(root, name)) break - + for dir in ('build', 'dist', os.path.join('src', 'calibre.egg-info')): shutil.rmtree(dir, ignore_errors=True) class build_py(_build_py): - + def find_data_files(self, package, src_dir): """ Return filenames for package's data files in 'src_dir' @@ -377,11 +377,11 @@ class build_py(_build_py): pattern = os.path.join(src_dir, convert_path(pattern)) next = glob.glob(pattern) files.extend(next if next else [pattern]) - + return self.exclude_data_files(package, src_dir, files) class build(_build): - + sub_commands = [ ('resources', lambda self : 'CALIBRE_BUILDBOT' not in os.environ.keys()), ('translations', lambda self : 'CALIBRE_BUILDBOT' not in os.environ.keys()), @@ -391,13 +391,13 @@ class build(_build): ('build_clib', _build.has_c_libraries), ('build_scripts', _build.has_scripts), ] - + class update(OptionlessCommand): - + description = 'Rebuild plugins and run develop. Should be called after ' +\ ' a version update.' - + def run(self): for x in ['build', 'dist', 'docs'] + \ glob.glob(os.path.join('src', 'calibre', 'plugins', '*')): @@ -407,24 +407,24 @@ class update(OptionlessCommand): os.mkdir(x) else: os.remove(x) - + check_call('python setup.py build_ext build'.split()) check_call('sudo python setup.py develop'.split()) - + class tag_release(OptionlessCommand): - + description = 'Tag a new release in bzr' - + def run(self): print 'Tagging release' check_call(('bzr tag '+__version__).split()) check_call('bzr commit --unchanged -m'.split() + ['IGN:Tag release']) - - + + class upload_demo(OptionlessCommand): - + description = 'Rebuild and upload various demos' - + def run(self): check_call( '''html2lrf --title='Demonstration of html2lrf' --author='Kovid Goyal' ''' @@ -432,23 +432,23 @@ class upload_demo(OptionlessCommand): '''--serif-family "/usr/share/fonts/corefonts, Times New Roman" ''' '''--mono-family "/usr/share/fonts/corefonts, Andale Mono" ''' ''''''%(HTML2LRF,), shell=True) - + check_call( 'cd src/calibre/ebooks/lrf/html/demo/ && ' 'zip -j /tmp/html-demo.zip * /tmp/html2lrf.lrf', shell=True) - + check_call('scp /tmp/html-demo.zip divok:%s/'%(DOWNLOADS,), shell=True) - + check_call( '''txt2lrf -t 'Demonstration of txt2lrf' -a 'Kovid Goyal' ''' '''--header -o /tmp/txt2lrf.lrf %s/demo.txt'''%(TXT2LRF,), shell=True) - + check_call('cd src/calibre/ebooks/lrf/txt/demo/ && ' 'zip -j /tmp/txt-demo.zip * /tmp/txt2lrf.lrf', shell=True) - + check_call('''scp /tmp/txt-demo.zip divok:%s/'''%(DOWNLOADS,), shell=True) - - + + def installer_name(ext): if ext in ('exe', 'dmg'): return 'dist/%s-%s.%s'%(__appname__, __version__, ext) @@ -467,14 +467,14 @@ class build_linux(OptionlessCommand): return os.path.basename(installer) class VMInstaller(OptionlessCommand): - + user_options = [('dont-shutdown', 'd', 'Dont shutdown Vm after build')] boolean_options = ['dont-shutdown'] - + def initialize_options(self): self.dont_shutdown = False - - BUILD_SCRIPT = textwrap.dedent('''\ + + BUILD_SCRIPT = textwrap.dedent('''\ #!/bin/bash export CALIBRE_BUILDBOT=1 cd ~/build && \ @@ -488,10 +488,10 @@ class VMInstaller(OptionlessCommand): rm -rf build/* dist/* && \ %%s %%s '''%dict(host=HOST, project=__appname__)) - + def get_build_script(self, subs): return self.BUILD_SCRIPT%subs - + def start_vm(self, ssh_host, build_script, sleep=75): build_script = self.get_build_script(build_script) vmware = ('vmware', '-q', '-x', '-n', self.VM) @@ -500,7 +500,7 @@ class VMInstaller(OptionlessCommand): t.write(build_script) t.flush() print 'Waiting for VM to startup' - while call('ping -q -c1 '+ssh_host, shell=True, + while call('ping -q -c1 '+ssh_host, shell=True, stdout=open('/dev/null', 'w')) != 0: time.sleep(5) time.sleep(20) @@ -513,10 +513,10 @@ class build_windows(VMInstaller): VM = '/mnt/backup/calibre_windows_xp_home/calibre_windows_xp_home.vmx' if not os.path.exists(VM): VM = '/home/kovid/calibre_windows_xp_home/calibre_windows_xp_home.vmx' - + def run(self): installer = installer_name('exe') - self.start_vm('windows', ('python setup.py develop', + self.start_vm('windows', ('python setup.py develop', 'python', r'installer\\windows\\freeze.py')) if os.path.exists('build/py2exe'): @@ -529,7 +529,7 @@ class build_windows(VMInstaller): Popen(('ssh', 'windows', 'shutdown', '-s', '-t', '0')) self.run_windows_install_jammer(installer) return os.path.basename(installer) - + def run_windows_install_jammer(self, installer): ibp = os.path.abspath('installer/windows') sys.path.insert(0, ibp) @@ -546,14 +546,14 @@ class build_osx(VMInstaller): VM = '/vmware/Mac OSX/Mac OSX.vmx' if not os.path.exists(VM): VM = '/home/kovid/calibre_os_x/Mac OSX.vmx' - + def get_build_script(self, subs): return (self.BUILD_SCRIPT%subs).replace('rm ', 'sudo rm ') - + def run(self): installer = installer_name('dmg') python = '/Library/Frameworks/Python.framework/Versions/Current/bin/python' - self.start_vm('osx', ('sudo %s setup.py develop'%python, python, + self.start_vm('osx', ('sudo %s setup.py develop'%python, python, 'installer/osx/freeze.py')) check_call(('scp', 'osx:build/calibre/dist/*.dmg', 'dist')) if not os.path.exists(installer): @@ -579,7 +579,7 @@ class upload_installers(OptionlessCommand): c.perform() c.close() return b.getvalue().split() if listonly else b.getvalue().splitlines() - + def curl_delete_file(self, path, url=MOBILEREAD): import pycurl c = pycurl.Curl() @@ -590,8 +590,8 @@ class upload_installers(OptionlessCommand): c.setopt(c.QUOTE, ['dele '+ path]) c.perform() c.close() - - + + def curl_upload_file(self, stream, url): import pycurl c = pycurl.Curl() @@ -618,7 +618,7 @@ class upload_installers(OptionlessCommand): stream.seek(0,2) if size != stream.tell(): raise RuntimeError('curl failed to upload %s correctly'%getattr(stream, 'name', '')) - + def upload_installer(self, name): if not os.path.exists(name): return @@ -633,19 +633,19 @@ class upload_installers(OptionlessCommand): print 'Uploading installers...' for i in ('dmg', 'exe', 'tar.bz2'): self.upload_installer(installer_name(i)) - + check_call('''ssh divok echo %s \\> %s/latest_version'''\ %(__version__, DOWNLOADS), shell=True) class upload_user_manual(OptionlessCommand): description = 'Build and upload the User Manual' sub_commands = [('manual', None)] - + def run(self): OptionlessCommand.run(self) check_call(' '.join(['scp', '-r', 'src/calibre/manual/.build/html/*', 'divok:%s'%USER_MANUAL]), shell=True) - + class upload_to_pypi(OptionlessCommand): description = 'Upload eggs and source to PyPI' def run(self): @@ -658,7 +658,7 @@ class upload_to_pypi(OptionlessCommand): os.mkdir('build') check_call('python setup.py build_ext bdist_egg --exclude-source-files upload'.split()) check_call('python setup.py sdist upload'.split()) - + class stage3(OptionlessCommand): description = 'Stage 3 of the build process' sub_commands = [ @@ -667,7 +667,7 @@ class stage3(OptionlessCommand): ('upload_to_pypi', None), ('upload_rss', None), ] - + @classmethod def misc(cls): check_call('ssh divok rm -f %s/calibre-\*.tar.gz'%DOWNLOADS, shell=True) @@ -675,23 +675,23 @@ class stage3(OptionlessCommand): check_call('''rm -rf dist/* build/*''', shell=True) check_call('ssh divok bzr update /var/www/calibre.kovidgoyal.net/calibre/', shell=True) - + def run(self): OptionlessCommand.run(self) self.misc() - + class stage2(OptionlessCommand): - description = 'Stage 2 of the build process' + description = 'Stage 2 of the build process' sub_commands = [ ('build_linux', None), ('build_windows', None), ('build_osx', None) ] - + def run(self): check_call('rm -rf dist/*', shell=True) OptionlessCommand.run(self) - + class stage1(OptionlessCommand): description = 'Stage 1 of the build process' sub_commands = [ @@ -699,10 +699,10 @@ class stage1(OptionlessCommand): ('tag_release', None), ('upload_demo', None), ] - + class upload(OptionlessCommand): description = 'Build and upload calibre to the servers' - + sub_commands = [ ('stage1', None), ('stage2', None), @@ -711,13 +711,13 @@ class upload(OptionlessCommand): try: class upload_rss(OptionlessCommand): - + from bzrlib import log as blog - + class ChangelogFormatter(blog.LogFormatter): supports_tags = True supports_merge_revisions = False - + def __init__(self, num_of_versions=20): from calibre.utils.rss_gen import RSS2 self.num_of_versions = num_of_versions @@ -727,21 +727,21 @@ try: description = 'Latest release of calibre', lastBuildDate = datetime.utcnow() ) - self.current_entry = None - + self.current_entry = None + def log_revision(self, r): from calibre.utils.rss_gen import RSSItem, Guid if len(self.rss.items) > self.num_of_versions-1: return msg = r.rev.message match = re.match(r'version\s+(\d+\.\d+.\d+)', msg) - + if match: if self.current_entry is not None: mkup = '<div><ul>%s</ul></div>' self.current_entry.description = mkup%(''.join( self.current_entry.description)) - + self.rss.items.append(self.current_entry) timestamp = r.rev.timezone + r.rev.timestamp self.current_entry = RSSItem( @@ -755,13 +755,13 @@ try: if re.search(r'[a-zA-Z]', msg) and len(msg.strip()) > 5: if 'translation' not in msg and not msg.startswith('IGN'): msg = msg.replace('<', '<').replace('>', '>') - msg = re.sub('#(\d+)', r'<a href="http://calibre.kovidgoyal.net/ticket/\1">#\1</a>', + msg = re.sub('#(\d+)', r'<a href="http://calibre.kovidgoyal.net/ticket/\1">#\1</a>', msg) - + self.current_entry.description.append( '<li>%s</li>'%msg.strip()) - - + + def run(self): from bzrlib import log, branch bzr_path = os.path.expanduser('~/work/calibre') @@ -771,5 +771,5 @@ try: lf.rss.write_xml(open('/tmp/releases.xml', 'wb')) subprocess.check_call('scp /tmp/releases.xml divok:/var/www/calibre.kovidgoyal.net/htdocs/downloads'.split()) except ImportError: - upload_rss = None + upload_rss = None