├── .gitignore ├── dev_requirements.in ├── github.png ├── screenshot.png ├── pyproject.toml ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── dev_requirements.txt ├── repos.ini ├── LICENSE ├── README.md └── create_workflow.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.alfredworkflow 2 | -------------------------------------------------------------------------------- /dev_requirements.in: -------------------------------------------------------------------------------- 1 | mypy 2 | ruff 3 | -------------------------------------------------------------------------------- /github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/alfred_shortcuts_github/main/github.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/alfred_shortcuts_github/main/screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "alfred_shortcuts_github" 3 | version = "1.0" 4 | 5 | [tool.mypy] 6 | mypy_path = "src" 7 | strict = true 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | time: "09:00" 9 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile dev_requirements.in --output-file dev_requirements.txt 3 | mypy==1.18.2 4 | # via -r dev_requirements.in 5 | mypy-extensions==1.0.0 6 | # via mypy 7 | pathspec==0.12.1 8 | # via mypy 9 | ruff==0.13.2 10 | # via -r dev_requirements.in 11 | tomli==2.2.1 12 | # via mypy 13 | typing-extensions==4.11.0 14 | # via mypy 15 | -------------------------------------------------------------------------------- /repos.ini: -------------------------------------------------------------------------------- 1 | [repos] 2 | alexwlchan = 3 | alexwlchan.net 4 | books.alexwlchan.net 5 | analytics.alexwlchan.net 6 | chives 7 | create_thumbnail 8 | docstore 9 | dominant_colours 10 | emptydir 11 | fishconfig 12 | javascript-data-files 13 | scripts 14 | snippets 15 | til 16 | web-archiving-scripts 17 | 18 | blink-photo-reviewer 19 | concurrently 20 | library-lookup 21 | 22 | .github (dotgithub) 23 | 24 | python = 25 | cpython 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: 3.12 23 | cache: pip 24 | cache-dependency-path: dev_requirements.txt 25 | 26 | - name: Install dependencies 27 | run: pip install -r dev_requirements.txt 28 | 29 | - name: Run linting 30 | run: | 31 | ruff check . 32 | ruff format --check . 33 | 34 | - name: Check types 35 | run: mypy *.py 36 | 37 | - name: Build the workflow 38 | run: python3 create_workflow.py 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Alex Chan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 17 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 18 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 19 | OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alfred_shortcuts_github 2 | 3 | This is a script to help me create an [Alfred Workflow] that links to GitHub repositories that I use regularly. 4 | 5 | ![Screenshot of an Alfred workflow with three GitHub links](screenshot.png) 6 | 7 | I do this with a custom workflow, rather than the built-in web search/bookmark features for two reasons: 8 | 9 | * I want to search a lot of repos this way, and it's easier to write a workflow-building script than point-and-click in the GUI. 10 | * I want GitHub shortcuts on both my personal and work computers, but I have no way to automatically sync my Alfred settings between them. 11 | Having a workflow I can build separately on each machine (and with different config) allows me to keep them consistent. 12 | 13 | [Alfred Workflow]: https://www.alfredapp.com/workflows/ 14 | 15 | 16 | 17 | ## Usage 18 | 19 | If you want to use this script yourself, you'll need Python installed. 20 | 21 | Clone this repo, update the list of repos in `repos.ini`, then run the script: 22 | 23 | ``` 24 | $ python3 create_workflow.py 25 | ``` 26 | 27 | This will create a package `github_shortcuts.alfredworkflow` in the repo; open this to get the shortcut. 28 | 29 | 30 | 31 | ## Config syntax 32 | 33 | This is an example of the repo config: 34 | 35 | ``` 36 | [repos] 37 | alexwlchan = 38 | docstore 39 | dominant_colours 40 | pathscripts 41 | 42 | wellcomecollection = 43 | wellcomecollection.org (dotorg) 44 | 45 | storage-service 46 | 47 | scanamo = 48 | scanamo 49 | ``` 50 | 51 | The GitHub owner is the top-level key, then put one repo per line. 52 | You can put in empty lines to organise the list. 53 | 54 | By default, the shortcut will trigger for the name of the repo (e.g. `docstore`). 55 | You can override the shortcut by putting an alternative name in brackets (e.g. `(dotorg)`). 56 | -------------------------------------------------------------------------------- /create_workflow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from collections.abc import Iterable 4 | import configparser 5 | import hashlib 6 | import os 7 | import plistlib 8 | import re 9 | import shutil 10 | import tempfile 11 | import typing 12 | import uuid 13 | 14 | 15 | class Connection(typing.TypedDict): 16 | destinationuid: str 17 | modifiers: int 18 | modifiersubtext: str 19 | vitoclose: bool 20 | 21 | 22 | class UiData(typing.TypedDict): 23 | xpos: int 24 | ypos: int 25 | 26 | 27 | class Metadata(typing.TypedDict): 28 | bundleid: str 29 | category: str 30 | connections: dict[str, list[Connection]] 31 | createdby: str 32 | description: str 33 | name: str 34 | objects: list[dict[str, typing.Any]] 35 | readme: str 36 | uidata: dict[str, UiData] 37 | version: str 38 | webaddress: str 39 | 40 | 41 | class AlfredWorkflow: 42 | def __init__(self) -> None: 43 | self.metadata: Metadata = { 44 | "bundleid": "alexwlchan.github-shortcuts", 45 | "category": "Internet", 46 | "connections": {}, 47 | "createdby": "@alexwlchan", 48 | "description": "Links to GitHub repos I use regularly", 49 | "name": "GitHub shortcuts", 50 | "objects": [], 51 | "readme": "", 52 | "uidata": {}, 53 | "version": "1.0.0", 54 | "webaddress": "https://github.com/alexwlchan/github_alfred_shortcuts", 55 | } 56 | 57 | def add_link(self, url: str, title: str, icon: str, shortcut: str) -> None: 58 | trigger_object = { 59 | "config": { 60 | "argumenttype": 2, 61 | "keyword": shortcut, 62 | "subtext": "", 63 | "text": title, 64 | "withspace": False, 65 | }, 66 | "type": "alfred.workflow.input.keyword", 67 | "uid": self.uuid("link", shortcut, url), 68 | "version": 1, 69 | } 70 | 71 | browser_object = { 72 | "config": { 73 | "browser": "", 74 | "spaces": "", 75 | "url": url, 76 | "utf8": True, 77 | }, 78 | "type": "alfred.workflow.action.openurl", 79 | "uid": self.uuid("openurl", shortcut, url), 80 | "version": 1, 81 | } 82 | 83 | self._add_trigger_action_pair( 84 | trigger_object=trigger_object, action_object=browser_object, icon=icon 85 | ) 86 | 87 | def uuid(self, *args: str) -> str: 88 | assert len(args) > 0 89 | md5 = hashlib.md5() 90 | for a in args: 91 | md5.update(a.encode("utf8")) 92 | 93 | # Quick check we don't have colliding UUIDs. 94 | if not hasattr(self, "_md5s"): 95 | self._md5s: dict[str, tuple[str, ...]] = {} 96 | hex_digest = md5.hexdigest() 97 | assert hex_digest not in self._md5s, (args, self._md5s[hex_digest]) 98 | self._md5s[hex_digest] = args 99 | 100 | return str(uuid.UUID(hex=hex_digest)).upper() 101 | 102 | def _add_trigger_action_pair( 103 | self, trigger_object: typing.Any, action_object: typing.Any, icon: str 104 | ) -> None: 105 | self.metadata["objects"].append(trigger_object) 106 | self.metadata["objects"].append(action_object) 107 | 108 | if not hasattr(self, "idx"): 109 | self.idx = 0 110 | 111 | self.metadata["uidata"][trigger_object["uid"]] = { 112 | "xpos": 150, 113 | "ypos": 50 + 120 * self.idx, 114 | } 115 | self.metadata["uidata"][action_object["uid"]] = { 116 | "xpos": 600, 117 | "ypos": 50 + 120 * self.idx, 118 | } 119 | self.idx += 1 120 | 121 | self.metadata["connections"][trigger_object["uid"]] = [ 122 | { 123 | "destinationuid": action_object["uid"], 124 | "modifiers": 0, 125 | "modifiersubtext": "", 126 | "vitoclose": False, 127 | }, 128 | ] 129 | 130 | def assemble_package(self, name: str) -> None: 131 | with tempfile.TemporaryDirectory() as tmp_dir: 132 | shutil.copyfile("github.png", os.path.join(tmp_dir, "Icon.png")) 133 | 134 | plist_path = os.path.join(tmp_dir, "Info.plist") 135 | plistlib.dump(self.metadata, open(plist_path, "wb")) 136 | 137 | shutil.make_archive( 138 | base_name=f"{name}.alfredworkflow", format="zip", root_dir=tmp_dir 139 | ) 140 | shutil.move(f"{name}.alfredworkflow.zip", f"{name}.alfredworkflow") 141 | 142 | 143 | class Repo(typing.TypedDict): 144 | owner: str 145 | name: str 146 | shortcut: str 147 | 148 | 149 | def get_repos(ini_path: str) -> Iterable[Repo]: 150 | config = configparser.ConfigParser() 151 | config.read(ini_path) 152 | 153 | for owner, repo_list in config["repos"].items(): 154 | for line in repo_list.strip().splitlines(): 155 | # Skip empty lines 156 | if not line.strip(): 157 | continue 158 | 159 | # to match lines like 160 | # 161 | # catalogue-api 162 | # wellcomecollection.org (dotorg) 163 | # 164 | m = re.match(r"^(?P[a-z._-]+)(?: \((?P[a-z_-]+)\))?$", line) 165 | 166 | if m is None: 167 | print(f"Unable to parse line: {line!r}") 168 | continue 169 | 170 | yield { 171 | "owner": owner, 172 | "name": m.group("name"), 173 | "shortcut": m.group("shortcut") or m.group("name"), 174 | } 175 | 176 | 177 | if __name__ == "__main__": 178 | workflow = AlfredWorkflow() 179 | 180 | for repo in get_repos("repos.ini"): 181 | workflow.add_link( 182 | url=f"https://github.com/{repo['owner']}/{repo['name']}", 183 | title=f"{repo['owner']}/{repo['name']}", 184 | icon="github.png", 185 | shortcut=repo["shortcut"], 186 | ) 187 | 188 | workflow.assemble_package(name="github_shortcuts") 189 | --------------------------------------------------------------------------------