diff --git a/.gitignore b/.gitignore index ec82e83..2fda33b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/README.md b/README.md index 7071505..c145b8b 100644 --- a/README.md +++ b/README.md @@ -11,34 +11,70 @@ ```ini [server/my-server1] -# enable = true # youtrack base url -base_url = https://youtrack.jetbrains.com +#base_url = https://youtrack.myserver1.com # the token for auth - is required and starts with perm: -api_token = perm:... +#api_token = -filter_label = YouTrack Jetbrains (filter) -issues_label = YouTrack Jetbrains (issues) +# displayed entry text +#issues_label = My bugtracker (issues) -issues_icon = youtrack -filter_icon = youtrack +# defaults to "youtrack" +#issues_icon = youtrack + +# displayed entry text +#filter_label = My bugtracker (filter) + +# defaults to "youtrack" +#filter_icon = youtrack + +# is prefixed to the entered filter +# Note: you can add the same server twice but with a different filter +#filter = + +# disables the automatic whitespace added after the prefix filter, defaults to False +#filter_dont_append_whitespace=False [server/my-server2] -# enable = true # youtrack base url -base_url = https://youtrack-server.foo.com +#base_url = https://youtrack.myserver2.com # the token for auth - is required and starts with perm: -api_token = perm:... +#api_token = + +# displayed entry text +#issues_label = My bugtracker (issues) + +# defaults to "youtrack" +#issues_icon = youtrack -filter_label = YouTrack Foo (filter) -issues_label = YouTrack Foo (issues) +# displayed entry text +#filter_label = My bugtracker (filter) -issues_icon = youtrack -filter_icon = youtrack +# defaults to "youtrack" +#filter_icon = youtrack + +# is prefixed to the entered filter +# Note: you can add the same server twice but with a different filter +#filter = + +# disables the automatic whitespace added after the prefix filter, defaults to False +#filter_dont_append_whitespace=False +``` + +* You can add the same server more than once but use different `filter` values that are prefixed to all queries. +* A space is added to the end of the prefix before the user input so that suggestions do not target the prefix +* Put your png icons in a subfolder youtrack and prefix them with `icon_` - in the example below ´test´ and ´xyz´ are valid identifiers in the ´youtrack.ini´: +``` ++ +|– youtrack.ini +|– youtrack/ + |– icon_test.png + |– icon_xyz.png ``` + ## Features ### Filter mode diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/api.py b/lib/api.py new file mode 100644 index 0000000..b4e35eb --- /dev/null +++ b/lib/api.py @@ -0,0 +1,132 @@ +from typing import Sequence, Callable, Union +from urllib import parse, request +from xml.dom import minidom +from xml.dom.minidom import Element + +from .util import get_as_xml, get_value, get_child_att_value + +class IntellisenseResult(object): + def __init__(self, full_option: str, prefix: Union[str, None], suffix: Union[str,None], option: str, start: int, end: int, description: str): + self.description = description + self.end = end + self.start = start + self.option = option + self.suffix = suffix + self.prefix = prefix + self.full_option = full_option + +class Issue(object): + def __init__(self, id: str, summary: str, description: str, url: str): + self.url = url + self.id = id + self.summary = summary + self.description = description + +class Api(): + AUTH_HEADER: str = 'Authorization' + TOKEN_PREFIX: str = 'Bearer ' + YOUTRACK_INTELLISENSE_ISSUE_API: str = '{base_url}/rest/issue/intellisense/?' + YOUTRACK_LIST_OF_ISSUES_API: str = '{base_url}/rest/issue?' + YOUTRACK_ISSUE: str = '{base_url}/issue/{id}' + YOUTRACK_ISSUES: str = '{base_url}/issues/?' + + def __init__(self, api_token: str, youtrack_url: str, dbg): + super().__init__() + self.dbg = dbg + self.api_token = api_token + self.youtrack_url = youtrack_url + + def open_url(self, http_url) -> str: + req = request.Request(http_url) + req.add_header(self.AUTH_HEADER, self.TOKEN_PREFIX + self.api_token) + + with request.urlopen(req) as resp: + content = resp.read() + return content + + def print(self, **kwargs): + toPrint = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()]) + self.dbg("[" + toPrint + "]") + + def create_issues_url(self, filter): + return self.YOUTRACK_ISSUES.format(base_url=self.youtrack_url) + parse.urlencode({'q': filter}) + + def create_issue_url(self, id): + return self.YOUTRACK_ISSUE.format(base_url=self.youtrack_url, id=id) + + def get_intellisense_suggestions(self, actual_user_input: str) -> Sequence[IntellisenseResult]: + """ + There is no non-legacy yet (YouTrack 2019.2) but already announced that it will be discontinued + once everything has been published under the new api. + """ + requestUrl = self.YOUTRACK_INTELLISENSE_ISSUE_API.format(base_url=self.youtrack_url) + filter = parse.urlencode({'filter': actual_user_input}) + requestUrl = requestUrl + filter + self.print(requesturl=requestUrl) + content: bytes = self.open_url(requestUrl) + api_result_suggestions = self.parse_intellisense_suggestions(content) + return api_result_suggestions + + def parse_intellisense_suggestions(self, response: bytes) -> Sequence[IntellisenseResult]: + dom = get_as_xml(response) + if (dom.documentElement.nodeName != 'IntelliSense'): return [] + items = [itemOrRecentItem + for suggestOrRecent in dom.documentElement.childNodes + for itemOrRecentItem in suggestOrRecent.childNodes + if isinstance(itemOrRecentItem, Element) and itemOrRecentItem.nodeName in ['item', 'recentItem']] + result = [] + + for item in items: + prefix: str = get_value(item, 'prefix') + suffix: str = get_value(item, 'suffix') + option: str = get_value(item, 'option') + description: str = get_value(item, 'description') + start: int = int(get_child_att_value(item, 'completion', 'start')) + end: int = int(get_child_att_value(item, 'completion', 'end')) + if option is None: continue + res = str.join('', (item for item in [prefix, option, suffix] if item is not None)) + intelliRes = IntellisenseResult( + full_option=res, + prefix=prefix, + suffix=suffix, + option=option, + start=start, + end=end, + description=description) + result.append(intelliRes) + return result + + def parse_list_of_issues_result(self, response: str) -> Sequence[Issue]: + dom = get_as_xml(response) + if (dom.documentElement.nodeName != 'issueCompacts'): return [] + items = [issue for issue in dom.documentElement.childNodes + if isinstance(issue, minidom.Element) and issue.nodeName == 'issue'] + issues: Sequence[Issue] = [] + for item in items: + self.print(item=str(item)) + id = item.getAttribute('id') + description = self.extract_field_value('description', "", item) + summary: str = self.extract_field_value('summary', "--no summary--", item) + issue = Issue(id=id, summary=summary, description=description, url=self.create_issue_url(id)) + issues.append(issue) + self.print(id=id, summary=summary,url=issue.url) + return issues + + + def extract_field_value(self, field_name: str, fallback: str, item) -> str: + return next((get_value(fieldNode, "value") + for fieldNode in item.childNodes + if isinstance(fieldNode, minidom.Element) and fieldNode.nodeName == 'field' and fieldNode.getAttribute( + 'name') == field_name), + fallback) + + def get_issues_matching_filter(self, actual_user_input: str) -> Sequence[Issue]: + requestUrl: str = self.YOUTRACK_LIST_OF_ISSUES_API.format(base_url=self.youtrack_url) + filter: str = parse.urlencode({'filter': actual_user_input}) + requestUrl = requestUrl + filter + self.print(requesturl=requestUrl) + content: str = self.open_url(requestUrl) + self.dbg("parsing issues result") + issues = self.parse_list_of_issues_result(content) + return issues + diff --git a/lib/util.py b/lib/util.py new file mode 100644 index 0000000..cbe0971 --- /dev/null +++ b/lib/util.py @@ -0,0 +1,18 @@ +from xml.dom import minidom + + +def get_as_xml(response: bytes): + response = response.decode(encoding="utf-8", errors="strict") + dom = minidom.parseString(response) + return dom + +def get_value(node, node_name): + return next((child.childNodes[0].nodeValue for child in node.childNodes if child.nodeName == node_name), None) + +def get_child_att_value(node, node_name, att_name): + return next((child.getAttribute(att_name) for child in node.childNodes if child.nodeName == node_name), None) + +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self diff --git a/tests/intellisense_result.xml b/tests/intellisense_result.xml new file mode 100644 index 0000000..263f7cd --- /dev/null +++ b/tests/intellisense_result.xml @@ -0,0 +1,55 @@ + + + + 9 + + by updated + + + keyword + : + + + 12 + + by updater + + + keyword + : + + + + + 0 + + Recent Searches + + + + 43 + +   + true + + + field + + + 18 + +   + true + + + field + + + + + string + 0 + 2 + + + \ No newline at end of file diff --git a/tests/keypirinha_api.py b/tests/keypirinha_api.py new file mode 100644 index 0000000..96de9fb --- /dev/null +++ b/tests/keypirinha_api.py @@ -0,0 +1,7 @@ +def name(): '' +def label(): '' +def version_tuple(): '' + +class Plugin(object): + def __new__(cls): + pass diff --git a/tests/youtrack_tests.py b/tests/youtrack_tests.py new file mode 100644 index 0000000..dab9de9 --- /dev/null +++ b/tests/youtrack_tests.py @@ -0,0 +1,30 @@ +import os +import unittest +from typing import Sequence + +from lib.api import Api, IntellisenseResult + +TESTDATA_FILENAME = os.path.join(os.path.dirname(__file__), 'intellisense_result.xml') + +class TestApi: + + def setup_class(self): + self.testdata = open(TESTDATA_FILENAME).read() + + def test_read_issues(self): + dummyDbg = lambda x: None + fixture = Api("no token", "https://foo.com", dummyDbg) + #fixture.get_intellisense_suggestions("test") + + res: Sequence[IntellisenseResult] = fixture.parse_intellisense_suggestions(self.testdata.encode("UTF-8")) + assert len(res) == 4 + assert self.equals(res[0],IntellisenseResult(start=0,end=2,description="by updated",option="updated",full_option="updated:",prefix=None,suffix=":")) + + def equals(self, one:IntellisenseResult, two:IntellisenseResult): + return one.description == two.description \ + and one.suffix == two.suffix \ + and one.end ==two.end \ + and one.full_option == two.full_option \ + and one.option==two.option \ + and one.prefix==two.prefix \ + and one.start==two.start \ No newline at end of file diff --git a/youtrack.ini b/youtrack.ini index 14ce40a..7954985 100644 --- a/youtrack.ini +++ b/youtrack.ini @@ -1,16 +1,32 @@ -[server/jetbrains] +# [server/jetbrains] + +# defaults to True +#enabled = False # youtrack base url -base_url = https://youtrack.jetbrains.com +#base_url = https://youtrack.jetbrains.com # the token for auth - is required and starts with perm: -# api_token = +#api_token = + +# displayed entry text +#issues_label = YouTrack Jetbrains (issues) + +# defaults to "youtrack" +#issues_icon = youtrack + +# displayed entry text +#filter_label = YouTrack Jetbrains (filter) + +# defaults to "youtrack" +#filter_icon = youtrack -filter_label = YouTrack Jetbrains (filter) -issues_label = YouTrack Jetbrains (issues) +# is prefixed to the entered filter +# Note: you can add the same server twice but with a different filter +#filter = -issues_icon = youtrack -filter_icon = youtrack +# disables the automatic whitespace added after the prefix filter, defaults to False +#filter_dont_append_whitespace=False #[server/my-server] @@ -18,10 +34,33 @@ filter_icon = youtrack #base_url = https://youtrack.myserver.com # the token for auth - is required and starts with perm: -# api_token = +#api_token = -#filter_label = My bugtracker (filter) +# displayed entry text #issues_label = My bugtracker (issues) +# defaults to "youtrack" #issues_icon = youtrack + +# displayed entry text +#filter_label = My bugtracker (filter) + +# defaults to "youtrack" #filter_icon = youtrack + +# is prefixed to the entered filter +# Note: you can add the same server twice but with a different filter +#filter = + +# disables the automatic whitespace added after the prefix filter, defaults to False +#filter_dont_append_whitespace=False + +# Concerning icons: +# Put your png icons in a subfolder youtrack and prefix them with `icon_` - in the example below ´test´ and ´xyz´ are valid identifiers in the ´youtrack.ini´: +# +# +# |– youtrack.ini +# |– youtrack/ +# |– icon_test.png +# |– icon_xyz.png +# diff --git a/youtrack.py b/youtrack.py index c111d0f..6a735ea 100644 --- a/youtrack.py +++ b/youtrack.py @@ -1,288 +1,15 @@ -import functools import os import shutil import traceback import urllib import webbrowser -from enum import Enum -from urllib import parse -from urllib import request -from xml.dom import minidom +from typing import List import keypirinha import keypirinha as kp import keypirinha_util as kpu -ICON_KEY_DEFAULT = "youtrack" - -class YouTrackServer(): - AUTH_HEADER = 'Authorization' - TOKEN_PREFIX = 'Bearer ' - KEYWORD_DEFAULT = "youtrack" - NAME_DEFAULT = "YouTrack" - LABEL_DEFAULT = "YouTrack" - YOUTRACK_INTELLISENSE_ISSUE_API = '{base_url}/rest/issue/intellisense/?' - YOUTRACK_LIST_OF_ISSUES_API = '{base_url}/rest/issue?' - YOUTRACK_ISSUE = '{base_url}/issue/{id}' - YOUTRACK_ISSUES = '{base_url}/issues/?' - - def __init__(self, plugin, name): - self.reset() - self.plugin = plugin - self.name = name - - def reset(self): - self.filter_icon = ICON_KEY_DEFAULT - self.issues_icon = ICON_KEY_DEFAULT - self.keyword = self.KEYWORD_DEFAULT - self.api_token = '' - self.youtrack_url = '' - - def dbg(self, text): - self.plugin.dbg(text) - - def print(self, **kwargs): - toPrint = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()]) - self.dbg("[" + toPrint + "]") - - def init_from_config(self, settings, section): - self.dbg('init_from_config') - if section.lower().startswith("server/"): - self.youtrack_url = settings.get("base_url", section, None) - self.api_token = settings.get("api_token", section, None) - self.filter_label = settings.get("filter_label", section, self.LABEL_DEFAULT) - self.issues_label = settings.get("issues_label", section, self.LABEL_DEFAULT) - self.name = settings.get("name", section, self.NAME_DEFAULT) - self.filter_icon = settings.get("filter_icon", section, ICON_KEY_DEFAULT) - self.issues_icon = settings.get("issues_icon", section, ICON_KEY_DEFAULT) - self.keyword = settings.get("keyword", section, self.KEYWORD_DEFAULT) - - def open_url(self, http_url, token): - req = request.Request(http_url) - req.add_header(self.AUTH_HEADER, self.TOKEN_PREFIX + token) - - with request.urlopen(req) as resp: - content = resp.read() - return content - - def ensureSpace(self, text, added): - res = text - if (added is not None): - if (not text.endswith(" ")): res += " " - return text + added - - def on_suggest(self, plugin, user_input, items_chain): - """ - :type user_input: str - """ - self.dbg('on_suggest') - - if not items_chain: - return [] - - initial_item = items_chain[0] - def calc(category, next_category): - if (next_category == plugin.ITEMCAT_SWITCH): - return plugin.ITEMCAT_FILTER if category == plugin.ITEMCAT_ISSUES else plugin.ITEMCAT_ISSUES - return next_category - - current_suggestion_type = functools.reduce(calc, [item.category() for item in items_chain]) - - current_items = items_chain[1:len(items_chain)] - suggestions = [] - actual_user_input = "" - previous_effective_value = "" - if (len(current_items) > 0): - current_item = current_items[-1] - previous_effective_value = kpu.kwargs_decode(current_item.data_bag())['effective_value'] - actual_user_input += previous_effective_value - actual_user_input += user_input - self.print(actual_user_input = actual_user_input, user_input=user_input) - self.print(is_filter=current_suggestion_type == plugin.ITEMCAT_FILTER) - if current_suggestion_type == plugin.ITEMCAT_FILTER: - self.add_filter_suggestions(actual_user_input, plugin, suggestions) - else: - self.add_issues_matching_filter(actual_user_input, plugin, suggestions) - - suggestions.append(plugin.create_item( - category=plugin.ITEMCAT_SWITCH, - label="Switch ⇆", - short_desc="Switch between filter suggestions and issue list", - target="switch", - args_hint=kp.ItemArgsHint.ACCEPTED, - hit_hint=kp.ItemHitHint.IGNORE, - icon_handle=plugin._icons[self.filter_icon], - loop_on_suggest=True, - data_bag=kpu.kwargs_encode(url=self.create_issues_url(previous_effective_value),effective_value=previous_effective_value))) - - # avoid flooding YouTrack with too many unnecessary queries in - # case user is still typing her search - if plugin.should_terminate(plugin.idle_time): - return [] - if initial_item.category() == plugin.ITEMCAT_SWITCH : - return [] - return suggestions - - def add_filter_suggestions(self, actual_user_input, plugin, suggestions): - api_result_suggestions = self.fetch_suggestions(actual_user_input, plugin) - first = True - for api_result_suggestion in api_result_suggestions: - start = api_result_suggestion.start - end = api_result_suggestion.end - - user_input_start_ = actual_user_input[:start] - user_input_end_ = actual_user_input[end:] - effective_value = user_input_start_ + api_result_suggestion.full_option + user_input_end_ - - data_bag_encoded = kpu.kwargs_encode(url=self.create_issues_url(effective_value), - effective_value=effective_value) - desc = api_result_suggestion.description + " | " + effective_value if first else api_result_suggestion.description - first = False - suggestions.append(plugin.create_item( - category=plugin.ITEMCAT_FILTER, - label=api_result_suggestion.full_option, - short_desc=desc, - target=kpu.kwargs_encode(server=self.name, label=api_result_suggestion.option), - args_hint=kp.ItemArgsHint.ACCEPTED, - hit_hint=kp.ItemHitHint.NOARGS, - icon_handle=plugin._icons[self.filter_icon], - loop_on_suggest=True, - data_bag=data_bag_encoded)) - if len(suggestions) == 0: - suggestions.insert(0, plugin.create_item( - category=plugin.ITEMCAT_FILTER, - label=actual_user_input, - short_desc=actual_user_input, - target=actual_user_input, - args_hint=kp.ItemArgsHint.ACCEPTED, - hit_hint=kp.ItemHitHint.NOARGS, - icon_handle=plugin._icons[self.filter_icon], - loop_on_suggest=True, - data_bag=(kpu.kwargs_encode(url=self.create_issues_url(actual_user_input), - effective_value=actual_user_input)))) - - def fetch_suggestions(self, actual_user_input, plugin): - requestUrl = self.YOUTRACK_INTELLISENSE_ISSUE_API.format(base_url=self.youtrack_url) - filter = parse.urlencode({'filter': actual_user_input}) - requestUrl = requestUrl + filter - self.print(requesturl=requestUrl) - content = self.open_url(requestUrl, self.api_token) - api_result_suggestions = self.youtrack_intellisense_legacy(plugin, content) - return api_result_suggestions - - def create_issues_url(self, filter): - return self.YOUTRACK_ISSUES.format(base_url=self.youtrack_url) + parse.urlencode({'q': filter}) - - def create_issue_url(self, id): - return self.YOUTRACK_ISSUE.format(base_url=self.youtrack_url, id=id) - - def get_value(self, node, node_name): - return next((child.childNodes[0].nodeValue for child in node.childNodes if child.nodeName == node_name), None) - - def get_child_att_value(self, node, node_name, att_name): - return next((child.getAttribute(att_name) for child in node.childNodes if child.nodeName == node_name), None) - - def youtrack_intellisense_legacy(self, plugin, response): - """ - There is no non-legacy yet (YouTrack 2019.2) but already announced that it will be discontinued - once everything has been published under the new api. - :param response: - :return: - """ - try: - dom = self.get_as_xml(response) - if (dom.documentElement.nodeName != 'IntelliSense'): return [] - items = [itemOrRecentItem - for suggestOrRecent in dom.documentElement.childNodes - for itemOrRecentItem in suggestOrRecent.childNodes - if isinstance(itemOrRecentItem, minidom.Element) and itemOrRecentItem.nodeName in ['item','recentItem']] - list = [] - - for item in items: - prefix = self.get_value(item, 'prefix') - suffix = self.get_value(item, 'suffix') - option = self.get_value(item, 'option') - description = self.get_value(item, 'description') - start = int(self.get_child_att_value(item, 'completion', 'start')) - end = int(self.get_child_att_value(item, 'completion', 'end')) - if option is None: continue - res = option - if suffix is not None: res = res + suffix - if prefix is not None: res = prefix + res - list.append(AttrDict({'full_option': res, 'prefix': prefix, 'suffix': suffix, 'option': option,'start':start,'end':end,'description':description})) - return list - except Exception as e: - self.warn("Failed to parse response.") - traceback.print_exc() - return [] - - def get_as_xml(self, response): - response = response.decode(encoding="utf-8", errors="strict") - dom = minidom.parseString(response) - return dom - - def add_issues_matching_filter(self, actual_user_input, plugin, suggestions): - self.dbg("add_issues_matching_filter for " + actual_user_input) - requestUrl = self.YOUTRACK_LIST_OF_ISSUES_API.format(base_url=self.youtrack_url) - filter = parse.urlencode({'filter': actual_user_input}) - requestUrl = requestUrl + filter - self.print(requesturl=requestUrl) - content = self.open_url(requestUrl, self.api_token) - api_result_suggestions = self.parse_list_of_issues_result_legacy(plugin, content) - for res in api_result_suggestions: - suggestions.append(res) - suggestions.insert(0, plugin.create_item( - category=plugin.ITEMCAT_ISSUES, - label=actual_user_input, - short_desc=actual_user_input, - target=actual_user_input, - args_hint=kp.ItemArgsHint.ACCEPTED, - hit_hint=kp.ItemHitHint.NOARGS, - icon_handle=plugin._icons[self.issues_icon], - loop_on_suggest=True, - data_bag=(kpu.kwargs_encode(url=self.create_issues_url(actual_user_input), - effective_value=actual_user_input)))) - - - def parse_list_of_issues_result_legacy(self, plugin, response): - try: - dom = self.get_as_xml(response) - if (dom.documentElement.nodeName != 'issueCompacts'): return [] - self.dbg("parsing issues result") - items = [issue for issue in dom.documentElement.childNodes - if isinstance(issue, minidom.Element) and issue.nodeName == 'issue'] - suggestions = [] - for item in items: - id = item.getAttribute('id') - summary = self.extract_field_value('summary', "--no summary--", item) - description = self.extract_field_value('description', None, item) - self.print(id=id,summary=summary) - suggestions.append(plugin.create_item(category=plugin.ITEMCAT_ISSUES, - label=summary, - short_desc=id + (" ▶ " + description if description is not None else ""), - target=id, - args_hint=kp.ItemArgsHint.FORBIDDEN, - hit_hint=kp.ItemHitHint.NOARGS, - icon_handle=plugin._icons[self.issues_icon], - loop_on_suggest=False, - data_bag=(kpu.kwargs_encode(url=self.create_issue_url(id))))) - return suggestions - except Exception as e: - plugin.warn("Failed to parse response.") - traceback.print_exc() - return [] - - def extract_field_value(self, field_name, fallback, item): - return next((self.get_value(fieldNode, "value") for fieldNode in item.childNodes if isinstance(fieldNode, - minidom.Element) and fieldNode.nodeName == 'field' and fieldNode.getAttribute( - 'name') == field_name), fallback) - - -class AttrDict(dict): - def __init__(self, *args, **kwargs): - super(AttrDict, self).__init__(*args, **kwargs) - self.__dict__ = self - +from .youtrack_server import YouTrackServer, ICON_KEY_DEFAULT class YouTrack(kp.Plugin): @@ -307,7 +34,7 @@ class YouTrack(kp.Plugin): def __init__(self): self.dbg('__init__') super().__init__() - self._debug = False + self._debug = True self._icons = {} def __del__(self): @@ -386,14 +113,14 @@ def on_catalog(self): catalog = [] for server_name, server in self.servers.items(): self.set_default_icon(self._icons[ICON_KEY_DEFAULT]) - self.info("Creating catalog entry for server issues_label={issues_label}, filter_label={filter_label}, name={name}, server_name={server_name}, issues_icon={issues_icon}, filter_icon={filter_icon}".format( + self.info("Creating catalog entry for server name={name}, issues_label={issues_label}, filter_label={filter_label}, server_name={server_name}, issues_icon={issues_icon}, filter_icon={filter_icon},filter={filter}".format( filter_icon=server.filter_icon, issues_icon=server.issues_icon, issues_label=server.issues_label, filter_label=server.filter_label, server_name=server_name, - name=server.name)) - str.join(",", self._icons) + name=server.name, + filter=server.filter_prefix)) catalog.append(self.create_item( category=self.ITEMCAT_FILTER, label=server.filter_label, @@ -412,7 +139,7 @@ def on_catalog(self): icon_handle=self._icons[server.issues_icon])) self.set_catalog(catalog) - def on_suggest(self, user_input, items_chain): + def on_suggest(self, user_input: str, items_chain: List): if not items_chain or items_chain[0].category() not in [self.ITEMCAT_FILTER, self.ITEMCAT_ISSUES, self.ITEMCAT_SWITCH]: return current_item = items_chain[0] @@ -437,7 +164,7 @@ def on_suggest(self, user_input, items_chain): server_suggestions = [] try: - server_suggestions = server.on_suggest(self, user_input, items_chain) + server_suggestions = server.on_suggest(user_input, items_chain) self.dbg("len=" + str(len(server_suggestions))) if self.should_terminate(): return @@ -477,6 +204,7 @@ def on_events(self, flags): if flags & kp.Events.PACKCONFIG: self.info("Configuration changed, rebuilding catalog...") self._read_config() + self._load_icons() self.on_catalog() def _load_icons(self): diff --git a/youtrack_server.py b/youtrack_server.py new file mode 100644 index 0000000..bf6a5c0 --- /dev/null +++ b/youtrack_server.py @@ -0,0 +1,181 @@ +import functools +from enum import Enum +from typing import Sequence, Callable +from urllib import parse + +import keypirinha as kp +import keypirinha_util as kpu + +from .lib.api import Api + +class SuggestionMode(Enum): + Filter = 0, + Issues = 1 + +ICON_KEY_DEFAULT = "youtrack" + +class YouTrackServer(): + KEYWORD_DEFAULT: str = "youtrack" + NAME_DEFAULT: str = "YouTrack" + LABEL_DEFAULT: str = "YouTrack" + suggestion_mode: SuggestionMode = SuggestionMode.Filter + + def __init__(self, plugin, name: str): + self.reset() + self.plugin = plugin + self.name = name + + def reset(self): + self.filter_icon = ICON_KEY_DEFAULT + self.issues_icon = ICON_KEY_DEFAULT + self.keyword = self.KEYWORD_DEFAULT + self.api = None + self.filter_prefix = "" + + def dbg(self, text): + self.plugin.dbg(text) + + def print(self, **kwargs): + toPrint = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()]) + self.dbg("[" + toPrint + "]") + + def init_from_config(self, settings, section): + self.dbg('init_from_config') + if section.lower().startswith("server/"): + youtrack_url = settings.get("base_url", section, None) + api_token = settings.get("api_token", section, None) + self.api = Api(api_token=api_token, youtrack_url=youtrack_url, dbg=self.dbg) + self.filter_label = settings.get("filter_label", section, self.LABEL_DEFAULT) + self.issues_label = settings.get("issues_label", section, self.LABEL_DEFAULT) + self.name = settings.get("name", section, self.NAME_DEFAULT) + self.filter_icon = settings.get("filter_icon", section, ICON_KEY_DEFAULT) + self.issues_icon = settings.get("issues_icon", section, ICON_KEY_DEFAULT) + self.keyword = settings.get("keyword", section, self.KEYWORD_DEFAULT) + dont_append = settings.get_bool("filter_dont_append_whitespace", section, False) + self.filter_prefix = settings.get("filter", section, "") + ("" if dont_append else " ") + self.print(filter_prefix=self.filter_prefix) + + def on_suggest(self, user_input: str, items_chain: Sequence): + self.dbg('on_suggest') + + if not items_chain: + return [] + + initial_item = items_chain[0] + current_items = items_chain[1:len(items_chain)] + current_suggestion_type: SuggestionMode = self.get_current_suggestionmode(items_chain) + suggestions = [] + actual_user_input = self.filter_prefix if len(items_chain) == 1 else "" + previous_effective_value = "" + if (len(current_items) > 0): + current_item = current_items[-1] + previous_effective_value = kpu.kwargs_decode(current_item.data_bag())['effective_value'] + actual_user_input += previous_effective_value + actual_user_input += user_input + self.print(actual_user_input = actual_user_input, user_input=user_input) + self.print(is_filter=str(current_suggestion_type)) + if current_suggestion_type == SuggestionMode.Filter: + self.add_filter_suggestions(actual_user_input, suggestions) + else: + self.add_issues_matching_filter(actual_user_input, suggestions) + + suggestions.append(self.plugin.create_item( + category=self.plugin.ITEMCAT_SWITCH, + label="Switch ⇆", + short_desc="Switch between filter suggestions and issue list", + target="switch", + args_hint=kp.ItemArgsHint.ACCEPTED, + hit_hint=kp.ItemHitHint.IGNORE, + icon_handle=self.plugin._icons[self.filter_icon], + loop_on_suggest=True, + data_bag=kpu.kwargs_encode(url=self.api.create_issues_url(previous_effective_value),effective_value=previous_effective_value))) + + # avoid flooding YouTrack with too many unnecessary queries in + # case user is still typing her search + if self.plugin.should_terminate(self.plugin.idle_time): + return [] + if initial_item.category() == self.plugin.ITEMCAT_SWITCH : + return [] + return suggestions + + def get_current_suggestionmode(self, current_items): + def calc(prev_category, next_category): + if (next_category == self.plugin.ITEMCAT_SWITCH): + return self.plugin.ITEMCAT_FILTER if prev_category == self.plugin.ITEMCAT_ISSUES else self.plugin.ITEMCAT_ISSUES + return next_category + + reduced = functools.reduce(calc, [item.category() for item in current_items], self.plugin.ITEMCAT_FILTER) + return SuggestionMode.Filter if reduced == self.plugin.ITEMCAT_FILTER else SuggestionMode.Issues + + def add_filter_suggestions(self, actual_user_input, suggestions) -> None: + api_result_suggestions = self.api.get_intellisense_suggestions(actual_user_input) + # the first displays the current filter so far + first = True + for api_result_suggestion in api_result_suggestions: + start = api_result_suggestion.start + end = api_result_suggestion.end + + user_input_start_ = actual_user_input[:start] + user_input_end_ = actual_user_input[end:] + effective_value = user_input_start_ + api_result_suggestion.full_option + user_input_end_ + + data_bag_encoded = kpu.kwargs_encode(url=self.api.create_issues_url(effective_value), + effective_value=effective_value) + desc = api_result_suggestion.description + " | " + effective_value if first else api_result_suggestion.description + first = False + suggestions.append(self.plugin.create_item( + category=self.plugin.ITEMCAT_FILTER, + label=api_result_suggestion.full_option, + short_desc=desc, + target=kpu.kwargs_encode(server=self.name, label=api_result_suggestion.option), + args_hint=kp.ItemArgsHint.ACCEPTED, + hit_hint=kp.ItemHitHint.NOARGS, + icon_handle=self.plugin._icons[self.filter_icon], + loop_on_suggest=True, + data_bag=data_bag_encoded)) + suggestions.insert(0, self.plugin.create_item( + category=self.plugin.ITEMCAT_FILTER, + label= self.filter_label + "▶" + actual_user_input, + short_desc="Open", + target=actual_user_input, + args_hint=kp.ItemArgsHint.FORBIDDEN, + hit_hint=kp.ItemHitHint.KEEPALL, + icon_handle=self.plugin._icons[self.filter_icon], + loop_on_suggest=False, + data_bag=(kpu.kwargs_encode(url=self.api.create_issues_url(actual_user_input), + effective_value=actual_user_input)))) + + def add_issues_matching_filter(self, actual_user_input: str, suggestions: Sequence) -> None: + self.dbg("add_issues_matching_filter for " + actual_user_input) + api_result_suggestions = self.get_issues_matching_filter(actual_user_input) + for res in api_result_suggestions: + suggestions.append(res) + suggestions.insert(0, self.plugin.create_item( + category=self.plugin.ITEMCAT_ISSUES, + label=actual_user_input, + short_desc=actual_user_input, + target=actual_user_input, + args_hint=kp.ItemArgsHint.ACCEPTED, + hit_hint=kp.ItemHitHint.IGNORE, + icon_handle=self.plugin._icons[self.issues_icon], + loop_on_suggest=True, + data_bag=(kpu.kwargs_encode(url=self.api.create_issues_url(actual_user_input), + effective_value=actual_user_input)))) + + def get_issues_matching_filter(self, actual_user_input): + issues = self.api.get_issues_matching_filter(actual_user_input) + suggestions = [] + for issue in issues: + suggestions.append(self.plugin.create_item( + category=self.plugin.ITEMCAT_ISSUES, + label=issue.summary, + short_desc= + issue.id + + (" ▶ " + issue.description if issue.description is not None else ""), + target=issue.id, + args_hint=kp.ItemArgsHint.FORBIDDEN, + hit_hint=kp.ItemHitHint.NOARGS, + icon_handle=self.plugin._icons[self.issues_icon], + loop_on_suggest=False, + data_bag=(kpu.kwargs_encode(url=issue.url)))) + return suggestions