From 6fc235d5f9e577bad944ebde1a97abe9a447302e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 08:09:32 -0700 Subject: [PATCH 01/10] ... --- resources/images/news/kompiutierra.png | Bin 0 -> 654 bytes resources/images/news/rbc_ru.png | Bin 0 -> 371 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/images/news/kompiutierra.png create mode 100644 resources/images/news/rbc_ru.png diff --git a/resources/images/news/kompiutierra.png b/resources/images/news/kompiutierra.png new file mode 100644 index 0000000000000000000000000000000000000000..272e3d905fb98bf2deef154486357ee490de0d22 GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt_f1s;*b zK-vS0-A-oPfdtD69Mgd`SU*F|v9*U87#O#Ex;TbdoL)NVymv^T%u)OFXR}nJbX8Wg zO2?{xYKwY4f5Db+!K_H#D2pcVzx;9C1%em*&z{k8DG|?5;K*fa?@+iUA~PAy(D^Lpd$N$+mX6MC@4@zA%zJKJ3L-Ok$^l&rTu z`ST-=)d%cYmaAO)FIb0vi~<=gKj^J|Ixwwki`CZs6x*S>GQ3vzBXhp*%@#GRrbeTcvepKP!CH z*>s*+_-dB=4u?>)If z?P`?flx>r%6s9qWO=MfS_iR Date: Sun, 6 Mar 2011 08:18:45 -0700 Subject: [PATCH 02/10] Search and replace preferences: Prevent very long strings from causing the wizard button to get pushed off the screen --- src/calibre/gui2/convert/xexp_edit.ui | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/calibre/gui2/convert/xexp_edit.ui b/src/calibre/gui2/convert/xexp_edit.ui index 18b7c39b52..68c0c8c98e 100644 --- a/src/calibre/gui2/convert/xexp_edit.ui +++ b/src/calibre/gui2/convert/xexp_edit.ui @@ -43,6 +43,9 @@ 0 + + QComboBox::AdjustToMinimumContentsLengthWithIcon + 30 From a94fe3adad7bfae3d94cdb9e9d11df85c0933756 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 08:31:58 -0700 Subject: [PATCH 03/10] FB2 Input: Support for tables. Fixes #9302 (fb2 --> epub, mobi Missing tables) --- resources/templates/fb2.xsl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/resources/templates/fb2.xsl b/resources/templates/fb2.xsl index 77c03cdc74..703d082467 100644 --- a/resources/templates/fb2.xsl +++ b/resources/templates/fb2.xsl @@ -293,6 +293,23 @@

Annotation

+ + + + +
+
+ + + + + + + + + + +
From d68aaddbb4d97ca829c29acc8117314fe78363d3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 08:39:53 -0700 Subject: [PATCH 04/10] ... --- resources/templates/fb2.xsl | 576 ++++++++++++++++++------------------ 1 file changed, 288 insertions(+), 288 deletions(-) diff --git a/resources/templates/fb2.xsl b/resources/templates/fb2.xsl index 703d082467..9d0abc7d45 100644 --- a/resources/templates/fb2.xsl +++ b/resources/templates/fb2.xsl @@ -19,21 +19,21 @@ ######################################################################### --> - - - - - - - - - - <xsl:value-of select="fb:description/fb:title-info/fb:book-title"/> - - + - - - -
- -
-
-
- -
    - -
-
+ + + +
+ +
+
+
+ +
    + +
+
- - - -
-
- -

- -

-
- - -
- - -
- - - - - + + + +
+
+ +

+ +

