Start work on OS X implementation of Open With

This commit is contained in:
Kovid Goyal 2015-03-07 12:33:10 +05:30
parent 9e4b1f71b9
commit 26e4ca1852
2 changed files with 329 additions and 3 deletions

View File

@ -11,13 +11,13 @@ from threading import Thread
from functools import partial from functools import partial
from PyQt5.Qt import ( from PyQt5.Qt import (
QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt,
QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray, QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray,
QBuffer, QPixmap, QAction, QKeySequence) QBuffer, QPixmap, QAction, QKeySequence)
from calibre import as_unicode from calibre import as_unicode
from calibre.constants import iswindows, isosx 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.widgets2 import Dialog
from calibre.gui2.progress_indicator import ProgressIndicator from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.utils.config import JSONConfig from calibre.utils.config import JSONConfig
@ -123,7 +123,34 @@ if iswindows:
# }}} # }}}
elif isosx: elif isosx:
# OS X {{{
oprefs = JSONConfig('osx_open_with') 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: else:
# XDG {{{ # XDG {{{
oprefs = JSONConfig('xdg_open_with') 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.la = la = QLabel(_('Choose a program to open %s files') % self.file_type.upper())
self.plist = pl = QListWidget(self) self.plist = pl = QListWidget(self)
pl.doubleClicked.connect(self.accept)
pl.setIconSize(QSize(48, 48)), pl.setSpacing(5) pl.setIconSize(QSize(48, 48)), pl.setSpacing(5)
pl.doubleClicked.connect(self.accept) pl.doubleClicked.connect(self.accept)
l.addWidget(la), l.addWidget(pl) l.addWidget(la), l.addWidget(pl)
@ -368,6 +396,6 @@ def register_keyboard_shortcuts(gui=None, finalize=False):
if __name__ == '__main__': if __name__ == '__main__':
from pprint import pprint from pprint import pprint
app = QApplication([]) app = Application([])
pprint(choose_program('pdf')) pprint(choose_program('pdf'))
del app del app

View File

@ -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 <kovid at kovidgoyal.net>'
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]