diff --git a/src/calibre/gui2/open_with.py b/src/calibre/gui2/open_with.py index 03131982d1..ac5e0b2b02 100644 --- a/src/calibre/gui2/open_with.py +++ b/src/calibre/gui2/open_with.py @@ -11,13 +11,13 @@ from threading import Thread from functools import partial from PyQt5.Qt import ( - QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt, + QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt, QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray, QBuffer, QPixmap, QAction, QKeySequence) from calibre import as_unicode from calibre.constants import iswindows, isosx -from calibre.gui2 import error_dialog, choose_files, choose_images, elided_text, sanitize_env_vars +from calibre.gui2 import error_dialog, choose_files, choose_images, elided_text, sanitize_env_vars, Application from calibre.gui2.widgets2 import Dialog from calibre.gui2.progress_indicator import ProgressIndicator from calibre.utils.config import JSONConfig @@ -123,7 +123,34 @@ if iswindows: # }}} elif isosx: + # OS X {{{ oprefs = JSONConfig('osx_open_with') + from calibre.utils.open_with.osx import find_programs, get_icon, entry_to_cmdline + + def entry_sort_key(entry): + return sort_key(entry.get('name') or '') + + def finalize_entry(entry): + entry['extensions'] = tuple(entry['extensions']) + data = get_icon(entry.pop('icon_file', None), as_data=True, pixmap_to_data=pixmap_to_data) + if data: + entry['icon_data'] = data + return entry + + def entry_to_item(entry, parent): + icon = get_icon(entry.get('icon_file'), as_data=False) + if icon is None: + icon = entry_to_icon_text(entry)[0] + else: + icon = QPixmap.fromImage(icon) + ans = QListWidgetItem(QIcon(icon), entry.get('name') or _('Unknown'), parent) + ans.setData(ENTRY_ROLE, entry) + ans.setToolTip(_('Application path:') + '\n' + entry['path']) + + def choose_manually(filetype, parent): + raise NotImplementedError() + # }}} + else: # XDG {{{ oprefs = JSONConfig('xdg_open_with') @@ -191,6 +218,7 @@ class ChooseProgram(Dialog): # {{{ self.la = la = QLabel(_('Choose a program to open %s files') % self.file_type.upper()) self.plist = pl = QListWidget(self) + pl.doubleClicked.connect(self.accept) pl.setIconSize(QSize(48, 48)), pl.setSpacing(5) pl.doubleClicked.connect(self.accept) l.addWidget(la), l.addWidget(pl) @@ -368,6 +396,6 @@ def register_keyboard_shortcuts(gui=None, finalize=False): if __name__ == '__main__': from pprint import pprint - app = QApplication([]) + app = Application([]) pprint(choose_program('pdf')) del app diff --git a/src/calibre/utils/open_with/osx.py b/src/calibre/utils/open_with/osx.py new file mode 100644 index 0000000000..2ccd959489 --- /dev/null +++ b/src/calibre/utils/open_with/osx.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2015, Kovid Goyal ' + +import os, plistlib, re, mimetypes, subprocess +from collections import defaultdict + +from calibre.ptempfile import TemporaryDirectory +from calibre.utils.icu import numeric_sort_key + +application_locations = ('/Applications', '~/Applications', '~/Desktop') + +# Public UTI MAP {{{ +def generate_public_uti_map(): + from lxml import etree + import html5lib, urllib + raw = urllib.urlopen( + 'https://developer.apple.com/library/ios/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html').read() + root = html5lib.parse(raw, treebuilder='lxml', namespaceHTMLElements=False) + table = root.xpath('//table')[0] + data = {} + for tr in table.xpath('descendant::tr')[1:]: + td = tr.xpath('descendant::td') + identifier = etree.tostring(td[0], method='text', encoding=unicode).strip() + tags = etree.tostring(td[2], method='text', encoding=unicode).strip() + identifier = identifier.split()[0].replace('\u200b', '') + exts = [x.strip()[1:].lower() for x in tags.split(',') if x.strip().startswith('.')] + for ext in exts: + data[ext] = identifier + lines = ['PUBLIC_UTI_MAP = {'] + for ext in sorted(data): + r = ("'" + ext + "':").ljust(16) + lines.append((' ' * 4) + r + "'" + data[ext] + "',") + lines.append('}') + with open(__file__, 'r+b') as f: + raw = f.read() + f.seek(0) + nraw = re.sub(r'^PUBLIC_UTI_MAP = .+?}', '\n'.join(lines), raw, flags=re.MULTILINE | re.DOTALL) + f.truncate(), f.write(nraw) +# Generated by generate_public_uti_map() +PUBLIC_UTI_MAP = { + '3g2': 'public.3gpp2', + '3gp': 'public.3gpp', + '3gp2': 'public.3gpp2', + '3gpp': 'public.3gpp', + 'aif': 'public.aiff-audio', + 'aifc': 'public.aifc-audio', + 'aiff': 'public.aiff-audio', + 'app': 'com.apple.application-bundle', + 'applescript': 'com.apple.applescript.text', + 'au': 'public.ulaw-audio', + 'avi': 'public.avi', + 'bin': 'com.apple.macbinary-archive', + 'bundle': 'com.apple.bundle', + 'c': 'public.c-source', + 'c++': 'public.c-plus-plus-source', + 'caf': 'com.apple.coreaudio-format', + 'cc': 'public.c-plus-plus-source', + 'class': 'com.sun.java-class', + 'command': 'public.shell-script', + 'cp': 'public.c-plus-plus-source', + 'cpio': 'public.cpio-archive', + 'cpp': 'public.c-plus-plus-source', + 'csh': 'public.csh-script', + 'cxx': 'public.c-plus-plus-source', + 'defs': 'public.mig-source', + 'dfont': 'com.apple.truetype-datafork-suitcase-font', + 'dll': 'com.microsoft.windows-dynamic-link-library', + 'exe': 'com.microsoft.windows-executable', + 'exp': 'com.apple.symbol-export', + 'framework': 'com.apple.framework', + 'gtar': 'org.gnu.gnu-tar-archive', + 'gz': 'org.gnu.gnu-zip-archive', + 'gzip': 'org.gnu.gnu-zip-archive', + 'h': 'public.c-header', + 'h++': 'public.c-plus-plus-header', + 'hpp': 'public.c-plus-plus-header', + 'hqx': 'com.apple.binhex-archive', + 'htm': 'public.html', + 'html': 'public.html', + 'hxx': 'public.c-plus-plus-header', + 'icc': 'com.apple.colorsync-profile', + 'icm': 'com.apple.colorsync-profile', + 'icns': 'com.apple.icns', + 'jar': 'com.sun.java-archive', + 'jav': 'com.sun.java-source', + 'java': 'com.sun.java-source', + 'javascript': 'com.netscape.javascript-source', + 'jnlp': 'com.sun.java-web-start', + 'jp2': 'public.jpeg-2000', + 'jpeg': 'public.jpeg', + 'jpg': 'public.jpeg', + 'js': 'com.netscape.javascript-source', + 'jscript': 'com.netscape.javascript-source', + 'm': 'public.objective-c-source', + 'm15': 'public.mpeg', + 'm4a': 'public.mpeg-4-audio', + 'm4b': 'com.apple.protected-mpeg-4-audio', + 'm4p': 'com.apple.protected-mpeg-4-audio', + 'm75': 'public.mpeg', + 'mdimporter': 'com.apple.metadata-importer', + 'mig': 'public.mig-source', + 'mm': 'public.objective-c-plus-plus-source', + 'mov': 'com.apple.quicktime-movie', + 'mp3': 'public.mp3', + 'mp4': 'public.mpeg-4', + 'mpeg': 'public.mpeg', + 'mpg': 'public.mpeg', + 'o': 'public.object-code', + 'otf': 'public.opentype-font', + 'pct': 'com.apple.pict', + 'pf': 'com.apple.colorsync-profile', + 'pfa': 'com.adobe.postscript.pfa-font', + 'pfb': 'com.adobe.postscript-pfb-font', + 'ph3': 'public.php-script', + 'ph4': 'public.php-script', + 'php': 'public.php-script', + 'php3': 'public.php-script', + 'php4': 'public.php-script', + 'phtml': 'public.php-script', + 'pic': 'com.apple.pict', + 'pict': 'com.apple.pict', + 'pl': 'public.perl-script', + 'plugin': 'com.apple.plugin', + 'pm': 'public.perl-script', + 'png': 'public.png', + 'pntg': 'com.apple.macpaint-image', + 'py': 'public.python-script', + 'qif': 'com.apple.quicktime-image', + 'qt': 'com.apple.quicktime-movie', + 'qtif': 'com.apple.quicktime-image', + 'qtz': 'com.apple.quartz-composer-composition', + 'r': 'com.apple.rez-source', + 'rb': 'public.ruby-script', + 'rbw': 'public.ruby-script', + 'rtf': 'public.rtf', + 'rtfd': 'com.apple.rtfd', + 's': 'public.assembly-source', + 'scpt': 'com.apple.applescript.script', + 'sh': 'public.shell-script', + 'snd': 'public.ulaw-audio', + 'suit': 'com.apple.font-suitcase', + 'tar': 'public.tar-archive', + 'tgz': 'org.gnu.gnu-zip-tar-archive', + 'tif': 'public.tiff', + 'tiff': 'public.tiff', + 'ttc': 'public.truetype-collection-font', + 'ttf': 'public.truetype-ttf-font', + 'txt': 'public.plain-text', + 'ulw': 'public.ulaw-audio', + 'vcard': 'public.vcard', + 'vcf': 'public.vcard', + 'vfw': 'public.avi', + 'wdgt': 'com.apple.dashboard-widget', + 'xbm': 'public.xbitmap-image', + 'xml': 'public.xml', + 'zip': 'com.pkware.zip-archive', +} +PUBLIC_UTI_RMAP = defaultdict(set) +for ext, uti in PUBLIC_UTI_MAP.iteritems(): + PUBLIC_UTI_RMAP[uti].add(ext) +PUBLIC_UTI_RMAP = dict(PUBLIC_UTI_RMAP) + +# }}} + +def find_applications_in(base): + try: + entries = os.listdir(base) + except EnvironmentError: + return + for name in entries: + path = os.path.join(base, name) + if os.path.isdir(path): + if name.lower().endswith('.app'): + yield path + else: + for app in find_applications_in(path): + yield app + +def find_applications(): + for base in application_locations: + base = os.path.expanduser(base) + for app in find_applications_in(base): + yield app + +def get_extensions_from_utis(utis, plist): + declared_utis = defaultdict(set) + for key in ('UTExportedTypeDeclarations', 'UTImportedTypeDeclarations'): + for decl in plist.get(key, ()): + if isinstance(decl, dict): + uti = decl.get('UTTypeIdentifier') + if isinstance(uti, basestring): + spec = decl.get('UTTypeTagSpecification') + if isinstance(spec, dict): + ext = spec.get('public.filename-extension') + if ext: + declared_utis[uti] |= set(ext) + types = spec.get('public.mime-type') + if types: + for mt in types: + for ext in mimetypes.guess_all_extensions(mt, strict=False): + declared_utis[uti].add(ext.lower()[1:]) + ans = set() + for uti in utis: + ans |= declared_utis[uti] + ans |= PUBLIC_UTI_RMAP.get(uti, set()) + return ans + +def get_bundle_data(path): + path = os.path.abspath(path) + info = os.path.join(path, 'Contents', 'Info.plist') + ans = { + 'name': os.path.splitext(os.path.basename(path))[0], + 'path': path, + } + try: + plist = plistlib.readPlist(info) + except Exception: + return None + ans['name'] = plist.get('CFBundleDisplayName') or plist.get('CFBundleName') or ans['name'] + icfile = plist.get('CFBundleIconFile') + if icfile: + icfile = os.path.join(path, 'Contents', 'Resources', icfile) + if not os.path.exists(icfile): + icfile += '.icns' + if os.path.exists(icfile): + ans['icon_file'] = icfile + bid = plist.get('CFBundleIdentifier') + if bid: + ans['identifier'] = bid + ans['extensions'] = extensions = set() + for dtype in plist.get('CFBundleDocumentTypes', ()): + utis = frozenset(dtype.get('LSItemContentTypes', ())) + if utis: + extensions |= get_extensions_from_utis(utis, plist) + else: + for ext in dtype.get('CFBundleTypeExtensions', ()): + if isinstance(ext, basestring): + extensions.add(ext.lower()) + for mt in dtype.get('CFBundleTypeMIMETypes', ()): + if isinstance(mt, basestring): + for ext in mimetypes.guess_all_extensions(mt, strict=False): + extensions.add(ext.lower()) + return ans + +def find_programs(extensions): + extensions = frozenset(extensions) + ans = [] + for app in find_applications(): + try: + app = get_bundle_data(app) + except Exception: + import traceback + traceback.print_exc() + continue + if app and app['extensions'].intersection(extensions): + ans.append(app) + return ans + +def get_icon(path, pixmap_to_data=None, as_data=False, size=64): + if not path: + return + with TemporaryDirectory() as tdir: + iconset = os.path.join(tdir, 'output.iconset') + try: + subprocess.check_call(['iconutil', '-c', 'iconset', '-o', 'output.iconset', path], cwd=tdir) + except subprocess.CalledProcessError: + return + try: + names = os.listdir(iconset) + except EnvironmentError: + return + if not names: + return + from PyQt5.Qt import QImage, Qt + names.sort(key=numeric_sort_key) + for name in names: + m = re.search('(\d+)x\d+', name) + if m is not None and int(m.group(1)) >= size: + ans = QImage(os.path.join(iconset, name)) + if not ans.isNull(): + break + else: + return + ans = ans.scaled(size, size, transformMode=Qt.SmoothTransformation) + if as_data: + ans = pixmap_to_data(ans) + return ans + +def entry_to_cmdline(entry, path): + app = entry['path'] + if not os.path.isdir(app) and 'identifier' in entry: + return ['open', '-b', entry['identifier'], path] + return ['open', '-a', app, path]