+
+ + +
+ + +
+ + + + + - -
-
- - -
  • - - - , # - - - -
      - - - -
    -
    - - - - - - - - - -
  • - - -
    -
    -
  • - - -
    - - - - - - -
    -
    + +
    + + + +
  • + + + , # + + + +
      + + + +
    +
    + + + + + + + + + +
  • + + +
    +
    +
  • + + +
    + + + + + + +
    +
    - + @@ -164,15 +164,15 @@ - - - - - + + + + + - - - + + + @@ -181,79 +181,79 @@ TOC_ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - -
    -
    - - - - - - - -
    - -
    -
    - - + +
    +
    + + + + + + + +
    + +
    +
    + + paragraph - - - - + + + + - - - - - - - - - - - - - - - -
    -
    +
    + + + + + + + + + + + + + + +
    +
    @@ -261,140 +261,140 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    Annotation

    - -
    - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    Annotation

    + +
    + +
    -
    - + + - - + + - + - - - -
    - - - - - - -
    -
    - - -
    - -
    -
    - - -
    - - - - - - -
    -
    - - -
    -
    -
    - - - - -     -
    -
    - -     -
    -
    -
    -
    - - -
    - - - - - - -
    -
    +
    + + +
    + + + + + + +
    +
    + + +
    + +
    +
    + + +
    + + + + + + +
    +
    + + +
    +
    +
    + + + + +     +
    +
    + +     +
    +
    +
    +
    + + +
    + + + + + + +
    +
    - - - -
    -
    - - - - - - - -
    -
    - - -
    - - - - - - - - - - -
    -
    + + + +
    +
    + + + + + + + +
    +
    + + +
    + + + + + + + + + + +
    +
    From bcdd6d474ae2c64735bbda2ae643e1137f411ecf Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 08:42:09 -0700 Subject: [PATCH 05/10] ... --- resources/templates/fb2.xsl | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/templates/fb2.xsl b/resources/templates/fb2.xsl index 9d0abc7d45..273edd71ae 100644 --- a/resources/templates/fb2.xsl +++ b/resources/templates/fb2.xsl @@ -4,6 +4,7 @@ # # # # # copyright 2002 Paul Henry Tremblay # +# Copyright 2011 Kovid Goyal # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # From 43fcdf8ca958e699cd91e1082b78a6a293326665 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 09:24:55 -0700 Subject: [PATCH 06/10] Replace leading period in filenames with an underscore --- src/calibre/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index 221f5911c6..716e3913fb 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -85,6 +85,8 @@ def sanitize_file_name(name, substitute='_', as_unicode=False): # Windows doesn't like path components that end with a period if one.endswith('.'): one = one[:-1]+'_' + if one.startswith('.'): + one = '_' + one[1:] return one From 56a89ca29f90552be35ac4142106b71ee73859bb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 11:42:22 -0700 Subject: [PATCH 07/10] Fix #7250 ("Save to disk" cannot save non-ASCII file name correctly if not convert to ASCII) --- src/calibre/__init__.py | 28 +++++++++++++++++++- src/calibre/library/save_to_disk.py | 14 ++++------ src/calibre/startup.py | 41 ----------------------------- 3 files changed, 32 insertions(+), 51 deletions(-) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index 716e3913fb..5ba1aa42de 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -61,8 +61,9 @@ def osx_version(): if m: return int(m.group(1)), int(m.group(2)), int(m.group(3)) - _filename_sanitize = re.compile(r'[\xae\0\\|\?\*<":>\+/]') +_filename_sanitize_unicode = frozenset([u'\\', u'|', u'?', u'*', u'<', + u'"', u':', u'>', u'+', u'/'] + list(map(unichr, xrange(32)))) def sanitize_file_name(name, substitute='_', as_unicode=False): ''' @@ -85,6 +86,31 @@ def sanitize_file_name(name, substitute='_', as_unicode=False): # Windows doesn't like path components that end with a period if one.endswith('.'): one = one[:-1]+'_' + # Names starting with a period are hidden on Unix + if one.startswith('.'): + one = '_' + one[1:] + return one + +def sanitize_file_name_unicode(name, substitute='_'): + ''' + Sanitize the filename `name`. All invalid characters are replaced by `substitute`. + The set of invalid characters is the union of the invalid characters in Windows, + OS X and Linux. Also removes leading and trailing whitespace. + **WARNING:** This function also replaces path separators, so only pass file names + and not full paths to it. + ''' + if not isinstance(name, unicode): + return sanitize_file_name(name, substitute=substitute, as_unicode=True) + chars = [substitute if c in _filename_sanitize_unicode else c for c in + name] + one = u''.join(chars) + one = re.sub(r'\s', ' ', one).strip() + one = re.sub(r'^\.+$', '_', one) + one = one.replace('..', substitute) + # Windows doesn't like path components that end with a period or space + if one and one[-1] in ('.', ' '): + one = one[:-1]+'_' + # Names starting with a period are hidden on Unix if one.startswith('.'): one = '_' + one[1:] return one diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index de586048b7..96c42e6e0e 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -12,13 +12,13 @@ from calibre.constants import DEBUG from calibre.utils.config import Config, StringConfig, tweaks from calibre.utils.formatter import TemplateFormatter from calibre.utils.filenames import shorten_components_to, supports_long_names, \ - ascii_filename, sanitize_file_name + ascii_filename from calibre.ebooks.metadata.opf2 import metadata_to_opf from calibre.ebooks.metadata.meta import set_metadata -from calibre.constants import preferred_encoding, filesystem_encoding +from calibre.constants import preferred_encoding from calibre.ebooks.metadata import fmt_sidx from calibre.ebooks.metadata import title_sort -from calibre import strftime, prints +from calibre import strftime, prints, sanitize_file_name_unicode plugboard_any_device_value = 'any device' plugboard_any_format_value = 'any format' @@ -197,12 +197,10 @@ def get_components(template, mi, id, timefmt='%b %Y', length=250, format_args[key] = '' components = SafeFormat().safe_format(template, format_args, 'G_C-EXCEPTION!', mi) - components = [x.strip() for x in components.split('/') if x.strip()] + components = [x.strip() for x in components.split('/')] components = [sanitize_func(x) for x in components if x] if not components: components = [str(id)] - components = [x.encode(filesystem_encoding, 'replace') if isinstance(x, - unicode) else x for x in components] if to_lowercase: components = [x.lower() for x in components] if replace_whitespace: @@ -247,7 +245,7 @@ def do_save_book_to_disk(id_, mi, cover, plugboards, return True, id_, mi.title components = get_components(opts.template, mi, id_, opts.timefmt, length, - ascii_filename if opts.asciiize else sanitize_file_name, + ascii_filename if opts.asciiize else sanitize_file_name_unicode, to_lowercase=opts.to_lowercase, replace_whitespace=opts.replace_whitespace) base_path = os.path.join(root, *components) @@ -329,8 +327,6 @@ def do_save_book_to_disk(id_, mi, cover, plugboards, def _sanitize_args(root, opts): if opts is None: opts = config().parse() - if isinstance(root, unicode): - root = root.encode(filesystem_encoding) root = os.path.abspath(root) opts.template = preprocess_template(opts.template) diff --git a/src/calibre/startup.py b/src/calibre/startup.py index 41b20f3946..c883c43e8a 100644 --- a/src/calibre/startup.py +++ b/src/calibre/startup.py @@ -72,47 +72,6 @@ if not _run_once: pass ################################################################################ - # Improve builtin path functions to handle unicode sensibly - - _abspath = os.path.abspath - def my_abspath(path, encoding=sys.getfilesystemencoding()): - ''' - Work around for buggy os.path.abspath. This function accepts either byte strings, - in which it calls os.path.abspath, or unicode string, in which case it first converts - to byte strings using `encoding`, calls abspath and then decodes back to unicode. - ''' - to_unicode = False - if encoding is None: - encoding = preferred_encoding - if isinstance(path, unicode): - path = path.encode(encoding) - to_unicode = True - res = _abspath(path) - if to_unicode: - res = res.decode(encoding) - return res - - os.path.abspath = my_abspath - - _join = os.path.join - def my_join(a, *p): - encoding=sys.getfilesystemencoding() - if not encoding: - encoding = preferred_encoding - p = [a] + list(p) - _unicode = False - for i in p: - if isinstance(i, unicode): - _unicode = True - break - p = [i.encode(encoding) if isinstance(i, unicode) else i for i in p] - - res = _join(*p) - if _unicode: - res = res.decode(encoding) - return res - - os.path.join = my_join def local_open(name, mode='r', bufsize=-1): ''' From 0a22a234cd80511a6c0eb7419ca536c36c0291d6 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 11:46:04 -0700 Subject: [PATCH 08/10] ... --- src/calibre/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index 5ba1aa42de..fa9a8f2404 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -84,7 +84,7 @@ def sanitize_file_name(name, substitute='_', as_unicode=False): one = one.decode(filesystem_encoding) one = one.replace('..', substitute) # Windows doesn't like path components that end with a period - if one.endswith('.'): + if one and one[-1] in ('.', ' '): one = one[:-1]+'_' # Names starting with a period are hidden on Unix if one.startswith('.'): From 1920f3e7bdb09006458571a4f62ddb834d090119 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 19:34:58 -0700 Subject: [PATCH 09/10] ... --- src/calibre/trac/bzr_commit_plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/calibre/trac/bzr_commit_plugin.py b/src/calibre/trac/bzr_commit_plugin.py index 2f91804315..325bac7a79 100644 --- a/src/calibre/trac/bzr_commit_plugin.py +++ b/src/calibre/trac/bzr_commit_plugin.py @@ -19,7 +19,7 @@ in the working tree you want to use it with:: trac_reponame_password = ''' -import os, re, xmlrpclib +import os, re, xmlrpclib, subprocess from bzrlib.builtins import cmd_commit as _cmd_commit, tree_files from bzrlib import branch import bzrlib @@ -115,5 +115,7 @@ class cmd_commit(_cmd_commit): server.ticket.update(int(bug), msg, {'status':'closed', 'resolution':'fixed'}, True) + subprocess.Popen('/home/kovid/work/kde/mail.py -f --delay 10'.split()) + bzrlib.commands.register_command(cmd_commit) From 62105efcc9ac1bcca82f019ad12aa4868209ebdf Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 6 Mar 2011 22:04:56 -0700 Subject: [PATCH 10/10] Improved support for dragging and dropping cover images directly from web browsers into calibre. You can drop the images onto the cover in calibre and it will be replaced. Drag and drop implementations in various browsers/OSes are very flaky, so your mileage may vary, depending on the site you are dragigng from, the browser you are using and your operating system. If it doesn't work you can alway right click and Copy Image, then right click and paste it in calibre --- src/calibre/gui2/actions/add.py | 23 ++- src/calibre/gui2/book_details.py | 73 ++++--- src/calibre/gui2/dnd.py | 325 +++++++++++++++++++++++++++++++ src/calibre/gui2/init.py | 3 + src/calibre/gui2/widgets.py | 94 +++++---- 5 files changed, 438 insertions(+), 80 deletions(-) create mode 100644 src/calibre/gui2/dnd.py diff --git a/src/calibre/gui2/actions/add.py b/src/calibre/gui2/actions/add.py index 0040acea28..cf67cd6cfa 100644 --- a/src/calibre/gui2/actions/add.py +++ b/src/calibre/gui2/actions/add.py @@ -204,15 +204,29 @@ class AddAction(InterfaceAction): to_device = self.gui.stack.currentIndex() != 0 self._add_books(paths, to_device) - def files_dropped_on_book(self, event, paths): + def remote_file_dropped_on_book(self, url, fname): + if self.gui.current_view() is not self.gui.library_view: + return + db = self.gui.library_view.model().db + current_idx = self.gui.library_view.currentIndex() + if not current_idx.isValid(): return + cid = db.id(current_idx.row()) + from calibre.gui2.dnd import DownloadDialog + d = DownloadDialog(url, fname, self.gui) + d.start_download() + if d.err is None: + self.files_dropped_on_book(None, [d.fpath], cid=cid) + + def files_dropped_on_book(self, event, paths, cid=None): accept = False if self.gui.current_view() is not self.gui.library_view: return db = self.gui.library_view.model().db cover_changed = False current_idx = self.gui.library_view.currentIndex() - if not current_idx.isValid(): return - cid = db.id(current_idx.row()) + if cid is None: + if not current_idx.isValid(): return + cid = db.id(current_idx.row()) if cid is None else cid for path in paths: ext = os.path.splitext(path)[1].lower() if ext: @@ -227,8 +241,9 @@ class AddAction(InterfaceAction): elif ext in BOOK_EXTENSIONS: db.add_format_with_hooks(cid, ext, path, index_is_id=True) accept = True - if accept: + if accept and event is not None: event.accept() + if current_idx.isValid(): self.gui.library_view.model().current_changed(current_idx, current_idx) if cover_changed: if self.gui.cover_flow: diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index 63deccb2f0..a28759486e 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -5,7 +5,7 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import os, collections, sys +import collections, sys from Queue import Queue from PyQt4.Qt import QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, \ @@ -14,7 +14,8 @@ from PyQt4.Qt import QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, \ from PyQt4.QtWebKit import QWebView from calibre import fit_image, prepare_string_for_xml -from calibre.gui2.widgets import IMAGE_EXTENSIONS +from calibre.gui2.dnd import dnd_has_image, dnd_get_image, dnd_get_files, \ + IMAGE_EXTENSIONS, dnd_has_extension from calibre.ebooks import BOOK_EXTENSIONS from calibre.constants import preferred_encoding from calibre.library.comments import comments_to_html @@ -165,11 +166,12 @@ class CoverView(QWidget): # {{{ def copy_to_clipboard(self): QApplication.instance().clipboard().setPixmap(self.pixmap) - def paste_from_clipboard(self): - cb = QApplication.instance().clipboard() - pmap = cb.pixmap() - if pmap.isNull() and cb.supportsSelection(): - pmap = cb.pixmap(cb.Selection) + def paste_from_clipboard(self, pmap=None): + if not isinstance(pmap, QPixmap): + cb = QApplication.instance().clipboard() + pmap = cb.pixmap() + if pmap.isNull() and cb.supportsSelection(): + pmap = cb.pixmap(cb.Selection) if not pmap.isNull(): self.pixmap = pmap self.do_layout() @@ -226,6 +228,7 @@ class BookInfo(QWebView): self._link_clicked = False self.setAttribute(Qt.WA_OpaquePaintEvent, False) palette = self.palette() + self.setAcceptDrops(False) palette.setBrush(QPalette.Base, Qt.transparent) self.page().setPalette(palette) @@ -388,36 +391,50 @@ class BookDetails(QWidget): # {{{ show_book_info = pyqtSignal() open_containing_folder = pyqtSignal(int) view_specific_format = pyqtSignal(int, object) - - # Drag 'n drop {{{ - DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS + remote_file_dropped = pyqtSignal(object, object) files_dropped = pyqtSignal(object, object) cover_changed = pyqtSignal(object, object) - # application/x-moz-file-promise-url - @classmethod - def paths_from_event(cls, event): - ''' - 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 = [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] + # Drag 'n drop {{{ + DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS def dragEnterEvent(self, event): - if int(event.possibleActions() & Qt.CopyAction) + \ - int(event.possibleActions() & Qt.MoveAction) == 0: - return - paths = self.paths_from_event(event) - if paths: + md = event.mimeData() + if dnd_has_extension(md, self.DROPABBLE_EXTENSIONS) or \ + dnd_has_image(md): event.acceptProposedAction() def dropEvent(self, event): - paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) - self.files_dropped.emit(event, paths) + md = event.mimeData() + + x, y = dnd_get_image(md) + if x is not None: + # We have an image, set cover + event.accept() + if y is None: + # Local image + self.cover_view.paste_from_clipboard(x) + else: + self.remote_file_dropped.emit(x, y) + # We do not support setting cover *and* adding formats for + # a remote drop, anyway, so return + return + + # Now look for ebook files + urls, filenames = dnd_get_files(md, BOOK_EXTENSIONS) + if not urls: + # Nothing found + return + + if not filenames: + # Local files + self.files_dropped.emit(event, urls) + else: + # Remote files, use the first file + self.remote_file_dropped.emit(urls[0], filenames[0]) + event.accept() + def dragMoveEvent(self, event): event.acceptProposedAction() diff --git a/src/calibre/gui2/dnd.py b/src/calibre/gui2/dnd.py new file mode 100644 index 0000000000..928de72578 --- /dev/null +++ b/src/calibre/gui2/dnd.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import posixpath, os, urllib, re +from urlparse import urlparse, urlunparse +from threading import Thread +from Queue import Queue, Empty + +from PyQt4.Qt import QPixmap, Qt, QDialog, QLabel, QVBoxLayout, \ + QDialogButtonBox, QProgressBar, QTimer + +from calibre.constants import DEBUG, iswindows +from calibre.ptempfile import PersistentTemporaryFile +from calibre import browser, as_unicode, prints +from calibre.gui2 import error_dialog + +IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'gif', 'png', 'bmp'] + +class Worker(Thread): # {{{ + + def __init__(self, url, fpath, rq): + Thread.__init__(self) + self.url, self.fpath = url, fpath + self.daemon = True + self.rq = rq + self.err = self.tb = None + + def run(self): + try: + br = browser() + br.retrieve(self.url, self.fpath, self.callback) + except Exception, e: + self.err = as_unicode(e) + import traceback + self.tb = traceback.format_exc() + + def callback(self, a, b, c): + self.rq.put((a, b, c)) +# }}} + +class DownloadDialog(QDialog): # {{{ + + def __init__(self, url, fname, parent): + QDialog.__init__(self, parent) + self.setWindowTitle(_('Download %s')%fname) + self.l = QVBoxLayout(self) + self.purl = urlparse(url) + self.msg = QLabel(_('Downloading %s from %s')%(fname, + self.purl.netloc)) + self.msg.setWordWrap(True) + self.l.addWidget(self.msg) + self.pb = QProgressBar(self) + self.pb.setMinimum(0) + self.pb.setMaximum(0) + self.l.addWidget(self.pb) + self.bb = QDialogButtonBox(QDialogButtonBox.Cancel, Qt.Horizontal, self) + self.l.addWidget(self.bb) + self.bb.rejected.connect(self.reject) + sz = self.sizeHint() + self.resize(max(sz.width(), 400), sz.height()) + + fpath = PersistentTemporaryFile(os.path.splitext(fname)[1]) + fpath.close() + self.fpath = fpath.name + + self.worker = Worker(url, self.fpath, Queue()) + self.rejected = False + + def reject(self): + self.rejected = True + QDialog.reject(self) + + def start_download(self): + self.worker.start() + QTimer.singleShot(50, self.update) + self.exec_() + if self.worker.err is not None: + error_dialog(self.parent(), _('Download failed'), + _('Failed to download from %r with error: %s')%( + self.worker.url, self.worker.err), + det_msg=self.worker.tb, show=True) + + def update(self): + if self.rejected: + return + + try: + progress = self.worker.rq.get_nowait() + except Empty: + pass + else: + self.update_pb(progress) + + if not self.worker.is_alive(): + return self.accept() + QTimer.singleShot(50, self.update) + + def update_pb(self, progress): + transferred, block_size, total = progress + if total == -1: + self.pb.setMaximum(0) + self.pb.setMinimum(0) + self.pb.setValue(0) + else: + so_far = transferred * block_size + self.pb.setMaximum(max(total, so_far)) + self.pb.setValue(so_far) + + @property + def err(self): + return self.worker.err + +# }}} + +def dnd_has_image(md): + return md.hasImage() + +def data_as_string(f, md): + raw = bytes(md.data(f)) + if '/x-moz' in f: + try: + raw = raw.decode('utf-16') + except: + pass + return raw + +def dnd_has_extension(md, extensions): + if DEBUG: + prints('Debugging DND event') + for f in md.formats(): + f = unicode(f) + prints(f, repr(data_as_string(f, md))[:300], '\n') + print () + if has_firefox_ext(md, extensions): + return True + if md.hasUrls(): + urls = [unicode(u.toString()) for u in + md.urls()] + purls = [urlparse(u) for u in urls] + if DEBUG: + prints('URLS:', urls) + prints('Paths:', [u2p(x) for x in purls]) + + exts = frozenset([posixpath.splitext(u.path)[1][1:].lower() for u in + purls]) + return bool(exts.intersection(frozenset(extensions))) + return False + +def u2p(url): + path = url.path + if iswindows: + if path.startswith('/'): + path = path[1:] + ans = path.replace('/', os.sep) + if os.path.exists(ans): + return ans + # Try unquoting the URL + return urllib.unquote(ans) + +def dnd_get_image(md, image_exts=IMAGE_EXTENSIONS): + ''' + Get the image in the QMimeData object md. + + :return: None, None if no image is found + QPixmap, None if an image is found, the pixmap is guaranteed not + null + url, filename if a URL that points to an image is found + ''' + if dnd_has_image(md): + for x in md.formats(): + x = unicode(x) + if x.startswith('image/'): + cdata = bytes(md.data(x)) + pmap = QPixmap() + pmap.loadFromData(cdata) + if not pmap.isNull(): + return pmap, None + break + + # No image, look for a URL pointing to an image + if md.hasUrls(): + urls = [unicode(u.toString()) for u in + md.urls()] + purls = [urlparse(u) for u in urls] + # First look for a local file + images = [u2p(x) for x in purls if x.scheme in ('', 'file') and + posixpath.splitext(urllib.unquote(x.path))[1][1:].lower() in + image_exts] + images = [x for x in images if os.path.exists(x)] + p = QPixmap() + for path in images: + try: + with open(path, 'rb') as f: + p.loadFromData(f.read()) + except: + continue + if not p.isNull(): + return p, None + + # No local images, look for remote ones + + # First, see if this is from Firefox + rurl, fname = get_firefox_rurl(md, image_exts) + + if rurl and fname: + return rurl, fname + # Look through all remaining URLs + remote_urls = [x for x in purls if x.scheme in ('http', 'https', + 'ftp') and posixpath.splitext(x.path)[1][1:].lower() in image_exts] + if remote_urls: + rurl = remote_urls[0] + fname = posixpath.basename(urllib.unquote(rurl.path)) + return urlunparse(rurl), fname + + return None, None + +def dnd_get_files(md, exts): + ''' + Get the file in the QMimeData object md with an extension that is one of + the extensions in exts. + + :return: None, None if no file is found + [paths], None if a local file is found + [urls], [filenames] if URLs that point to a files are found + ''' + # Look for a URL pointing to a file + if md.hasUrls(): + urls = [unicode(u.toString()) for u in + md.urls()] + purls = [urlparse(u) for u in urls] + # First look for a local file + local_files = [u2p(x) for x in purls if x.scheme in ('', 'file') and + posixpath.splitext(urllib.unquote(x.path))[1][1:].lower() in + exts] + local_files = [x for x in local_files if os.path.exists(x)] + if local_files: + return local_files, None + + # No local files, look for remote ones + + # First, see if this is from Firefox + rurl, fname = get_firefox_rurl(md, exts) + if rurl and fname: + return [rurl], [fname] + + # Look through all remaining URLs + remote_urls = [x for x in purls if x.scheme in ('http', 'https', + 'ftp') and posixpath.splitext(x.path)[1][1:].lower() in exts] + if remote_urls: + filenames = [posixpath.basename(urllib.unquote(rurl.path)) for rurl in + remote_urls] + return [urlunparse(x) for x in remote_urls], filenames + + return None, None + +def _get_firefox_pair(md, exts, url, fname): + url = bytes(md.data(url)).decode('utf-16') + fname = bytes(md.data(fname)).decode('utf-16') + while url.endswith('\x00'): + url = url[:-1] + while fname.endswith('\x00'): + fname = fname[:-1] + if not url or not fname: + return None, None + ext = posixpath.splitext(fname)[1][1:].lower() + # Weird firefox bug on linux + ext = {'jpe':'jpg', 'epu':'epub', 'mob':'mobi'}.get(ext, ext) + fname = os.path.splitext(fname)[0] + '.' + ext + if DEBUG: + prints('Firefox file promise:', url, fname) + if ext not in exts: + fname = url = None + return url, fname + + +def get_firefox_rurl(md, exts): + formats = frozenset([unicode(x) for x in md.formats()]) + url = fname = None + if 'application/x-moz-file-promise-url' in formats and \ + 'application/x-moz-file-promise-dest-filename' in formats: + try: + url, fname = _get_firefox_pair(md, exts, + 'application/x-moz-file-promise-url', + 'application/x-moz-file-promise-dest-filename') + except: + if DEBUG: + import traceback + traceback.print_exc() + if url is None and 'text/x-moz-url-data' in formats and \ + 'text/x-moz-url-desc' in formats: + try: + url, fname = _get_firefox_pair(md, exts, + 'text/x-moz-url-data', 'text/x-moz-url-desc') + except: + if DEBUG: + import traceback + traceback.print_exc() + + if url is None and '_NETSCAPE_URL' in formats: + try: + raw = bytes(md.data('_NETSCAPE_URL')) + raw = raw.decode('utf-8') + lines = raw.splitlines() + if len(lines) > 1 and re.match(r'[a-z]+://', lines[1]) is None: + url, fname = lines[:2] + ext = posixpath.splitext(fname)[1][1:].lower() + if ext not in exts: + fname = url = None + except: + if DEBUG: + import traceback + traceback.print_exc() + if DEBUG: + prints('Firefox rurl:', url, fname) + return url, fname + +def has_firefox_ext(md, exts): + return bool(get_firefox_rurl(md, exts)[0]) + diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index e8c2712c83..9119a8da77 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -264,6 +264,9 @@ class LayoutMixin(object): # {{{ self.book_details.files_dropped.connect(self.iactions['Add Books'].files_dropped_on_book) self.book_details.cover_changed.connect(self.bd_cover_changed, type=Qt.QueuedConnection) + self.book_details.remote_file_dropped.connect( + self.iactions['Add Books'].remote_file_dropped_on_book, + type=Qt.QueuedConnection) self.book_details.open_containing_folder.connect(self.iactions['View'].view_folder_for_id) self.book_details.view_specific_format.connect(self.iactions['View'].view_format_by_id) diff --git a/src/calibre/gui2/widgets.py b/src/calibre/gui2/widgets.py index aa9d6c8b9f..8ebf9c2c21 100644 --- a/src/calibre/gui2/widgets.py +++ b/src/calibre/gui2/widgets.py @@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal ' ''' Miscellaneous widgets used in the GUI ''' -import re, os, traceback +import re, traceback from PyQt4.Qt import QIcon, QFont, QLabel, QListWidget, QAction, \ QListWidgetItem, QTextCharFormat, QApplication, \ @@ -22,6 +22,8 @@ from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.metadata.meta import metadata_from_filename from calibre.utils.config import prefs, XMLConfig, tweaks from calibre.gui2.progress_indicator import ProgressIndicator as _ProgressIndicator +from calibre.gui2.dnd import dnd_has_image, dnd_get_image, dnd_get_files, \ + IMAGE_EXTENSIONS, dnd_has_extension, DownloadDialog history = XMLConfig('history') @@ -141,36 +143,35 @@ class FilenamePattern(QWidget, Ui_Form): return pat -IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'gif', 'png', 'bmp'] - class FormatList(QListWidget): DROPABBLE_EXTENSIONS = BOOK_EXTENSIONS formats_dropped = pyqtSignal(object, object) delete_format = pyqtSignal() - @classmethod - def paths_from_event(cls, event): - ''' - 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 = [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: - return - paths = self.paths_from_event(event) - if paths: + md = event.mimeData() + if dnd_has_extension(md, self.DROPABBLE_EXTENSIONS): event.acceptProposedAction() def dropEvent(self, event): - paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) - self.formats_dropped.emit(event, paths) + md = event.mimeData() + # Now look for ebook files + urls, filenames = dnd_get_files(md, self.DROPABBLE_EXTENSIONS) + if not urls: + # Nothing found + return + + if not filenames: + # Local files + self.formats_dropped.emit(event, urls) + else: + # Remote files, use the first file + d = DownloadDialog(urls[0], filenames[0], self) + d.start_download() + if d.err is None: + self.formats_dropped.emit(event, [d.fpath]) + def dragMoveEvent(self, event): event.acceptProposedAction() @@ -183,7 +184,7 @@ class FormatList(QListWidget): class ImageDropMixin(object): # {{{ ''' - Adds support for dropping images onto widgets and a contect menu for + Adds support for dropping images onto widgets and a context menu for copy/pasting images. ''' DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS @@ -191,39 +192,36 @@ class ImageDropMixin(object): # {{{ def __init__(self): self.setAcceptDrops(True) - @classmethod - def paths_from_event(cls, event): - ''' - 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 = [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: - return - paths = self.paths_from_event(event) - if paths: + md = event.mimeData() + if dnd_has_extension(md, self.DROPABBLE_EXTENSIONS) or \ + dnd_has_image(md): event.acceptProposedAction() def dropEvent(self, event): - paths = self.paths_from_event(event) event.setDropAction(Qt.CopyAction) - for path in paths: - pmap = QPixmap() - pmap.load(path) - if not pmap.isNull(): - self.handle_image_drop(path, pmap) - event.accept() - break + md = event.mimeData() - def handle_image_drop(self, path, pmap): + x, y = dnd_get_image(md) + if x is not None: + # We have an image, set cover + event.accept() + if y is None: + # Local image + self.handle_image_drop(x) + else: + # Remote files, use the first file + d = DownloadDialog(x, y, self) + d.start_download() + if d.err is None: + pmap = QPixmap() + pmap.loadFromData(open(d.fpath, 'rb').read()) + if not pmap.isNull(): + self.handle_image_drop(pmap) + + def handle_image_drop(self, pmap): self.set_pixmap(pmap) - self.cover_changed.emit(open(path, 'rb').read()) + self.cover_changed.emit(pixmap_to_data(pmap)) def dragMoveEvent(self, event): event.acceptProposedAction()