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 0000000000..e145f6e0f1 Binary files /dev/null and b/src/calibre/gui2/images/gmail_logo.png differ 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