Allow specifying rules to perform arbitrary actions in kitty when opening URLs
This commit is contained in:
parent
be1ff61e4a
commit
0d6bca3e5d
@ -1016,9 +1016,17 @@ class Boss:
|
||||
tab.set_active_window(window_id)
|
||||
|
||||
def open_url(self, url: str, program: Optional[Union[str, List[str]]] = None, cwd: Optional[str] = None) -> None:
|
||||
if url:
|
||||
if isinstance(program, str):
|
||||
program = to_cmdline(program)
|
||||
if not url:
|
||||
return
|
||||
if isinstance(program, str):
|
||||
program = to_cmdline(program)
|
||||
found_action = False
|
||||
if program is None:
|
||||
from .open_actions import actions_for_url
|
||||
for action in actions_for_url(url):
|
||||
found_action = True
|
||||
self.dispatch_action(action)
|
||||
if not found_action:
|
||||
open_url(url, program or self.opts.open_url_with, cwd=cwd)
|
||||
|
||||
def destroy(self) -> None:
|
||||
|
||||
180
kitty/open_actions.py
Normal file
180
kitty/open_actions.py
Normal file
@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
|
||||
import os
|
||||
import posixpath
|
||||
from contextlib import suppress
|
||||
from functools import lru_cache
|
||||
from typing import Generator, Iterable, List, NamedTuple, Optional, Tuple, cast
|
||||
from urllib.parse import ParseResult, unquote, urlparse
|
||||
|
||||
from .conf.utils import to_bool, to_cmdline
|
||||
from .config import KeyAction, parse_key_action
|
||||
from .constants import config_dir
|
||||
from .typing import MatchType
|
||||
from .utils import expandvars, log_error
|
||||
|
||||
|
||||
class MatchCriteria(NamedTuple):
|
||||
type: MatchType
|
||||
value: str
|
||||
|
||||
|
||||
class OpenAction(NamedTuple):
|
||||
match_criteria: Tuple[MatchCriteria, ...]
|
||||
actions: Tuple[KeyAction, ...]
|
||||
|
||||
|
||||
def parse(lines: Iterable[str]) -> Generator[OpenAction, None, None]:
|
||||
match_criteria: List[MatchCriteria] = []
|
||||
actions: List[KeyAction] = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
if not line:
|
||||
if match_criteria and actions:
|
||||
yield OpenAction(tuple(match_criteria), tuple(actions))
|
||||
match_criteria = []
|
||||
actions = []
|
||||
continue
|
||||
parts = line.split(maxsplit=1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
key, rest = parts
|
||||
key = key.lower()
|
||||
if key == 'action':
|
||||
with to_cmdline.filter_env_vars('URL', 'FILE_PATH', 'FILE', 'FRAGMENT'):
|
||||
x = parse_key_action(rest)
|
||||
if x is not None:
|
||||
actions.append(x)
|
||||
elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'has_fragment'):
|
||||
if key != 'url':
|
||||
rest = rest.lower()
|
||||
match_criteria.append(MatchCriteria(cast(MatchType, key), rest))
|
||||
else:
|
||||
log_error(f'Ignoring malformed open actions line: {line}')
|
||||
|
||||
if match_criteria and actions:
|
||||
yield OpenAction(tuple(match_criteria), tuple(actions))
|
||||
|
||||
|
||||
def url_matches_criterion(purl: 'ParseResult', url: str, mc: MatchCriteria) -> bool:
|
||||
if mc.type == 'url':
|
||||
import re
|
||||
try:
|
||||
pat = re.compile(mc.value)
|
||||
except re.error:
|
||||
return False
|
||||
return pat.search(url) is not None
|
||||
|
||||
if mc.type == 'mime':
|
||||
import fnmatch
|
||||
from mimetypes import guess_type
|
||||
try:
|
||||
mt = guess_type(purl.path)[0]
|
||||
except Exception:
|
||||
return False
|
||||
if mt is None:
|
||||
return False
|
||||
mt = mt.lower()
|
||||
for mpat in mc.value.split(','):
|
||||
mpat = mpat.strip()
|
||||
with suppress(Exception):
|
||||
if fnmatch.fnmatchcase(mt, mpat):
|
||||
return True
|
||||
return False
|
||||
|
||||
if mc.type == 'ext':
|
||||
if not purl.path:
|
||||
return False
|
||||
path = purl.path.lower()
|
||||
for ext in mc.value.split(','):
|
||||
ext = ext.strip()
|
||||
if path.endswith('.' + ext):
|
||||
return True
|
||||
return False
|
||||
|
||||
if mc.type == 'protocol':
|
||||
protocol = (purl.scheme or 'file').lower()
|
||||
for key in mc.value.split(','):
|
||||
if key.strip() == protocol:
|
||||
return True
|
||||
return False
|
||||
|
||||
if mc.type == 'has_fragment':
|
||||
return to_bool(mc.value) == bool(purl.fragment)
|
||||
|
||||
if mc.type == 'path':
|
||||
import fnmatch
|
||||
try:
|
||||
return fnmatch.fnmatchcase(purl.path.lower(), mc.value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if mc.type == 'file':
|
||||
import fnmatch
|
||||
import posixpath
|
||||
try:
|
||||
fname = posixpath.basename(purl.path)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
return fnmatch.fnmatchcase(fname.lower(), mc.value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def url_matches_criteria(purl: 'ParseResult', url: str, criteria: Iterable[MatchCriteria]) -> bool:
|
||||
for x in criteria:
|
||||
try:
|
||||
if not url_matches_criterion(purl, url, x):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Generator[KeyAction, None, None]:
|
||||
try:
|
||||
purl = urlparse(url)
|
||||
except Exception:
|
||||
return
|
||||
path = unquote(purl.path)
|
||||
|
||||
env = {
|
||||
'URL': url,
|
||||
'FILE_PATH': path,
|
||||
'FILE': posixpath.basename(path),
|
||||
'FRAGMENT': purl.fragment
|
||||
}
|
||||
|
||||
def expand(x: str) -> str:
|
||||
return expandvars(x, env, fallback_to_os_env=False)
|
||||
|
||||
for action in actions:
|
||||
if url_matches_criteria(purl, url, action.match_criteria):
|
||||
for ac in action.actions:
|
||||
yield ac._replace(args=tuple(map(expand, ac.args)))
|
||||
return
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def load_open_actions() -> Tuple[OpenAction, ...]:
|
||||
try:
|
||||
f = open(os.path.join(config_dir, 'open-actions.conf'))
|
||||
except FileNotFoundError:
|
||||
return ()
|
||||
with f:
|
||||
return tuple(parse(f))
|
||||
|
||||
|
||||
def actions_for_url(url: str, actions_spec: Optional[str] = None) -> Generator[KeyAction, None, None]:
|
||||
if actions_spec is None:
|
||||
actions = load_open_actions()
|
||||
else:
|
||||
actions = tuple(parse(actions_spec.splitlines()))
|
||||
yield from actions_for_url_from_list(url, actions)
|
||||
@ -18,4 +18,5 @@ TermManagerType = LoopType = Debug = GraphicsCommandType = None
|
||||
CompletedProcess = Tuple
|
||||
TypedDict = dict
|
||||
EdgeLiteral = str
|
||||
MatchType = str
|
||||
Protocol = object
|
||||
|
||||
@ -43,6 +43,7 @@ from .config import ( # noqa; noqa
|
||||
)
|
||||
|
||||
EdgeLiteral = Literal['left', 'top', 'right', 'bottom']
|
||||
MatchType = Literal['mime', 'ext', 'protocol', 'file', 'path', 'url', 'has_fragment']
|
||||
GRT_a = Literal['t', 'T', 'q', 'p', 'd']
|
||||
GRT_f = Literal[24, 32, 100]
|
||||
GRT_t = Literal['d', 'f', 't', 's']
|
||||
|
||||
57
kitty_tests/open_actions.py
Normal file
57
kitty_tests/open_actions.py
Normal file
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# vim:fileencoding=utf-8
|
||||
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
from . import BaseTest
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_env(**kw):
|
||||
orig = os.environ.copy()
|
||||
for k, v in kw.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
yield
|
||||
os.environ.clear()
|
||||
os.environ.update(orig)
|
||||
|
||||
|
||||
class TestOpenActions(BaseTest):
|
||||
|
||||
def test_parsing_of_open_actions(self):
|
||||
from kitty.open_actions import actions_for_url, KeyAction
|
||||
spec = '''
|
||||
protocol file
|
||||
mime text/*
|
||||
has_fragment yes
|
||||
AcTion launch $EDITOR $FILE_PATH $FRAGMENT
|
||||
action
|
||||
|
||||
protocol file
|
||||
mime text/*
|
||||
action ignored
|
||||
|
||||
ext py,txt
|
||||
action one
|
||||
action two
|
||||
'''
|
||||
|
||||
def actions(url):
|
||||
with patch_env(EDITOR='editor', FILE_PATH='notgood'):
|
||||
return tuple(actions_for_url(url, spec))
|
||||
|
||||
def single(url, func, *args):
|
||||
acts = actions(url)
|
||||
self.ae(len(acts), 1)
|
||||
self.ae(acts[0].func, func)
|
||||
self.ae(acts[0].args, args)
|
||||
|
||||
single('file://hostname/tmp/moo.txt#23', 'launch', 'editor', '/tmp/moo.txt', '23')
|
||||
single('some thing.txt', 'ignored')
|
||||
self.ae(actions('x:///a.txt'), (KeyAction('one', ()), KeyAction('two', ())))
|
||||
Loading…
x
Reference in New Issue
Block a user