From c920ab72983f1a1a741c9a77c5c20c6dff7176bb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 27 Feb 2015 11:23:09 +0530 Subject: [PATCH] Refactor open with code --- src/calibre/gui2/open_with.py | 238 ++---------------------- src/calibre/utils/open_with/__init__.py | 1 + src/calibre/utils/open_with/linux.py | 223 ++++++++++++++++++++++ 3 files changed, 238 insertions(+), 224 deletions(-) create mode 100644 src/calibre/utils/open_with/__init__.py create mode 100644 src/calibre/utils/open_with/linux.py diff --git a/src/calibre/gui2/open_with.py b/src/calibre/gui2/open_with.py index 2289aeddd3..e48d320d62 100644 --- a/src/calibre/gui2/open_with.py +++ b/src/calibre/gui2/open_with.py @@ -6,8 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import os, re, shlex, cPickle -from collections import defaultdict +import os from threading import Thread from functools import partial @@ -16,14 +15,12 @@ from PyQt5.Qt import ( QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray, QBuffer, QPixmap) -from calibre import force_unicode, walk, guess_type, prints, as_unicode -from calibre.constants import iswindows, isosx, filesystem_encoding, cache_dir +from calibre import as_unicode +from calibre.constants import iswindows, isosx from calibre.gui2 import error_dialog, choose_files from calibre.gui2.widgets2 import Dialog from calibre.gui2.progress_indicator import ProgressIndicator from calibre.utils.config import JSONConfig -from calibre.utils.icu import numeric_sort_key as sort_key -from calibre.utils.localization import canonicalize_lang, get_lang DESC_ROLE = Qt.UserRole ENTRY_ROLE = DESC_ROLE + 1 @@ -59,190 +56,19 @@ if iswindows: elif isosx: oprefs = JSONConfig('osx_open_with') else: + # XDG {{{ oprefs = JSONConfig('xdg_open_with') - # XDG find_programs {{{ - def parse_localized_key(key): - name, rest = key.partition('[')[0::2] - if not rest: - return name, None - rest = rest[:-1] - lang = re.split(r'[_.@]', rest)[0] - return name, canonicalize_lang(lang) + from calibre.utils.open_with.linux import entry_to_cmdline, find_programs, entry_sort_key - def unquote_exec(val): - val = val.replace(r'\\', '\\') - return shlex.split(val) - - def parse_desktop_file(path): - gpat = re.compile(r'^\[(.+?)\]\s*$') - kpat = re.compile(r'^([-a-zA-Z0-9\[\]@_.]+)\s*=\s*(.+)$') - try: - with open(path, 'rb') as f: - raw = f.read().decode('utf-8') - except (EnvironmentError, UnicodeDecodeError): - return - group = None - ans = {} - ans['desktop_file_path'] = path - for line in raw.splitlines(): - m = gpat.match(line) - if m is not None: - if group == 'Desktop Entry': - break - group = m.group(1) - continue - if group == 'Desktop Entry': - m = kpat.match(line) - if m is not None: - k, v = m.group(1), m.group(2) - if k == 'Hidden' and v == 'true': - return - if k == 'Type' and v != 'Application': - return - if k == 'Exec': - cmdline = unquote_exec(v) - if cmdline and (not os.path.isabs(cmdline[0]) or os.access(cmdline[0], os.X_OK)): - ans[k] = cmdline - elif k == 'MimeType': - ans[k] = frozenset(x.strip() for x in v.split(';')) - elif k in {'Name', 'GenericName', 'Comment', 'Icon'} or '[' in k: - name, lang = parse_localized_key(k) - if name not in ans: - ans[name] = {} - if isinstance(ans[name], type('')): - ans[name] = {None:ans[name]} - ans[name][lang] = v - else: - ans[k] = v - if 'Exec' in ans and 'MimeType' in ans and 'Name' in ans: - return ans - - icon_data = None - - def find_icons(): - global icon_data - if icon_data is not None: - return icon_data - base_dirs = [os.path.expanduser('~/.icons')] + [ - os.path.join(b, 'icons') for b in os.environ.get( - 'XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(os.pathsep)] + [ - '/usr/share/pixmaps'] - ans = defaultdict(list) - sz_pat = re.compile(r'/((?:\d+x\d+)|scalable)/') - cache_file = os.path.join(cache_dir(), 'icon-theme-cache.pickle') - exts = {'.svg', '.png', '.xpm'} - - def read_icon_theme_dir(dirpath): - ans = defaultdict(list) - for path in walk(dirpath): - bn = os.path.basename(path) - name, ext = os.path.splitext(bn) - if ext in exts: - sz = sz_pat.findall(path) - if sz: - sz = sz[-1] - if sz == 'scalable': - sz = 100000 - else: - sz = int(sz.partition('x')[0]) - idx = len(ans[name]) - ans[name].append((-sz, idx, sz, path)) - for icons in ans.itervalues(): - icons.sort() - return {k:(-v[0][2], v[0][3]) for k, v in ans.iteritems()} - - try: - with open(cache_file, 'rb') as f: - cache = cPickle.load(f) - mtimes, cache = cache['mtimes'], cache['data'] - except Exception: - mtimes, cache = defaultdict(int), defaultdict(dict) - - seen_dirs = set() - changed = False - - for loc in base_dirs: - try: - subdirs = os.listdir(loc) - except EnvironmentError: - continue - for dname in subdirs: - d = os.path.join(loc, dname) - if os.path.isdir(d): - try: - mtime = os.stat(d).st_mtime - except EnvironmentError: - continue - seen_dirs.add(d) - if mtime != mtimes[d]: - changed = True - try: - cache[d] = read_icon_theme_dir(d) - except Exception: - prints('Failed to read icon theme dir: %r with error:' % d) - import traceback - traceback.print_exc() - mtimes[d] = mtime - for name, data in cache[d].iteritems(): - ans[name].append(data) - for removed in set(mtimes) - seen_dirs: - mtimes.pop(removed), cache.pop(removed) - changed = True - - if changed: - try: - with open(cache_file, 'wb') as f: - cPickle.dump({'data':cache, 'mtimes':mtimes}, f, -1) - except Exception: - import traceback - traceback.print_exc() - - for icons in ans.itervalues(): - icons.sort() - icon_data = {k:v[0][1] for k, v in ans.iteritems()} - return icon_data - - def localize_string(data): - lang = canonicalize_lang(get_lang()) - return data.get(lang, data.get(None)) or '' - - def find_programs(extensions): - extensions = {ext.lower() for ext in extensions} - data_dirs = [os.environ.get('XDG_DATA_HOME') or os.path.expanduser('~/.local/share')] - data_dirs += (os.environ.get('XDG_DATA_DIRS') or '/usr/local/share/:/usr/share/').split(os.pathsep) - data_dirs = [force_unicode(x, filesystem_encoding).rstrip(os.sep) for x in data_dirs] - data_dirs = [x for x in data_dirs if x and os.path.isdir(x)] - desktop_files = {} - mime_types = {guess_type('file.' + ext)[0] for ext in extensions} - ans = [] - for base in data_dirs: - for f in walk(os.path.join(base, 'applications')): - if f.endswith('.desktop'): - bn = os.path.basename(f) - if f not in desktop_files: - desktop_files[bn] = f - for bn, path in desktop_files.iteritems(): - try: - data = parse_desktop_file(path) - except Exception: - import traceback - traceback.print_exc() - continue - if data is not None and mime_types.intersection(data['MimeType']): - icon = data.get('Icon', {}).get(None) - if icon and not os.path.isabs(icon): - icon = find_icons().get(icon) - if icon: - data['Icon'] = icon - else: - data.pop('Icon') - for k in ('Name', 'GenericName', 'Comment'): - val = data.get(k) - if val: - data[k] = localize_string(val) - ans.append(data) - ans.sort(key=lambda d:sort_key(d.get('Name'))) - return ans + def entry_to_icon_text(entry): + data = entry.get('icon_data') + if data is None: + icon = QIcon(I('blank.png')) + else: + pmap = QPixmap() + pmap.loadFromData(bytes(data)) + icon = QIcon(pmap) + return icon, entry['Name'] def entry_to_item(entry, parent): icon_path = entry.get('Icon') or I('blank.png') @@ -273,42 +99,6 @@ else: entry['icon_data'] = pixmap_to_data(pmap) entry['MimeType'] = tuple(entry['MimeType']) return entry - - def entry_sort_key(entry): - return sort_key(entry['Name']) - - def entry_to_icon_text(entry): - data = entry.get('icon_data') - if data is None: - icon = QIcon(I('blank.png')) - else: - pmap = QPixmap() - pmap.loadFromData(bytes(data)) - icon = QIcon(pmap) - return icon, entry['Name'] - - def entry_to_cmdline(entry, path): - path = os.path.abspath(path) - rmap = { - 'f':path, 'F':path, 'u':'file://'+path, 'U':'file://'+path, '%':'%', - 'c':entry.get('Name', ''), 'k':entry.get('desktop_file_path', ''), - } - def replace(match): - char = match.group()[-1] - repl = rmap.get(char) - return match.group() if repl is None else repl - sub = re.compile(r'%[fFuUdDnNickvm%]').sub - cmd = entry['Exec'] - try: - idx = cmd.index('%i') - except ValueError: - pass - else: - icon = entry.get('Icon') - repl = ['--icon', icon] if icon else [] - cmd[idx:idx+1] = repl - return cmd[:1] + [sub(replace, x) for x in cmd[1:]] - # }}} class ChooseProgram(Dialog): # {{{ diff --git a/src/calibre/utils/open_with/__init__.py b/src/calibre/utils/open_with/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/calibre/utils/open_with/__init__.py @@ -0,0 +1 @@ + diff --git a/src/calibre/utils/open_with/linux.py b/src/calibre/utils/open_with/linux.py new file mode 100644 index 0000000000..3b6c1197af --- /dev/null +++ b/src/calibre/utils/open_with/linux.py @@ -0,0 +1,223 @@ +#!/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 re, shlex, os, cPickle +from collections import defaultdict + +from calibre import walk, guess_type, prints, force_unicode +from calibre.constants import filesystem_encoding, cache_dir +from calibre.utils.icu import numeric_sort_key as sort_key +from calibre.utils.localization import canonicalize_lang, get_lang + +def parse_localized_key(key): + name, rest = key.partition('[')[0::2] + if not rest: + return name, None + rest = rest[:-1] + lang = re.split(r'[_.@]', rest)[0] + return name, canonicalize_lang(lang) + +def unquote_exec(val): + val = val.replace(r'\\', '\\') + return shlex.split(val) + +def parse_desktop_file(path): + gpat = re.compile(r'^\[(.+?)\]\s*$') + kpat = re.compile(r'^([-a-zA-Z0-9\[\]@_.]+)\s*=\s*(.+)$') + try: + with open(path, 'rb') as f: + raw = f.read().decode('utf-8') + except (EnvironmentError, UnicodeDecodeError): + return + group = None + ans = {} + ans['desktop_file_path'] = path + for line in raw.splitlines(): + m = gpat.match(line) + if m is not None: + if group == 'Desktop Entry': + break + group = m.group(1) + continue + if group == 'Desktop Entry': + m = kpat.match(line) + if m is not None: + k, v = m.group(1), m.group(2) + if k == 'Hidden' and v == 'true': + return + if k == 'Type' and v != 'Application': + return + if k == 'Exec': + cmdline = unquote_exec(v) + if cmdline and (not os.path.isabs(cmdline[0]) or os.access(cmdline[0], os.X_OK)): + ans[k] = cmdline + elif k == 'MimeType': + ans[k] = frozenset(x.strip() for x in v.split(';')) + elif k in {'Name', 'GenericName', 'Comment', 'Icon'} or '[' in k: + name, lang = parse_localized_key(k) + if name not in ans: + ans[name] = {} + if isinstance(ans[name], type('')): + ans[name] = {None:ans[name]} + ans[name][lang] = v + else: + ans[k] = v + if 'Exec' in ans and 'MimeType' in ans and 'Name' in ans: + return ans + +icon_data = None + +def find_icons(): + global icon_data + if icon_data is not None: + return icon_data + base_dirs = [os.path.expanduser('~/.icons')] + [ + os.path.join(b, 'icons') for b in os.environ.get( + 'XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(os.pathsep)] + [ + '/usr/share/pixmaps'] + ans = defaultdict(list) + sz_pat = re.compile(r'/((?:\d+x\d+)|scalable)/') + cache_file = os.path.join(cache_dir(), 'icon-theme-cache.pickle') + exts = {'.svg', '.png', '.xpm'} + + def read_icon_theme_dir(dirpath): + ans = defaultdict(list) + for path in walk(dirpath): + bn = os.path.basename(path) + name, ext = os.path.splitext(bn) + if ext in exts: + sz = sz_pat.findall(path) + if sz: + sz = sz[-1] + if sz == 'scalable': + sz = 100000 + else: + sz = int(sz.partition('x')[0]) + idx = len(ans[name]) + ans[name].append((-sz, idx, sz, path)) + for icons in ans.itervalues(): + icons.sort() + return {k:(-v[0][2], v[0][3]) for k, v in ans.iteritems()} + + try: + with open(cache_file, 'rb') as f: + cache = cPickle.load(f) + mtimes, cache = cache['mtimes'], cache['data'] + except Exception: + mtimes, cache = defaultdict(int), defaultdict(dict) + + seen_dirs = set() + changed = False + + for loc in base_dirs: + try: + subdirs = os.listdir(loc) + except EnvironmentError: + continue + for dname in subdirs: + d = os.path.join(loc, dname) + if os.path.isdir(d): + try: + mtime = os.stat(d).st_mtime + except EnvironmentError: + continue + seen_dirs.add(d) + if mtime != mtimes[d]: + changed = True + try: + cache[d] = read_icon_theme_dir(d) + except Exception: + prints('Failed to read icon theme dir: %r with error:' % d) + import traceback + traceback.print_exc() + mtimes[d] = mtime + for name, data in cache[d].iteritems(): + ans[name].append(data) + for removed in set(mtimes) - seen_dirs: + mtimes.pop(removed), cache.pop(removed) + changed = True + + if changed: + try: + with open(cache_file, 'wb') as f: + cPickle.dump({'data':cache, 'mtimes':mtimes}, f, -1) + except Exception: + import traceback + traceback.print_exc() + + for icons in ans.itervalues(): + icons.sort() + icon_data = {k:v[0][1] for k, v in ans.iteritems()} + return icon_data + +def localize_string(data): + lang = canonicalize_lang(get_lang()) + return data.get(lang, data.get(None)) or '' + +def find_programs(extensions): + extensions = {ext.lower() for ext in extensions} + data_dirs = [os.environ.get('XDG_DATA_HOME') or os.path.expanduser('~/.local/share')] + data_dirs += (os.environ.get('XDG_DATA_DIRS') or '/usr/local/share/:/usr/share/').split(os.pathsep) + data_dirs = [force_unicode(x, filesystem_encoding).rstrip(os.sep) for x in data_dirs] + data_dirs = [x for x in data_dirs if x and os.path.isdir(x)] + desktop_files = {} + mime_types = {guess_type('file.' + ext)[0] for ext in extensions} + ans = [] + for base in data_dirs: + for f in walk(os.path.join(base, 'applications')): + if f.endswith('.desktop'): + bn = os.path.basename(f) + if f not in desktop_files: + desktop_files[bn] = f + for bn, path in desktop_files.iteritems(): + try: + data = parse_desktop_file(path) + except Exception: + import traceback + traceback.print_exc() + continue + if data is not None and mime_types.intersection(data['MimeType']): + icon = data.get('Icon', {}).get(None) + if icon and not os.path.isabs(icon): + icon = find_icons().get(icon) + if icon: + data['Icon'] = icon + else: + data.pop('Icon') + for k in ('Name', 'GenericName', 'Comment'): + val = data.get(k) + if val: + data[k] = localize_string(val) + ans.append(data) + ans.sort(key=lambda d:sort_key(d.get('Name'))) + return ans + +def entry_sort_key(entry): + return sort_key(entry['Name']) + +def entry_to_cmdline(entry, path): + path = os.path.abspath(path) + rmap = { + 'f':path, 'F':path, 'u':'file://'+path, 'U':'file://'+path, '%':'%', + 'c':entry.get('Name', ''), 'k':entry.get('desktop_file_path', ''), + } + def replace(match): + char = match.group()[-1] + repl = rmap.get(char) + return match.group() if repl is None else repl + sub = re.compile(r'%[fFuUdDnNickvm%]').sub + cmd = entry['Exec'] + try: + idx = cmd.index('%i') + except ValueError: + pass + else: + icon = entry.get('Icon') + repl = ['--icon', icon] if icon else [] + cmd[idx:idx+1] = repl + return cmd[:1] + [sub(replace, x) for x in cmd[1:]]