From cc1fdc6135c285b58adf729009fc2c7296ee83d2 Mon Sep 17 00:00:00 2001 From: AirOnSkin Date: Thu, 20 Feb 2025 10:48:20 +0100 Subject: [PATCH 1/6] GitHub repositories search plugin --- github/.gitignore | 2 + github/README.md | 11 +++ github/__init__.py | 192 +++++++++++++++++++++++++++++++++++++++++++++ github/plugin.svg | 3 + 4 files changed, 208 insertions(+) create mode 100644 github/.gitignore create mode 100644 github/README.md create mode 100644 github/__init__.py create mode 100644 github/plugin.svg diff --git a/github/.gitignore b/github/.gitignore new file mode 100644 index 00000000..0ef5fd9a --- /dev/null +++ b/github/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +repository_cache.json diff --git a/github/README.md b/github/README.md new file mode 100644 index 00000000..d5951958 --- /dev/null +++ b/github/README.md @@ -0,0 +1,11 @@ +# GitHub user repositories plugin + +This Albert plugin lets you search & open GitHub user repositories in the browser. + +You'll first need to provide your [GitHub access token](https://github.com/settings/tokens) (only needs the *repo* scope) with `gh your-access-token-here`. +Please refer to the [keyring](https://pypi.org/project/keyring/) documentation should your Linux installation be missing an appropriate backend. + +Next, you'll need to create a local cache of your repositories. Simply trigger `gh ` and press `[enter]`. This will take a few seconds. +You can refresh the local cache anytime with `gh refresh cache`. + +Now you're ready to search for a repository with `gh repo-name`. diff --git a/github/__init__.py b/github/__init__.py new file mode 100644 index 00000000..4d780fbf --- /dev/null +++ b/github/__init__.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- + +""" +Searches GitHub user repositories and opens the selected match in the browser +Trigger with 'gh ' +Rebuild cache with 'gh rebuild cache' +""" + +import os +import json +import keyring +from albert import * +from pathlib import Path +import github3 +from rapidfuzz import fuzz + +md_iid = '2.5' +md_version = "1.3" +md_name = "GitHub repositories" +md_description = "Open GitHub user repositories in the browser" +md_license = "GPL-3.0" +md_url = 'https://github.com/albertlauncher/python/tree/main/github' +md_authors = "@aironskin" +md_lib_dependencies = ["github3.py", "rapidfuzz", "keyring"] + +plugin_dir = os.path.dirname(__file__) +CACHE_FILE = os.path.join(plugin_dir, "repository_cache.json") + + +class Plugin(PluginInstance, TriggerQueryHandler): + + # initialize the plugin + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__( + self, + id=self.id, + name=self.name, + description=self.description, + defaultTrigger='gh ' + ) + self.iconUrls = [f"file:{Path(__file__).parent}/plugin.svg"] + + # persist the github token in the keyring + def save_token(self, token): + keyring.set_password("albert-github", "github_token", token) + + # load the token from the keyring + def load_token(self): + return keyring.get_password("albert-github", "github_token") + + # fetch user repositories from github + def get_user_repositories(self, token): + g = github3.login(token=token) + user = g.me() + repositories = [] + + for repo in g.repositories(): + repositories.append( + { + "name": repo.name, + "full_name": repo.full_name, + "html_url": repo.html_url, + } + ) + + repositories.sort(key=lambda repo: repo["name"].lower()) + + return repositories + + # cache the repositories on the file system + def cache_repositories(self, repositories): + with open(CACHE_FILE, "w") as file: + json.dump(repositories, file) + + # load the cached repositories from the file if it exists + def load_cached_repositories(self): + if os.path.exists(CACHE_FILE): + with open(CACHE_FILE, "r") as file: + return json.load(file) + return None + + # perform fuzzy search on the repositories + def fuzzy_search_repositories(self, repositories, search_string): + matching_repos = [] + for repo in repositories: + repo_name = repo["name"] + ratio = fuzz.token_set_ratio( + repo_name.lower(), search_string.lower()) + if ratio >= 75: + matching_repos.append(repo) + return matching_repos + + def handleTriggerQuery(self, query): + # load github user token + token = self.load_token() + if not token: + query.add(StandardItem( + id=self.id, + text=self.name, + iconUrls=self.iconUrls, + subtext="Paste your GitHub token and press [enter] to save it", + actions=[ + Action("save", "Save token", lambda t=query.string.strip(): self.save_token(t))] + )) + + # load the repositories from cache or fetch them from github + repositories = self.load_cached_repositories() + if not repositories: + query.add(StandardItem( + id=self.id, + text=self.name, + iconUrls=self.iconUrls, + subtext="Press [enter] to initialize the repository cache (may take a few seconds)", + actions=[Action("cache", "Create repository cache", lambda: self.cache_repositories( + self.get_user_repositories(token)))] + )) + + query_stripped = query.string.strip() + + if query_stripped: + + # fefresh local repositories cache + if query_stripped.lower() == "rebuild cache": + query.add(StandardItem( + id=self.id, + text=self.name, + iconUrls=self.iconUrls, + subtext="Press [enter] to rebuild the local repository cache (may take a few seconds)", + actions=[Action("rebuild", "Rebuild repository cache", lambda: self.cache_repositories( + self.get_user_repositories(token)))] + )) + + if not repositories: + return [] + + # fuzzy search the query in repository names + search_term = query.string.strip().lower() + exact_matches = [] + fuzzy_matches = [] + + for repo in repositories: + repo_name = repo["name"] + if repo_name.lower().startswith(search_term): + exact_matches.append(repo) + else: + similarity_ratio = fuzz.token_set_ratio( + repo_name.lower(), search_term) + if similarity_ratio > 25: + fuzzy_matches.append((repo, similarity_ratio)) + + # sort the fuzzy matches based on similarity ratio + fuzzy_matches.sort(key=lambda x: x[1], reverse=True) + + # return the results + results = [] + for repo in exact_matches: + results.append(StandardItem( + id=self.id, + text=repo["name"], + iconUrls=self.iconUrls, + subtext=repo["full_name"], + actions=[Action("eopen", "Open exact match", + lambda u=repo["html_url"]: openUrl(u))] + )) + + for repo, similarity_ratio in fuzzy_matches: + results.append(StandardItem( + id=self.id, + text=repo["name"], + iconUrls=self.iconUrls, + subtext=repo["full_name"], + actions=[Action("fopen", "Open fuzzy match", + lambda u=repo["html_url"]: openUrl(u))] + )) + + if results: + query.add(results) + else: + query.add(StandardItem( + id=self.id, + text="No repositories matching search string", + iconUrls=self.iconUrls + )) + + else: + query.add(StandardItem( + id=self.id, + iconUrls=self.iconUrls, + text="...", + subtext="Search for a GitHub user repository name" + )) diff --git a/github/plugin.svg b/github/plugin.svg new file mode 100644 index 00000000..a8d11740 --- /dev/null +++ b/github/plugin.svg @@ -0,0 +1,3 @@ + + + From 0905916ce951f18d168639d3cdf17070bebb9bdc Mon Sep 17 00:00:00 2001 From: AirOnSkin Date: Thu, 20 Feb 2025 10:52:21 +0100 Subject: [PATCH 2/6] Update README.md --- github/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/github/README.md b/github/README.md index d5951958..e9e7db17 100644 --- a/github/README.md +++ b/github/README.md @@ -1,11 +1,11 @@ # GitHub user repositories plugin -This Albert plugin lets you search & open GitHub user repositories in the browser. +This plugin lets you search & open GitHub user repositories in the browser. You'll first need to provide your [GitHub access token](https://github.com/settings/tokens) (only needs the *repo* scope) with `gh your-access-token-here`. Please refer to the [keyring](https://pypi.org/project/keyring/) documentation should your Linux installation be missing an appropriate backend. -Next, you'll need to create a local cache of your repositories. Simply trigger `gh ` and press `[enter]`. This will take a few seconds. -You can refresh the local cache anytime with `gh refresh cache`. +Next, you'll need to create a local cache of your repositories. Simply trigger `gh ` and press `[enter]`. This may take a few seconds. +You can rebuild the local cache anytime with `gh rebuild cache`. Now you're ready to search for a repository with `gh repo-name`. From 3e796645b819b797b1d64c2cd0389d9aa1140344 Mon Sep 17 00:00:00 2001 From: Stefan Schnyder Date: Wed, 5 Mar 2025 14:34:40 +0100 Subject: [PATCH 3/6] Correct typo --- github/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github/__init__.py b/github/__init__.py index 4d780fbf..a77ae62c 100644 --- a/github/__init__.py +++ b/github/__init__.py @@ -120,7 +120,7 @@ def handleTriggerQuery(self, query): if query_stripped: - # fefresh local repositories cache + # refresh local repositories cache if query_stripped.lower() == "rebuild cache": query.add(StandardItem( id=self.id, From 663d3036b226750137112ac751da33d126e4a980 Mon Sep 17 00:00:00 2001 From: AirOnSkin Date: Mon, 10 Mar 2025 10:50:12 +0100 Subject: [PATCH 4/6] Update to plugin specification 3.0 --- github/__init__.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/github/__init__.py b/github/__init__.py index a77ae62c..b344d8f5 100644 --- a/github/__init__.py +++ b/github/__init__.py @@ -14,8 +14,8 @@ import github3 from rapidfuzz import fuzz -md_iid = '2.5' -md_version = "1.3" +md_iid = '3.0' +md_version = "1.4" md_name = "GitHub repositories" md_description = "Open GitHub user repositories in the browser" md_license = "GPL-3.0" @@ -29,17 +29,16 @@ class Plugin(PluginInstance, TriggerQueryHandler): + # set the icon + iconUrls = [f"file:{Path(__file__).parent}/plugin.svg"] + # initialize the plugin def __init__(self): PluginInstance.__init__(self) - TriggerQueryHandler.__init__( - self, - id=self.id, - name=self.name, - description=self.description, - defaultTrigger='gh ' - ) - self.iconUrls = [f"file:{Path(__file__).parent}/plugin.svg"] + TriggerQueryHandler.__init__(self) + + def defaultTrigger(self): + return 'gh ' # persist the github token in the keyring def save_token(self, token): @@ -96,8 +95,8 @@ def handleTriggerQuery(self, query): token = self.load_token() if not token: query.add(StandardItem( - id=self.id, - text=self.name, + id=self.id(), + text=self.name(), iconUrls=self.iconUrls, subtext="Paste your GitHub token and press [enter] to save it", actions=[ @@ -108,8 +107,8 @@ def handleTriggerQuery(self, query): repositories = self.load_cached_repositories() if not repositories: query.add(StandardItem( - id=self.id, - text=self.name, + id=self.id(), + text=self.name(), iconUrls=self.iconUrls, subtext="Press [enter] to initialize the repository cache (may take a few seconds)", actions=[Action("cache", "Create repository cache", lambda: self.cache_repositories( @@ -123,8 +122,8 @@ def handleTriggerQuery(self, query): # refresh local repositories cache if query_stripped.lower() == "rebuild cache": query.add(StandardItem( - id=self.id, - text=self.name, + id=self.id(), + text=self.name(), iconUrls=self.iconUrls, subtext="Press [enter] to rebuild the local repository cache (may take a few seconds)", actions=[Action("rebuild", "Rebuild repository cache", lambda: self.cache_repositories( @@ -156,7 +155,7 @@ def handleTriggerQuery(self, query): results = [] for repo in exact_matches: results.append(StandardItem( - id=self.id, + id=self.id(), text=repo["name"], iconUrls=self.iconUrls, subtext=repo["full_name"], @@ -166,7 +165,7 @@ def handleTriggerQuery(self, query): for repo, similarity_ratio in fuzzy_matches: results.append(StandardItem( - id=self.id, + id=self.id(), text=repo["name"], iconUrls=self.iconUrls, subtext=repo["full_name"], @@ -178,14 +177,14 @@ def handleTriggerQuery(self, query): query.add(results) else: query.add(StandardItem( - id=self.id, + id=self.id(), text="No repositories matching search string", iconUrls=self.iconUrls )) else: query.add(StandardItem( - id=self.id, + id=self.id(), iconUrls=self.iconUrls, text="...", subtext="Search for a GitHub user repository name" From b82a91dc761f713bbebae11d51ec43df42d619b0 Mon Sep 17 00:00:00 2001 From: AirOnSkin Date: Mon, 10 Mar 2025 14:16:47 +0100 Subject: [PATCH 5/6] Add copyright notice & update license --- github/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/github/__init__.py b/github/__init__.py index b344d8f5..b84f1074 100644 --- a/github/__init__.py +++ b/github/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# Copyright (c) 2024 Stefan Schnyder """ Searches GitHub user repositories and opens the selected match in the browser @@ -18,7 +19,7 @@ md_version = "1.4" md_name = "GitHub repositories" md_description = "Open GitHub user repositories in the browser" -md_license = "GPL-3.0" +md_license = "MIT" md_url = 'https://github.com/albertlauncher/python/tree/main/github' md_authors = "@aironskin" md_lib_dependencies = ["github3.py", "rapidfuzz", "keyring"] From c9223beb0ad27dc6fbc1624846abfdee31fc4198 Mon Sep 17 00:00:00 2001 From: AirOnSkin Date: Mon, 31 Mar 2025 11:07:44 +0200 Subject: [PATCH 6/6] Switch to native matcher, make fuzzy search an option --- github/__init__.py | 74 +++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/github/__init__.py b/github/__init__.py index b84f1074..14718057 100644 --- a/github/__init__.py +++ b/github/__init__.py @@ -13,16 +13,15 @@ from albert import * from pathlib import Path import github3 -from rapidfuzz import fuzz md_iid = '3.0' -md_version = "1.4" +md_version = "1.5" md_name = "GitHub repositories" md_description = "Open GitHub user repositories in the browser" md_license = "MIT" md_url = 'https://github.com/albertlauncher/python/tree/main/github' md_authors = "@aironskin" -md_lib_dependencies = ["github3.py", "rapidfuzz", "keyring"] +md_lib_dependencies = ["github3.py", "keyring"] plugin_dir = os.path.dirname(__file__) CACHE_FILE = os.path.join(plugin_dir, "repository_cache.json") @@ -38,6 +37,28 @@ def __init__(self): PluginInstance.__init__(self) TriggerQueryHandler.__init__(self) + self._fuzzy = self.readConfig("fuzzy", bool) + if self._fuzzy is None: + self._fuzzy = False + + @property + def fuzzy(self): + return self._fuzzy + + @fuzzy.setter + def fuzzy(self, value): + self._fuzzy = value + self.writeConfig("fuzzy", value) + + def configWidget(self): + return [ + { + "type": "checkbox", + "label": "Enable fuzzy matching for repository names", + "property": "fuzzy", + } + ] + def defaultTrigger(self): return 'gh ' @@ -80,17 +101,6 @@ def load_cached_repositories(self): return json.load(file) return None - # perform fuzzy search on the repositories - def fuzzy_search_repositories(self, repositories, search_string): - matching_repos = [] - for repo in repositories: - repo_name = repo["name"] - ratio = fuzz.token_set_ratio( - repo_name.lower(), search_string.lower()) - if ratio >= 75: - matching_repos.append(repo) - return matching_repos - def handleTriggerQuery(self, query): # load github user token token = self.load_token() @@ -134,43 +144,21 @@ def handleTriggerQuery(self, query): if not repositories: return [] - # fuzzy search the query in repository names - search_term = query.string.strip().lower() - exact_matches = [] - fuzzy_matches = [] - - for repo in repositories: - repo_name = repo["name"] - if repo_name.lower().startswith(search_term): - exact_matches.append(repo) - else: - similarity_ratio = fuzz.token_set_ratio( - repo_name.lower(), search_term) - if similarity_ratio > 25: - fuzzy_matches.append((repo, similarity_ratio)) - - # sort the fuzzy matches based on similarity ratio - fuzzy_matches.sort(key=lambda x: x[1], reverse=True) + # match the query with the cached repositories + m = Matcher(query.string, MatchConfig(fuzzy=self.fuzzy)) + matching_repos = [ + repo for repo in repositories if m.match(repo["name"]) + ] # return the results results = [] - for repo in exact_matches: - results.append(StandardItem( - id=self.id(), - text=repo["name"], - iconUrls=self.iconUrls, - subtext=repo["full_name"], - actions=[Action("eopen", "Open exact match", - lambda u=repo["html_url"]: openUrl(u))] - )) - - for repo, similarity_ratio in fuzzy_matches: + for repo in matching_repos: results.append(StandardItem( id=self.id(), text=repo["name"], iconUrls=self.iconUrls, subtext=repo["full_name"], - actions=[Action("fopen", "Open fuzzy match", + actions=[Action("open", "Open repository", lambda u=repo["html_url"]: openUrl(u))] ))