├── Fuzzy List Filter.alfredworkflow ├── README.md ├── fuzzy.py ├── fuzzylist.py ├── info.plist ├── list.csv └── list.json /Fuzzy List Filter.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derickfay/fuzzylist/df2f0d97fb2fc7c4b66a1b8b49b7ebeff1411546/Fuzzy List Filter.alfredworkflow -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fuzzylist 2 | Fuzzy, self-updating list filter workflow for Alfred 4 3 | 4 | v. 0.3 (2022-03-30) now uses Python 3 and runs on MacOS 12.3 5 | 6 | This is a workflow template - it does nothing as is. It includes sample data and icons which you'll want to replace. 7 | 8 | ## Usage: 9 | - create a csv file like you would for an [Alfred List Filter](https://www.alfredapp.com/help/workflows/inputs/list-filter/), with an optional fourth field containing the path to an icon file for that result. 10 | - name the file *list.csv* and add it to the workflow directory 11 | 12 | On the initial run, the workflow will create a file list.json for output to the fuzzy search. If list.csv is modified, it will update list.json . 13 | 14 | You can also create multiple instances by changing the file name from list.csv in a copy of the Script Filter object. 15 | 16 | More details can be found in the [Alfred forum post](https://www.alfredforum.com/topic/11094-fuzzy-self-updating-list-filter-workflow-template/?tab=comments#comment-57706) 17 | 18 | ## Credits 19 | - uses [fuzzy.py by Dean Jackson](https://github.com/deanishe/alfred-fuzzy) 20 | 21 | -------------------------------------------------------------------------------- /fuzzy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | # 6 | # Copyright (c) 2017 Dean Jackson 7 | # 8 | # MIT Licence. See http://opensource.org/licenses/MIT 9 | # 10 | # Created on 2017-09-09 11 | # 12 | 13 | """Add fuzzy search to your Alfred 3 Script Filters. 14 | 15 | This script is a replacement for Alfred's "Alfred filters results" 16 | feature that provides a fuzzy search algorithm. 17 | 18 | To use in your Script Filter, you must export the user query to 19 | the ``query`` environment variable, and call your own script via this 20 | one. 21 | 22 | If your Script Filter (using Language = /bin/bash) looks like this: 23 | 24 | /usr/bin/python myscript.py 25 | 26 | Change it to this: 27 | 28 | export query="$1" 29 | ./fuzzy.py /usr/bin/env python3 myscript.py 30 | 31 | Your script will be run once per session (while the use is using your 32 | workflow) to retrieve and cache all items, then the items are filtered 33 | against the user query on their titles using a fuzzy matching algorithm. 34 | 35 | """ 36 | 37 | from __future__ import print_function, absolute_import 38 | 39 | import json 40 | import os 41 | from subprocess import check_output 42 | import sys 43 | import time 44 | from unicodedata import normalize 45 | 46 | # Name of workflow variable storing session ID 47 | SID = 'fuzzy_session_id' 48 | 49 | # Bonus for adjacent matches 50 | adj_bonus = int(os.getenv('adj_bonus') or '5') 51 | # Bonus if match is uppercase 52 | camel_bonus = int(os.getenv('camel_bonus') or '10') 53 | # Penalty for each character before first match 54 | lead_penalty = int(os.getenv('lead_penalty') or '-3') 55 | # Max total ``lead_penalty`` 56 | max_lead_penalty = int(os.getenv('max_lead_penalty') or '-9') 57 | # Bonus if after a separator 58 | sep_bonus = int(os.getenv('sep_bonus') or '10') 59 | # Penalty for each unmatched character 60 | unmatched_penalty = int(os.getenv('unmatched_penalty') or '-1') 61 | 62 | 63 | def log(s, *args): 64 | """Simple STDERR logger.""" 65 | if args: 66 | s = s % args 67 | print('[fuzzy] ' + s, file=sys.stderr) 68 | 69 | 70 | def fold_diacritics(u): 71 | """Remove diacritics from Unicode string.""" 72 | u = normalize('NFD', u) 73 | s = u.encode('us-ascii', 'ignore') 74 | return unicode(s) 75 | 76 | 77 | def isascii(u): 78 | """Return ``True`` if Unicode string contains only ASCII characters.""" 79 | return u == fold_diacritics(u) 80 | 81 | 82 | def decode(s): 83 | """Decode and NFC-normalise string.""" 84 | if not isinstance(s, unicode): 85 | if isinstance(s, str): 86 | s = s.decode('utf-8') 87 | else: 88 | s = unicode(s) 89 | 90 | return normalize('NFC', s) 91 | 92 | 93 | class Fuzzy(object): 94 | """Fuzzy comparison of strings. 95 | 96 | Attributes: 97 | adj_bonus (int): Bonus for adjacent matches 98 | camel_bonus (int): Bonus if match is uppercase 99 | lead_penalty (int): Penalty for each character before first match 100 | max_lead_penalty (int): Max total ``lead_penalty`` 101 | sep_bonus (int): Bonus if after a separator 102 | unmatched_penalty (int): Penalty for each unmatched character 103 | 104 | """ 105 | 106 | def __init__(self, adj_bonus=adj_bonus, sep_bonus=sep_bonus, 107 | camel_bonus=camel_bonus, lead_penalty=lead_penalty, 108 | max_lead_penalty=max_lead_penalty, 109 | unmatched_penalty=unmatched_penalty): 110 | self.adj_bonus = adj_bonus 111 | self.sep_bonus = sep_bonus 112 | self.camel_bonus = camel_bonus 113 | self.lead_penalty = lead_penalty 114 | self.max_lead_penalty = max_lead_penalty 115 | self.unmatched_penalty = unmatched_penalty 116 | self._cache = {} 117 | 118 | def filter_feedback(self, fb, query): 119 | """Filter feedback dict. 120 | 121 | The titles of ``items`` in feedback dict are compared against 122 | ``query``. Items that don't match are removed and the remainder 123 | are sorted by best match. 124 | 125 | Args: 126 | fb (dict): Parsed Alfred feedback JSON 127 | query (str): Query to filter items against 128 | 129 | Returns: 130 | dict: ``fb`` with items sorted/removed. 131 | """ 132 | # fold = isascii(query) 133 | items = [] 134 | 135 | for it in fb['items']: 136 | title = it['title'] 137 | # if fold: 138 | # title = fold_diacritics(title) 139 | 140 | ok, score = self.match(query, title) 141 | if not ok: 142 | continue 143 | 144 | items.append((score, it)) 145 | 146 | items.sort(reverse=True) 147 | fb['items'] = [it for _, it in items] 148 | return fb 149 | 150 | # https://gist.github.com/menzenski/f0f846a254d269bd567e2160485f4b89 151 | def match(self, query, instring): 152 | """Return match boolean and match score. 153 | 154 | Args: 155 | query (str): Query to match against 156 | instring (str): String to score against query 157 | 158 | Returns: 159 | tuple: (match, score) where ``match`` is `True`/`False` and 160 | ``score`` is a `float`. The higher the score, the better 161 | the match.s 162 | """ 163 | # cache results 164 | key = (query, instring) 165 | if key in self._cache: 166 | return self._cache[key] 167 | 168 | adj_bonus = self.adj_bonus 169 | sep_bonus = self.sep_bonus 170 | camel_bonus = self.camel_bonus 171 | lead_penalty = self.lead_penalty 172 | max_lead_penalty = self.max_lead_penalty 173 | unmatched_penalty = self.unmatched_penalty 174 | 175 | score, q_idx, s_idx, q_len, s_len = 0, 0, 0, len(query), len(instring) 176 | prev_match, prev_lower = False, False 177 | prev_sep = True # so that matching first letter gets sep_bonus 178 | best_letter, best_lower, best_letter_idx = None, None, None 179 | best_letter_score = 0 180 | matched_indices = [] 181 | 182 | while s_idx != s_len: 183 | p_char = query[q_idx] if (q_idx != q_len) else None 184 | s_char = instring[s_idx] 185 | p_lower = p_char.lower() if p_char else None 186 | s_lower, s_upper = s_char.lower(), s_char.upper() 187 | 188 | next_match = p_char and p_lower == s_lower 189 | rematch = best_letter and best_lower == s_lower 190 | 191 | advanced = next_match and best_letter 192 | p_repeat = best_letter and p_char and best_lower == p_lower 193 | 194 | if advanced or p_repeat: 195 | score += best_letter_score 196 | matched_indices.append(best_letter_idx) 197 | best_letter, best_lower, best_letter_idx = None, None, None 198 | best_letter_score = 0 199 | 200 | if next_match or rematch: 201 | new_score = 0 202 | 203 | # apply penalty for each letter before the first match 204 | # using max because penalties are negative (so max = smallest) 205 | if q_idx == 0: 206 | score += max(s_idx * lead_penalty, max_lead_penalty) 207 | 208 | # apply bonus for consecutive matches 209 | if prev_match: 210 | new_score += adj_bonus 211 | 212 | # apply bonus for matches after a separator 213 | if prev_sep: 214 | new_score += sep_bonus 215 | 216 | # apply bonus across camelCase boundaries 217 | if prev_lower and s_char == s_upper and s_lower != s_upper: 218 | new_score += camel_bonus 219 | 220 | # update query index iff the next query letter was matched 221 | if next_match: 222 | q_idx += 1 223 | 224 | # update best letter match (may be next or rematch) 225 | if new_score >= best_letter_score: 226 | # apply penalty for now-skipped letter 227 | if best_letter is not None: 228 | score += unmatched_penalty 229 | best_letter = s_char 230 | best_lower = best_letter.lower() 231 | best_letter_idx = s_idx 232 | best_letter_score = new_score 233 | 234 | prev_match = True 235 | 236 | else: 237 | score += unmatched_penalty 238 | prev_match = False 239 | 240 | prev_lower = s_char == s_lower and s_lower != s_upper 241 | prev_sep = s_char in '_ ' 242 | 243 | s_idx += 1 244 | 245 | if best_letter: 246 | score += best_letter_score 247 | matched_indices.append(best_letter_idx) 248 | 249 | res = (q_idx == q_len, score) 250 | self._cache[key] = res 251 | return res 252 | 253 | 254 | class Cache(object): 255 | """Caches script output for the session. 256 | 257 | Attributes: 258 | cache_dir (str): Directory where script output is cached 259 | cmd (list): Command to run your script 260 | 261 | """ 262 | 263 | def __init__(self, cmd): 264 | self.cmd = cmd 265 | self.cache_dir = os.path.join(os.getenv('alfred_workflow_cache',default=None), 266 | '_fuzzy') 267 | self._cache_path = None 268 | self._session_id = None 269 | self._from_cache = False 270 | 271 | def load(self): 272 | """Return parsed Alfred feedback from cache or command. 273 | 274 | Returns: 275 | dict: Parsed Alfred feedback. 276 | 277 | """ 278 | sid = self.session_id 279 | if self._from_cache and os.path.exists(self.cache_path): 280 | log('loading cached items ...') 281 | with open(self.cache_path, 'r') as fp: 282 | js = fp.read() 283 | else: 284 | log('running command %r ...', self.cmd) 285 | js = check_output(self.cmd) 286 | 287 | fb = json.loads(js) 288 | log('loaded %d item(s)', len(fb.get('items', []))) 289 | 290 | if not self._from_cache: # add session ID 291 | if 'variables' in fb: 292 | fb['variables'][SID] = sid 293 | else: 294 | fb['variables'] = {SID: sid} 295 | 296 | log('added session id %r to results', sid) 297 | 298 | with open(self.cache_path, 'w') as fp: 299 | json.dump(fb, fp) 300 | log('cached script results to %r', self.cache_path) 301 | 302 | return fb 303 | 304 | @property 305 | def session_id(self): 306 | """ID for this session.""" 307 | if not self._session_id: 308 | sid = os.getenv(SID) 309 | if sid: 310 | self._session_id = sid 311 | self._from_cache = True 312 | else: 313 | self._session_id = str(os.getpid()) 314 | 315 | return self._session_id 316 | 317 | @property 318 | def cache_path(self): 319 | """Return cache path for this session.""" 320 | if not self._cache_path: 321 | if not os.path.exists(self.cache_dir): 322 | os.makedirs(self.cache_dir, 0o700) 323 | log('created cache dir %r', self.cache_dir) 324 | 325 | self._cache_path = os.path.join(self.cache_dir, 326 | self.session_id + '.json') 327 | 328 | return self._cache_path 329 | 330 | def clear(self): 331 | """Delete cached files.""" 332 | if not os.path.exists(self.cache_dir): 333 | return 334 | 335 | for fn in os.listdir(self.cache_dir): 336 | os.unlink(os.path.join(self.cache_dir, fn)) 337 | 338 | log('cleared old cache files') 339 | 340 | 341 | def main(): 342 | """Perform fuzzy search on JSON output by specified command.""" 343 | start = time.time() 344 | log('.') # ensure logging output starts on a new line 345 | cmd = sys.argv[1:] 346 | query = os.getenv('query') 347 | log('cmd=%r, query=%r, session_id=%r', cmd, query, 348 | os.getenv(SID)) 349 | 350 | cache = Cache(cmd) 351 | fb = cache.load() 352 | 353 | if query: 354 | # query = decode(query) 355 | fz = Fuzzy() 356 | fz.filter_feedback(fb, query) 357 | log('%d item(s) match %r', len(fb['items']), query) 358 | 359 | json.dump(fb, sys.stdout) 360 | log('fuzzy filtered in %0.2fs', time.time() - start) 361 | 362 | 363 | if __name__ == '__main__': 364 | main() 365 | -------------------------------------------------------------------------------- /fuzzylist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import csv 5 | import sys 6 | import json 7 | import os 8 | 9 | theFile=sys.argv[1] 10 | fieldnames=["title","subtitle","arg","iconfile"] 11 | 12 | json_filename = theFile.split(".")[0]+".json" 13 | 14 | 15 | def convert(csv_filename, json_filename, fieldnames): 16 | f=open(csv_filename, 'r') 17 | csv_reader = csv.DictReader(f,fieldnames) 18 | 19 | jsonf = open(json_filename,'w') 20 | jsonf.write('{"items":[') 21 | 22 | data="" 23 | 24 | for r in csv_reader: 25 | r['uid']=r['arg'] 26 | r['icon']={"path":r['iconfile']} 27 | data = data+json.dumps(r)+",\n" 28 | 29 | jsonf.write(data[:-2]) 30 | 31 | jsonf.write(']}') 32 | f.close() 33 | jsonf.close() 34 | 35 | if (not os.path.isfile(json_filename)) or (os.path.getmtime(theFile) > os.path.getmtime(json_filename)) : 36 | convert(theFile, json_filename, fieldnames) 37 | 38 | with open(json_filename, 'r') as fin: 39 | print(fin.read(), end="") -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | fm.fastmail.dfay.fuzzylist 7 | connections 8 | 9 | createdby 10 | Derick Fay 11 | description 12 | 13 | disabled 14 | 15 | name 16 | Fuzzy List Filter 17 | objects 18 | 19 | 20 | config 21 | 22 | alfredfiltersresults 23 | 24 | alfredfiltersresultsmatchmode 25 | 0 26 | argumenttrimmode 27 | 0 28 | argumenttype 29 | 0 30 | escaping 31 | 102 32 | keyword 33 | fl 34 | queuedelaycustom 35 | 3 36 | queuedelayimmediatelyinitially 37 | 38 | queuedelaymode 39 | 0 40 | queuemode 41 | 1 42 | runningsubtext 43 | 44 | script 45 | export query="$1" 46 | ./fuzzy.py /usr/bin/python fuzzylist.py 47 | scriptargtype 48 | 1 49 | scriptfile 50 | 51 | subtext 52 | 53 | title 54 | Fuzzy List Filter 55 | type 56 | 0 57 | withspace 58 | 59 | 60 | type 61 | alfred.workflow.input.scriptfilter 62 | uid 63 | 5FF12341-D9C4-4FBE-A5A8-10D098C90699 64 | version 65 | 2 66 | 67 | 68 | readme 69 | 70 | uidata 71 | 72 | 5FF12341-D9C4-4FBE-A5A8-10D098C90699 73 | 74 | xpos 75 | 60 76 | ypos 77 | 180 78 | 79 | 80 | webaddress 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /list.csv: -------------------------------------------------------------------------------- 1 | title subtitle arg iconfile 2 | test 1 1st item test 1 -------------------------------------------------------------------------------- /list.json: -------------------------------------------------------------------------------- 1 | {"items":[{"title": "title\tsubtitle\targ\ticonfile", "subtitle": null, "arg": null, "iconfile": null, "uid": null, "icon": {"path": null}}, 2 | {"title": "test 1\t1st item\ttest 1\t", "subtitle": null, "arg": null, "iconfile": null, "uid": null, "icon": {"path": null}}]} --------------------------------------------------------------------------------