├── .gitignore ├── Fuzzy-Demo-0.2.alfredworkflow ├── LICENCE ├── README.md ├── demo.gif ├── demo ├── books.json ├── ebooks.py ├── french_books.json ├── fuzzy.py ├── german_books.json ├── gutenberg.py ├── icon.png ├── info.plist └── lfc.json └── fuzzy.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vim,python,sublimetext 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | ### SublimeText ### 108 | # cache files for sublime text 109 | *.tmlanguage.cache 110 | *.tmPreferences.cache 111 | *.stTheme.cache 112 | 113 | # workspace files are user-specific 114 | *.sublime-workspace 115 | 116 | # project files should be checked into the repository, unless a significant 117 | # proportion of contributors will probably not be using SublimeText 118 | # *.sublime-project 119 | 120 | # sftp configuration file 121 | sftp-config.json 122 | 123 | # Package control specific files 124 | Package Control.last-run 125 | Package Control.ca-list 126 | Package Control.ca-bundle 127 | Package Control.system-ca-bundle 128 | Package Control.cache/ 129 | Package Control.ca-certs/ 130 | Package Control.merged-ca-bundle 131 | Package Control.user-ca-bundle 132 | oscrypto-ca-bundle.crt 133 | bh_unicode_properties.cache 134 | 135 | # Sublime-github package stores a github token in this file 136 | # https://packagecontrol.io/packages/sublime-github 137 | GitHub.sublime-settings 138 | 139 | ### Vim ### 140 | # swap 141 | [._]*.s[a-v][a-z] 142 | [._]*.sw[a-p] 143 | [._]s[a-v][a-z] 144 | [._]sw[a-p] 145 | # session 146 | Session.vim 147 | # temporary 148 | .netrwhist 149 | *~ 150 | # auto-generated tag files 151 | tags 152 | 153 | # End of https://www.gitignore.io/api/vim,python,sublimetext 154 | -------------------------------------------------------------------------------- /Fuzzy-Demo-0.2.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzy/5ef71fa0eede9cea9a0434c6ebdb6bcbf982ac1e/Fuzzy-Demo-0.2.alfredworkflow -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dean Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Fuzzy search for Alfred 3 | ======================= 4 | 5 | `fuzzy.py` is a helper script for Alfred 3+ Script Filters that replaces the "Alfred filters results" option with fuzzy search (Alfred uses "word starts with"). 6 | 7 | ![](./demo.gif "") 8 | 9 | 10 | 11 | - [How it works](#how-it-works) 12 | - [Example usage](#example-usage) 13 | - [Demo](#demo) 14 | - [Caveats](#caveats) 15 | - [Performance](#performance) 16 | - [Utility](#utility) 17 | - [Technical details](#technical-details) 18 | - [Customisation](#customisation) 19 | - [Multiple Script Filters](#multiple-script-filters) 20 | - [Thanks](#thanks) 21 | 22 | 23 | 24 | 25 | How it works 26 | ------------ 27 | 28 | Instead of calling your script directly, you call it via `fuzzy.py`, which caches your script's output for the duration of the user session (as long as the user is using your workflow), and filters the items emitted by your script against the user's query using a fuzzy algorithm. 29 | 30 | The query is compared to each item's `match` field if it's present, and against the item's `title` field if not. 31 | 32 | 33 | 34 | Example usage 35 | ------------- 36 | 37 | `fuzzy.py` only works in Script Filters, and you should run it as a bash/zsh script (i.e. with `Language = /bin/bash` or `Language = /bin/zsh`). 38 | 39 | Instead of running your own script directly, place `./fuzzy.py` in front of it. 40 | 41 | For example, if your Script Filter script looks like this: 42 | 43 | ```bash 44 | /usr/bin/python myscript.py 45 | ``` 46 | 47 | You would replace it with: 48 | 49 | ```bash 50 | # Export user query to `query` environment variable, so `fuzzy.py` can read it 51 | export query="$1" 52 | # Or if you're using "with input as {query}" 53 | # export query="{query}" 54 | 55 | # call your original script via `fuzzy.py` 56 | ./fuzzy.py /usr/bin/python myscript.py 57 | ``` 58 | 59 | **Note**: Don't forget to turn off "Alfred filters results"! 60 | 61 | 62 | 63 | Demo 64 | ---- 65 | 66 | Grab the [Fuzzy-Demo.alfredworkflow][demo] file from this repo to try out the search and view an example implementation. 67 | 68 | 69 | 70 | Caveats 71 | ------- 72 | 73 | Fuzzy search, and this implementation in particular, are by no means the "search algorithm to end all algorithms". 74 | 75 | 76 | 77 | ### Performance ### 78 | 79 | By dint of being written in Python and using a more complex algorithm, `fuzzy.py` can only comfortably handle a small fraction of the number of results that Alfred's native search can. On my 2012 MBA, it becomes noticeably, but not annoyingly, sluggish at about ~2500 items. 80 | 81 | If the script is well-received, I'll reimplement it in a compiled language. My [Go library for Alfred workflows][awgo] uses the same algorithm, and can comfortably handle 20K+ items. 82 | 83 | 84 | 85 | ### Utility ### 86 | 87 | Fuzzy search is awesome for some datasets, but fairly sucks for others. It can work very, very well when you only want to search one field, such as name/title or filename/filepath, but it tends to provide sub-optimal results when searching across multiple fields, especially keywords/tags. 88 | 89 | In such cases, you'll usually get better results from a word-based search. 90 | 91 | 92 | 93 | Technical details 94 | ----------------- 95 | 96 | The fuzzy algorithm is taken from [this gist][pyversion] by [@menzenski][menzenski], which is based on Forrest Smith's [reverse engineering of Sublime Text's algorithm][forrest]. 97 | 98 | The only addition is smarter handling of non-ASCII. If the user's query contains only ASCII, the search is diacritic-insensitive. If the query contains non-ASCII, the search considers diacritics. 99 | 100 | 101 | 102 | Customisation 103 | ------------- 104 | 105 | You can tweak the algorithm by altering the bonuses and penalties applied, or changing the characters treated as separators. 106 | 107 | Export different values for the following environment variables before calling `fuzzy.py` to configure the fuzzy algorithm: 108 | 109 | | Variable | Default | Description | 110 | |---------------------|-----------|-----------------------------------------------| 111 | | `adj_bonus` | 5 | Bonus for adjacent matches | 112 | | `camel_bonus` | 10 | Bonus if match is uppercase | 113 | | `sep_bonus` | 10 | Bonus if after a separator | 114 | | `unmatched_penalty` | -1 | Penalty for each unmatched character | 115 | | `lead_penalty` | -3 | Penalty for each character before first match | 116 | | `max_lead_penalty` | -9 | Maximum total `lead_penalty` | 117 | | `separators` | `_-.([/ ` | Characters to consider separators (for the purposes of assigning `sep_bonus`) | 118 | 119 | 120 | 121 | ### Multiple Script Filters ### 122 | 123 | If you're using multiple Script Filters chained together that use different datasets, you'll need to set the `session_var` environment variable to ensure each one uses a separate cache: 124 | 125 | ```bash 126 | # Script Filter 1 127 | export query="$1" 128 | ./fuzzy /usr/bin/python myscript.py 129 | 130 | # Script Filter 2 (downstream of 1) 131 | export query="$1" 132 | export session_var="fuzzy_filter2" 133 | ./fuzzy /usr/bin/python myotherscript.py 134 | ``` 135 | 136 | 137 | Thanks 138 | ------ 139 | 140 | The fuzzy matching code was (mostly) written by [@menzenski][menzenski] and the algorithm was designed by [@forrestthewoods][forrestthewoods]. 141 | 142 | 143 | [awgo]: https://github.com/deanishe/awgo 144 | [demo]: ./Fuzzy-Demo-0.2.alfredworkflow 145 | [forrest]: https://blog.forrestthewoods.com/reverse-engineering-sublime-text-s-fuzzy-match-4cffeed33fdb 146 | [forrestthewoods]: https://github.com/forrestthewoods 147 | [menzenski]: https://github.com/menzenski 148 | [pyversion]: https://gist.github.com/menzenski/f0f846a254d269bd567e2160485f4b89 149 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzy/5ef71fa0eede9cea9a0434c6ebdb6bcbf982ac1e/demo.gif -------------------------------------------------------------------------------- /demo/books.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "arg": "https://standardebooks.org/ebooks/abraham-merritt/the-moon-pool", 5 | "uid": "https://standardebooks.org/ebooks/abraham-merritt/the-moon-pool", 6 | "subtitle": "Abraham Merritt", 7 | "title": "The Moon Pool" 8 | }, 9 | { 10 | "arg": "https://standardebooks.org/ebooks/agatha-christie/the-mysterious-affair-at-styles", 11 | "uid": "https://standardebooks.org/ebooks/agatha-christie/the-mysterious-affair-at-styles", 12 | "subtitle": "Agatha Christie", 13 | "title": "The Mysterious Affair at Styles" 14 | }, 15 | { 16 | "arg": "https://standardebooks.org/ebooks/agatha-christie/the-secret-adversary", 17 | "uid": "https://standardebooks.org/ebooks/agatha-christie/the-secret-adversary", 18 | "subtitle": "Agatha Christie", 19 | "title": "The Secret Adversary" 20 | }, 21 | { 22 | "arg": "https://standardebooks.org/ebooks/alfred-lord-tennyson/idylls-of-the-king/gustave-dore", 23 | "uid": "https://standardebooks.org/ebooks/alfred-lord-tennyson/idylls-of-the-king/gustave-dore", 24 | "subtitle": "Alfred, Lord Tennyson", 25 | "title": "Idylls of the King" 26 | }, 27 | { 28 | "arg": "https://standardebooks.org/ebooks/algis-budrys/short-fiction", 29 | "uid": "https://standardebooks.org/ebooks/algis-budrys/short-fiction", 30 | "subtitle": "Algis Budrys", 31 | "title": "Short Fiction" 32 | }, 33 | { 34 | "arg": "https://standardebooks.org/ebooks/anton-chekhov/short-fiction/constance-garnett", 35 | "uid": "https://standardebooks.org/ebooks/anton-chekhov/short-fiction/constance-garnett", 36 | "subtitle": "Anton Chekhov", 37 | "title": "Short Fiction" 38 | }, 39 | { 40 | "arg": "https://standardebooks.org/ebooks/anton-chekhov/the-duel/constance-garnett", 41 | "uid": "https://standardebooks.org/ebooks/anton-chekhov/the-duel/constance-garnett", 42 | "subtitle": "Anton Chekhov", 43 | "title": "The Duel" 44 | }, 45 | { 46 | "arg": "https://standardebooks.org/ebooks/apsley-cherry-garrard/the-worst-journey-in-the-world", 47 | "uid": "https://standardebooks.org/ebooks/apsley-cherry-garrard/the-worst-journey-in-the-world", 48 | "subtitle": "Apsley Cherry-Garrard", 49 | "title": "The Worst Journey in the World" 50 | }, 51 | { 52 | "arg": "https://standardebooks.org/ebooks/arthur-machen/short-fiction", 53 | "uid": "https://standardebooks.org/ebooks/arthur-machen/short-fiction", 54 | "subtitle": "Arthur Machen", 55 | "title": "Short Fiction" 56 | }, 57 | { 58 | "arg": "https://standardebooks.org/ebooks/august-strindberg/the-red-room/ellie-schleussner", 59 | "uid": "https://standardebooks.org/ebooks/august-strindberg/the-red-room/ellie-schleussner", 60 | "subtitle": "August Strindberg", 61 | "title": "The Red Room" 62 | }, 63 | { 64 | "arg": "https://standardebooks.org/ebooks/benjamin-franklin/the-autobiography-of-benjamin-franklin", 65 | "uid": "https://standardebooks.org/ebooks/benjamin-franklin/the-autobiography-of-benjamin-franklin", 66 | "subtitle": "Benjamin Franklin", 67 | "title": "The Autobiography of Benjamin Franklin" 68 | }, 69 | { 70 | "arg": "https://standardebooks.org/ebooks/booth-tarkington/the-magnificent-ambersons", 71 | "uid": "https://standardebooks.org/ebooks/booth-tarkington/the-magnificent-ambersons", 72 | "subtitle": "Booth Tarkington", 73 | "title": "The Magnificent Ambersons" 74 | }, 75 | { 76 | "arg": "https://standardebooks.org/ebooks/bram-stoker/dracula", 77 | "uid": "https://standardebooks.org/ebooks/bram-stoker/dracula", 78 | "subtitle": "Bram Stoker", 79 | "title": "Dracula" 80 | }, 81 | { 82 | "arg": "https://standardebooks.org/ebooks/charles-dickens/david-copperfield", 83 | "uid": "https://standardebooks.org/ebooks/charles-dickens/david-copperfield", 84 | "subtitle": "Charles Dickens", 85 | "title": "David Copperfield" 86 | }, 87 | { 88 | "arg": "https://standardebooks.org/ebooks/c-j-cutcliffe-hyne/the-lost-continent", 89 | "uid": "https://standardebooks.org/ebooks/c-j-cutcliffe-hyne/the-lost-continent", 90 | "subtitle": "C. J. Cutcliffe Hyne", 91 | "title": "The Lost Continent" 92 | }, 93 | { 94 | "arg": "https://standardebooks.org/ebooks/daniel-defoe/a-journal-of-the-plague-year", 95 | "uid": "https://standardebooks.org/ebooks/daniel-defoe/a-journal-of-the-plague-year", 96 | "subtitle": "Daniel Defoe", 97 | "title": "A Journal of the Plague Year" 98 | }, 99 | { 100 | "arg": "https://standardebooks.org/ebooks/david-lindsay/a-voyage-to-arcturus", 101 | "uid": "https://standardebooks.org/ebooks/david-lindsay/a-voyage-to-arcturus", 102 | "subtitle": "David Lindsay", 103 | "title": "A Voyage to Arcturus" 104 | }, 105 | { 106 | "arg": "https://standardebooks.org/ebooks/d-h-lawrence/sons-and-lovers", 107 | "uid": "https://standardebooks.org/ebooks/d-h-lawrence/sons-and-lovers", 108 | "subtitle": "D. H. Lawrence", 109 | "title": "Sons and Lovers" 110 | }, 111 | { 112 | "arg": "https://standardebooks.org/ebooks/edgar-allan-poe/the-narrative-of-arthur-gordon-pym-of-nantucket", 113 | "uid": "https://standardebooks.org/ebooks/edgar-allan-poe/the-narrative-of-arthur-gordon-pym-of-nantucket", 114 | "subtitle": "Edgar Allan Poe", 115 | "title": "The Narrative of Arthur Gordon Pym of Nantucket" 116 | }, 117 | { 118 | "arg": "https://standardebooks.org/ebooks/edgar-rice-burroughs/a-princess-of-mars/frank-e-schoonover", 119 | "uid": "https://standardebooks.org/ebooks/edgar-rice-burroughs/a-princess-of-mars/frank-e-schoonover", 120 | "subtitle": "Edgar Rice Burroughs", 121 | "title": "A Princess of Mars" 122 | }, 123 | { 124 | "arg": "https://standardebooks.org/ebooks/elizabeth-gaskell/cranford", 125 | "uid": "https://standardebooks.org/ebooks/elizabeth-gaskell/cranford", 126 | "subtitle": "Elizabeth Gaskell", 127 | "title": "Cranford" 128 | }, 129 | { 130 | "arg": "https://standardebooks.org/ebooks/e-m-forster/a-room-with-a-view", 131 | "uid": "https://standardebooks.org/ebooks/e-m-forster/a-room-with-a-view", 132 | "subtitle": "E. M. Forster", 133 | "title": "A Room With a View" 134 | }, 135 | { 136 | "arg": "https://standardebooks.org/ebooks/emile-zola/his-masterpiece/ernest-alfred-vizetelly", 137 | "uid": "https://standardebooks.org/ebooks/emile-zola/his-masterpiece/ernest-alfred-vizetelly", 138 | "subtitle": "\u00c9mile Zola", 139 | "title": "His Masterpiece" 140 | }, 141 | { 142 | "arg": "https://standardebooks.org/ebooks/epictetus/the-enchiridion/elizabeth-carter", 143 | "uid": "https://standardebooks.org/ebooks/epictetus/the-enchiridion/elizabeth-carter", 144 | "subtitle": "Epictetus", 145 | "title": "The Enchiridion" 146 | }, 147 | { 148 | "arg": "https://standardebooks.org/ebooks/erskine-childers/the-riddle-of-the-sands", 149 | "uid": "https://standardebooks.org/ebooks/erskine-childers/the-riddle-of-the-sands", 150 | "subtitle": "Erskine Childers", 151 | "title": "The Riddle of the Sands" 152 | }, 153 | { 154 | "arg": "https://standardebooks.org/ebooks/e-t-a-hoffmann/master-flea/george-soane", 155 | "uid": "https://standardebooks.org/ebooks/e-t-a-hoffmann/master-flea/george-soane", 156 | "subtitle": "E. T. A. Hoffmann", 157 | "title": "Master Flea" 158 | }, 159 | { 160 | "arg": "https://standardebooks.org/ebooks/ethel-voynich/the-gadfly", 161 | "uid": "https://standardebooks.org/ebooks/ethel-voynich/the-gadfly", 162 | "subtitle": "Ethel Voynich", 163 | "title": "The Gadfly" 164 | }, 165 | { 166 | "arg": "https://standardebooks.org/ebooks/f-marion-crawford/khaled-a-tale-of-arabia", 167 | "uid": "https://standardebooks.org/ebooks/f-marion-crawford/khaled-a-tale-of-arabia", 168 | "subtitle": "F. Marion Crawford", 169 | "title": "Khaled: A Tale of Arabia" 170 | }, 171 | { 172 | "arg": "https://standardebooks.org/ebooks/frances-hodgson-burnett/the-secret-garden", 173 | "uid": "https://standardebooks.org/ebooks/frances-hodgson-burnett/the-secret-garden", 174 | "subtitle": "Frances Hodgson Burnett", 175 | "title": "The Secret Garden" 176 | }, 177 | { 178 | "arg": "https://standardebooks.org/ebooks/francisco-de-quevedo/pablo-de-segovia-the-spanish-sharper/pedro-pineda", 179 | "uid": "https://standardebooks.org/ebooks/francisco-de-quevedo/pablo-de-segovia-the-spanish-sharper/pedro-pineda", 180 | "subtitle": "Francisco de Quevedo", 181 | "title": "Pablo de Segovia, the Spanish Sharper" 182 | }, 183 | { 184 | "arg": "https://standardebooks.org/ebooks/friedrich-nietzsche/beyond-good-and-evil/helen-zimmern", 185 | "uid": "https://standardebooks.org/ebooks/friedrich-nietzsche/beyond-good-and-evil/helen-zimmern", 186 | "subtitle": "Friedrich Nietzsche", 187 | "title": "Beyond Good and Evil" 188 | }, 189 | { 190 | "arg": "https://standardebooks.org/ebooks/fritz-leiber/the-big-time", 191 | "uid": "https://standardebooks.org/ebooks/fritz-leiber/the-big-time", 192 | "subtitle": "Fritz Leiber", 193 | "title": "The Big Time" 194 | }, 195 | { 196 | "arg": "https://standardebooks.org/ebooks/fyodor-dostoevsky/crime-and-punishment/constance-garnett", 197 | "uid": "https://standardebooks.org/ebooks/fyodor-dostoevsky/crime-and-punishment/constance-garnett", 198 | "subtitle": "Fyodor Dostoevsky", 199 | "title": "Crime and Punishment" 200 | }, 201 | { 202 | "arg": "https://standardebooks.org/ebooks/george-macdonald/the-princess-and-the-goblin", 203 | "uid": "https://standardebooks.org/ebooks/george-macdonald/the-princess-and-the-goblin", 204 | "subtitle": "George MacDonald", 205 | "title": "The Princess and the Goblin" 206 | }, 207 | { 208 | "arg": "https://standardebooks.org/ebooks/george-meredith/the-shaving-of-shagpat", 209 | "uid": "https://standardebooks.org/ebooks/george-meredith/the-shaving-of-shagpat", 210 | "subtitle": "George Meredith", 211 | "title": "The Shaving of Shagpat" 212 | }, 213 | { 214 | "arg": "https://standardebooks.org/ebooks/g-k-chesterton/the-innocence-of-father-brown", 215 | "uid": "https://standardebooks.org/ebooks/g-k-chesterton/the-innocence-of-father-brown", 216 | "subtitle": "G. K. Chesterton", 217 | "title": "The Innocence of Father Brown" 218 | }, 219 | { 220 | "arg": "https://standardebooks.org/ebooks/g-k-chesterton/the-man-who-was-thursday", 221 | "uid": "https://standardebooks.org/ebooks/g-k-chesterton/the-man-who-was-thursday", 222 | "subtitle": "G. K. Chesterton", 223 | "title": "The Man Who Was Thursday" 224 | }, 225 | { 226 | "arg": "https://standardebooks.org/ebooks/g-k-chesterton/the-napoleon-of-notting-hill", 227 | "uid": "https://standardebooks.org/ebooks/g-k-chesterton/the-napoleon-of-notting-hill", 228 | "subtitle": "G. K. Chesterton", 229 | "title": "The Napoleon of Notting Hill" 230 | }, 231 | { 232 | "arg": "https://standardebooks.org/ebooks/g-k-chesterton/the-wisdom-of-father-brown", 233 | "uid": "https://standardebooks.org/ebooks/g-k-chesterton/the-wisdom-of-father-brown", 234 | "subtitle": "G. K. Chesterton", 235 | "title": "The Wisdom of Father Brown" 236 | }, 237 | { 238 | "arg": "https://standardebooks.org/ebooks/gustave-flaubert/madame-bovary/eleanor-marx-aveling", 239 | "uid": "https://standardebooks.org/ebooks/gustave-flaubert/madame-bovary/eleanor-marx-aveling", 240 | "subtitle": "Gustave Flaubert", 241 | "title": "Madame Bovary" 242 | }, 243 | { 244 | "arg": "https://standardebooks.org/ebooks/h-beam-piper/little-fuzzy", 245 | "uid": "https://standardebooks.org/ebooks/h-beam-piper/little-fuzzy", 246 | "subtitle": "H. Beam Piper", 247 | "title": "Little Fuzzy" 248 | }, 249 | { 250 | "arg": "https://standardebooks.org/ebooks/h-beam-piper/space-viking", 251 | "uid": "https://standardebooks.org/ebooks/h-beam-piper/space-viking", 252 | "subtitle": "H. Beam Piper", 253 | "title": "Space Viking" 254 | }, 255 | { 256 | "arg": "https://standardebooks.org/ebooks/h-beam-piper/the-cosmic-computer", 257 | "uid": "https://standardebooks.org/ebooks/h-beam-piper/the-cosmic-computer", 258 | "subtitle": "H. Beam Piper", 259 | "title": "The Cosmic Computer" 260 | }, 261 | { 262 | "arg": "https://standardebooks.org/ebooks/henry-david-thoreau/walden", 263 | "uid": "https://standardebooks.org/ebooks/henry-david-thoreau/walden", 264 | "subtitle": "Henry David Thoreau", 265 | "title": "Walden" 266 | }, 267 | { 268 | "arg": "https://standardebooks.org/ebooks/henry-james/the-turn-of-the-screw", 269 | "uid": "https://standardebooks.org/ebooks/henry-james/the-turn-of-the-screw", 270 | "subtitle": "Henry James", 271 | "title": "The Turn of the Screw" 272 | }, 273 | { 274 | "arg": "https://standardebooks.org/ebooks/hermann-hesse/siddhartha/gunther-olesch_anke-dreher_amy-coulter_stefan-langer_semyon-chaichenets", 275 | "uid": "https://standardebooks.org/ebooks/hermann-hesse/siddhartha/gunther-olesch_anke-dreher_amy-coulter_stefan-langer_semyon-chaichenets", 276 | "subtitle": "Hermann Hesse", 277 | "title": "Siddhartha" 278 | }, 279 | { 280 | "arg": "https://standardebooks.org/ebooks/h-g-wells/the-time-machine", 281 | "uid": "https://standardebooks.org/ebooks/h-g-wells/the-time-machine", 282 | "subtitle": "H. G. Wells", 283 | "title": "The Time Machine" 284 | }, 285 | { 286 | "arg": "https://standardebooks.org/ebooks/h-g-wells/the-war-of-the-worlds", 287 | "uid": "https://standardebooks.org/ebooks/h-g-wells/the-war-of-the-worlds", 288 | "subtitle": "H. G. Wells", 289 | "title": "The War of the Worlds" 290 | }, 291 | { 292 | "arg": "https://standardebooks.org/ebooks/honore-de-balzac/father-goriot/ellen-marriage", 293 | "uid": "https://standardebooks.org/ebooks/honore-de-balzac/father-goriot/ellen-marriage", 294 | "subtitle": "Honor\u00e9 de Balzac", 295 | "title": "Father Goriot" 296 | }, 297 | { 298 | "arg": "https://standardebooks.org/ebooks/horace-walpole/the-castle-of-otranto", 299 | "uid": "https://standardebooks.org/ebooks/horace-walpole/the-castle-of-otranto", 300 | "subtitle": "Horace Walpole", 301 | "title": "The Castle of Otranto" 302 | }, 303 | { 304 | "arg": "https://standardebooks.org/ebooks/jack-london/lost-face", 305 | "uid": "https://standardebooks.org/ebooks/jack-london/lost-face", 306 | "subtitle": "Jack London", 307 | "title": "Lost Face" 308 | }, 309 | { 310 | "arg": "https://standardebooks.org/ebooks/jack-london/the-call-of-the-wild", 311 | "uid": "https://standardebooks.org/ebooks/jack-london/the-call-of-the-wild", 312 | "subtitle": "Jack London", 313 | "title": "The Call of the Wild" 314 | }, 315 | { 316 | "arg": "https://standardebooks.org/ebooks/jack-london/white-fang", 317 | "uid": "https://standardebooks.org/ebooks/jack-london/white-fang", 318 | "subtitle": "Jack London", 319 | "title": "White Fang" 320 | }, 321 | { 322 | "arg": "https://standardebooks.org/ebooks/james-de-mille/a-strange-manuscript-found-in-a-copper-cylinder", 323 | "uid": "https://standardebooks.org/ebooks/james-de-mille/a-strange-manuscript-found-in-a-copper-cylinder", 324 | "subtitle": "James De Mille", 325 | "title": "A Strange Manuscript Found in a Copper Cylinder" 326 | }, 327 | { 328 | "arg": "https://standardebooks.org/ebooks/james-fenimore-cooper/the-last-of-the-mohicans", 329 | "uid": "https://standardebooks.org/ebooks/james-fenimore-cooper/the-last-of-the-mohicans", 330 | "subtitle": "James Fenimore Cooper", 331 | "title": "The Last of the Mohicans" 332 | }, 333 | { 334 | "arg": "https://standardebooks.org/ebooks/james-joyce/dubliners", 335 | "uid": "https://standardebooks.org/ebooks/james-joyce/dubliners", 336 | "subtitle": "James Joyce", 337 | "title": "Dubliners" 338 | }, 339 | { 340 | "arg": "https://standardebooks.org/ebooks/jane-austen/pride-and-prejudice", 341 | "uid": "https://standardebooks.org/ebooks/jane-austen/pride-and-prejudice", 342 | "subtitle": "Jane Austen", 343 | "title": "Pride and Prejudice" 344 | }, 345 | { 346 | "arg": "https://standardebooks.org/ebooks/jerome-k-jerome/three-men-in-a-boat", 347 | "uid": "https://standardebooks.org/ebooks/jerome-k-jerome/three-men-in-a-boat", 348 | "subtitle": "Jerome K. Jerome", 349 | "title": "Three Men in a Boat" 350 | }, 351 | { 352 | "arg": "https://standardebooks.org/ebooks/john-stuart-mill/on-liberty", 353 | "uid": "https://standardebooks.org/ebooks/john-stuart-mill/on-liberty", 354 | "subtitle": "John Stuart Mill", 355 | "title": "On Liberty" 356 | }, 357 | { 358 | "arg": "https://standardebooks.org/ebooks/joseph-conrad/heart-of-darkness", 359 | "uid": "https://standardebooks.org/ebooks/joseph-conrad/heart-of-darkness", 360 | "subtitle": "Joseph Conrad", 361 | "title": "Heart of Darkness" 362 | }, 363 | { 364 | "arg": "https://standardebooks.org/ebooks/joseph-conrad/the-secret-agent", 365 | "uid": "https://standardebooks.org/ebooks/joseph-conrad/the-secret-agent", 366 | "subtitle": "Joseph Conrad", 367 | "title": "The Secret Agent" 368 | }, 369 | { 370 | "arg": "https://standardebooks.org/ebooks/jules-verne/around-the-world-in-eighty-days/george-makepeace-towle", 371 | "uid": "https://standardebooks.org/ebooks/jules-verne/around-the-world-in-eighty-days/george-makepeace-towle", 372 | "subtitle": "Jules Verne", 373 | "title": "Around the World In Eighty Days" 374 | }, 375 | { 376 | "arg": "https://standardebooks.org/ebooks/jules-verne/the-mysterious-island/stephen-w-white", 377 | "uid": "https://standardebooks.org/ebooks/jules-verne/the-mysterious-island/stephen-w-white", 378 | "subtitle": "Jules Verne", 379 | "title": "The Mysterious Island" 380 | }, 381 | { 382 | "arg": "https://standardebooks.org/ebooks/jules-verne/twenty-thousand-leagues-under-the-seas/f-p-walter", 383 | "uid": "https://standardebooks.org/ebooks/jules-verne/twenty-thousand-leagues-under-the-seas/f-p-walter", 384 | "subtitle": "Jules Verne", 385 | "title": "Twenty Thousand Leagues Under the Seas" 386 | }, 387 | { 388 | "arg": "https://standardebooks.org/ebooks/j-w-von-goethe/the-sorrows-of-young-werther/r-d-boylan", 389 | "uid": "https://standardebooks.org/ebooks/j-w-von-goethe/the-sorrows-of-young-werther/r-d-boylan", 390 | "subtitle": "J. W. von Goethe", 391 | "title": "The Sorrows of Young Werther" 392 | }, 393 | { 394 | "arg": "https://standardebooks.org/ebooks/kenneth-grahame/the-wind-in-the-willows", 395 | "uid": "https://standardebooks.org/ebooks/kenneth-grahame/the-wind-in-the-willows", 396 | "subtitle": "Kenneth Grahame", 397 | "title": "The Wind in the Willows" 398 | }, 399 | { 400 | "arg": "https://standardebooks.org/ebooks/laozi/tao-te-ching/james-legge", 401 | "uid": "https://standardebooks.org/ebooks/laozi/tao-te-ching/james-legge", 402 | "subtitle": "Laozi", 403 | "title": "Tao Te Ching" 404 | }, 405 | { 406 | "arg": "https://standardebooks.org/ebooks/leo-tolstoy/a-confession/aylmer-maude_louise-maude", 407 | "uid": "https://standardebooks.org/ebooks/leo-tolstoy/a-confession/aylmer-maude_louise-maude", 408 | "subtitle": "Leo Tolstoy", 409 | "title": "A Confession" 410 | }, 411 | { 412 | "arg": "https://standardebooks.org/ebooks/leo-tolstoy/the-kingdom-of-god-is-within-you/leo-wiener", 413 | "uid": "https://standardebooks.org/ebooks/leo-tolstoy/the-kingdom-of-god-is-within-you/leo-wiener", 414 | "subtitle": "Leo Tolstoy", 415 | "title": "The Kingdom of God Is Within You" 416 | }, 417 | { 418 | "arg": "https://standardebooks.org/ebooks/lewis-carroll/alices-adventures-in-wonderland", 419 | "uid": "https://standardebooks.org/ebooks/lewis-carroll/alices-adventures-in-wonderland", 420 | "subtitle": "Lewis Carroll", 421 | "title": "Alice's Adventures in Wonderland" 422 | }, 423 | { 424 | "arg": "https://standardebooks.org/ebooks/lord-dunsany/the-book-of-wonder/sidney-h-sime", 425 | "uid": "https://standardebooks.org/ebooks/lord-dunsany/the-book-of-wonder/sidney-h-sime", 426 | "subtitle": "Lord Dunsany", 427 | "title": "The Book of Wonder" 428 | }, 429 | { 430 | "arg": "https://standardebooks.org/ebooks/lord-dunsany/the-gods-of-pegana", 431 | "uid": "https://standardebooks.org/ebooks/lord-dunsany/the-gods-of-pegana", 432 | "subtitle": "Lord Dunsany", 433 | "title": "The Gods of Peg\u0101na" 434 | }, 435 | { 436 | "arg": "https://standardebooks.org/ebooks/louisa-may-alcott/little-women", 437 | "uid": "https://standardebooks.org/ebooks/louisa-may-alcott/little-women", 438 | "subtitle": "Louisa May Alcott", 439 | "title": "Little Women" 440 | }, 441 | { 442 | "arg": "https://standardebooks.org/ebooks/marcus-aurelius/meditations/george-long", 443 | "uid": "https://standardebooks.org/ebooks/marcus-aurelius/meditations/george-long", 444 | "subtitle": "Marcus Aurelius", 445 | "title": "Meditations" 446 | }, 447 | { 448 | "arg": "https://standardebooks.org/ebooks/mark-twain/a-connecticut-yankee-in-king-arthurs-court", 449 | "uid": "https://standardebooks.org/ebooks/mark-twain/a-connecticut-yankee-in-king-arthurs-court", 450 | "subtitle": "Mark Twain", 451 | "title": "A Connecticut Yankee in King Arthur's Court" 452 | }, 453 | { 454 | "arg": "https://standardebooks.org/ebooks/mark-twain/the-adventures-of-huckleberry-finn", 455 | "uid": "https://standardebooks.org/ebooks/mark-twain/the-adventures-of-huckleberry-finn", 456 | "subtitle": "Mark Twain", 457 | "title": "The Adventures of Huckleberry Finn" 458 | }, 459 | { 460 | "arg": "https://standardebooks.org/ebooks/mary-shelley/frankenstein", 461 | "uid": "https://standardebooks.org/ebooks/mary-shelley/frankenstein", 462 | "subtitle": "Mary Shelley", 463 | "title": "Frankenstein" 464 | }, 465 | { 466 | "arg": "https://standardebooks.org/ebooks/max-beerbohm/zuleika-dobson", 467 | "uid": "https://standardebooks.org/ebooks/max-beerbohm/zuleika-dobson", 468 | "subtitle": "Max Beerbohm", 469 | "title": "Zuleika Dobson" 470 | }, 471 | { 472 | "arg": "https://standardebooks.org/ebooks/miguel-de-cervantes-saavedra/don-quixote/john-ormsby", 473 | "uid": "https://standardebooks.org/ebooks/miguel-de-cervantes-saavedra/don-quixote/john-ormsby", 474 | "subtitle": "Miguel de Cervantes Saavedra", 475 | "title": "Don Quixote" 476 | }, 477 | { 478 | "arg": "https://standardebooks.org/ebooks/niccolo-machiavelli/the-prince/w-k-marriott", 479 | "uid": "https://standardebooks.org/ebooks/niccolo-machiavelli/the-prince/w-k-marriott", 480 | "subtitle": "Niccol\u00f2 Machiavelli", 481 | "title": "The Prince" 482 | }, 483 | { 484 | "arg": "https://standardebooks.org/ebooks/nikolai-gogol/dead-souls/d-j-hogarth", 485 | "uid": "https://standardebooks.org/ebooks/nikolai-gogol/dead-souls/d-j-hogarth", 486 | "subtitle": "Nikolai Gogol", 487 | "title": "Dead Souls" 488 | }, 489 | { 490 | "arg": "https://standardebooks.org/ebooks/omar-khayyam/the-rubaiyat-of-omar-khayyam/edward-fitzgerald/edmund-dulac", 491 | "uid": "https://standardebooks.org/ebooks/omar-khayyam/the-rubaiyat-of-omar-khayyam/edward-fitzgerald/edmund-dulac", 492 | "subtitle": "Omar Khayy\u00e1m", 493 | "title": "The Rub\u00e1iy\u00e1t of Omar Khayy\u00e1m" 494 | }, 495 | { 496 | "arg": "https://standardebooks.org/ebooks/oscar-wilde/the-importance-of-being-earnest", 497 | "uid": "https://standardebooks.org/ebooks/oscar-wilde/the-importance-of-being-earnest", 498 | "subtitle": "Oscar Wilde", 499 | "title": "The Importance of Being Earnest" 500 | }, 501 | { 502 | "arg": "https://standardebooks.org/ebooks/oscar-wilde/the-picture-of-dorian-gray", 503 | "uid": "https://standardebooks.org/ebooks/oscar-wilde/the-picture-of-dorian-gray", 504 | "subtitle": "Oscar Wilde", 505 | "title": "The Picture of Dorian Gray" 506 | }, 507 | { 508 | "arg": "https://standardebooks.org/ebooks/p-g-wodehouse/right-ho-jeeves", 509 | "uid": "https://standardebooks.org/ebooks/p-g-wodehouse/right-ho-jeeves", 510 | "subtitle": "P. G. Wodehouse", 511 | "title": "Right Ho, Jeeves" 512 | }, 513 | { 514 | "arg": "https://standardebooks.org/ebooks/philip-francis-nowlan/armageddon-2419-a-d", 515 | "uid": "https://standardebooks.org/ebooks/philip-francis-nowlan/armageddon-2419-a-d", 516 | "subtitle": "Philip Francis Nowlan", 517 | "title": "Armageddon 2419 A.D." 518 | }, 519 | { 520 | "arg": "https://standardebooks.org/ebooks/philip-k-dick/short-fiction", 521 | "uid": "https://standardebooks.org/ebooks/philip-k-dick/short-fiction", 522 | "subtitle": "Philip K. Dick", 523 | "title": "Short Fiction" 524 | }, 525 | { 526 | "arg": "https://standardebooks.org/ebooks/p-t-barnum/the-art-of-money-getting", 527 | "uid": "https://standardebooks.org/ebooks/p-t-barnum/the-art-of-money-getting", 528 | "subtitle": "P. T. Barnum", 529 | "title": "The Art of Money Getting" 530 | }, 531 | { 532 | "arg": "https://standardebooks.org/ebooks/rabindranath-tagore/gitanjali", 533 | "uid": "https://standardebooks.org/ebooks/rabindranath-tagore/gitanjali", 534 | "subtitle": "Rabindranath Tagore", 535 | "title": "Gitanjali" 536 | }, 537 | { 538 | "arg": "https://standardebooks.org/ebooks/rafael-sabatini/scaramouche", 539 | "uid": "https://standardebooks.org/ebooks/rafael-sabatini/scaramouche", 540 | "subtitle": "Rafael Sabatini", 541 | "title": "Scaramouche" 542 | }, 543 | { 544 | "arg": "https://standardebooks.org/ebooks/robert-frost/north-of-boston", 545 | "uid": "https://standardebooks.org/ebooks/robert-frost/north-of-boston", 546 | "subtitle": "Robert Frost", 547 | "title": "North of Boston" 548 | }, 549 | { 550 | "arg": "https://standardebooks.org/ebooks/robert-louis-stevenson/the-strange-case-of-dr-jekyll-and-mr-hyde", 551 | "uid": "https://standardebooks.org/ebooks/robert-louis-stevenson/the-strange-case-of-dr-jekyll-and-mr-hyde", 552 | "subtitle": "Robert Louis Stevenson", 553 | "title": "The Strange Case of Dr. Jekyll and Mr. Hyde" 554 | }, 555 | { 556 | "arg": "https://standardebooks.org/ebooks/robert-louis-stevenson/treasure-island/milo-winter", 557 | "uid": "https://standardebooks.org/ebooks/robert-louis-stevenson/treasure-island/milo-winter", 558 | "subtitle": "Robert Louis Stevenson", 559 | "title": "Treasure Island" 560 | }, 561 | { 562 | "arg": "https://standardebooks.org/ebooks/robert-w-chambers/the-king-in-yellow", 563 | "uid": "https://standardebooks.org/ebooks/robert-w-chambers/the-king-in-yellow", 564 | "subtitle": "Robert W. Chambers", 565 | "title": "The King in Yellow" 566 | }, 567 | { 568 | "arg": "https://standardebooks.org/ebooks/rudolph-erich-raspe/the-surprising-adventures-of-baron-munchausen", 569 | "uid": "https://standardebooks.org/ebooks/rudolph-erich-raspe/the-surprising-adventures-of-baron-munchausen", 570 | "subtitle": "Rudolph Erich Raspe", 571 | "title": "The Surprising Adventures of Baron Munchausen" 572 | }, 573 | { 574 | "arg": "https://standardebooks.org/ebooks/rudyard-kipling/the-jungle-book", 575 | "uid": "https://standardebooks.org/ebooks/rudyard-kipling/the-jungle-book", 576 | "subtitle": "Rudyard Kipling", 577 | "title": "The Jungle Book" 578 | }, 579 | { 580 | "arg": "https://standardebooks.org/ebooks/samuel-johnson/the-history-of-rasselas-prince-of-abyssinia", 581 | "uid": "https://standardebooks.org/ebooks/samuel-johnson/the-history-of-rasselas-prince-of-abyssinia", 582 | "subtitle": "Samuel Johnson", 583 | "title": "The History of Rasselas, Prince of Abyssinia" 584 | }, 585 | { 586 | "arg": "https://standardebooks.org/ebooks/sax-rohmer/the-insidious-dr-fu-manchu", 587 | "uid": "https://standardebooks.org/ebooks/sax-rohmer/the-insidious-dr-fu-manchu", 588 | "subtitle": "Sax Rohmer", 589 | "title": "The Insidious Dr. Fu-Manchu" 590 | }, 591 | { 592 | "arg": "https://standardebooks.org/ebooks/selma-lagerlof/the-wonderful-adventures-of-nils/velma-swanston-howard", 593 | "uid": "https://standardebooks.org/ebooks/selma-lagerlof/the-wonderful-adventures-of-nils/velma-swanston-howard", 594 | "subtitle": "Selma Lagerl\u00f6f", 595 | "title": "The Wonderful Adventures of Nils" 596 | }, 597 | { 598 | "arg": "https://standardebooks.org/ebooks/seneca/dialogues/aubrey-stewart", 599 | "uid": "https://standardebooks.org/ebooks/seneca/dialogues/aubrey-stewart", 600 | "subtitle": "Seneca", 601 | "title": "Dialogues" 602 | }, 603 | { 604 | "arg": "https://standardebooks.org/ebooks/sir-arthur-conan-doyle/the-lost-world", 605 | "uid": "https://standardebooks.org/ebooks/sir-arthur-conan-doyle/the-lost-world", 606 | "subtitle": "Sir Arthur Conan Doyle", 607 | "title": "The Lost World" 608 | }, 609 | { 610 | "arg": "https://standardebooks.org/ebooks/stanley-g-weinbaum/short-fiction", 611 | "uid": "https://standardebooks.org/ebooks/stanley-g-weinbaum/short-fiction", 612 | "subtitle": "Stanley G. Weinbaum", 613 | "title": "Short Fiction" 614 | }, 615 | { 616 | "arg": "https://standardebooks.org/ebooks/thomas-carlyle/sartor-resartus", 617 | "uid": "https://standardebooks.org/ebooks/thomas-carlyle/sartor-resartus", 618 | "subtitle": "Thomas Carlyle", 619 | "title": "Sartor Resartus" 620 | }, 621 | { 622 | "arg": "https://standardebooks.org/ebooks/thomas-de-quincey/confessions-of-an-english-opium-eater", 623 | "uid": "https://standardebooks.org/ebooks/thomas-de-quincey/confessions-of-an-english-opium-eater", 624 | "subtitle": "Thomas De Quincey", 625 | "title": "Confessions of an English Opium-Eater" 626 | }, 627 | { 628 | "arg": "https://standardebooks.org/ebooks/thomas-de-quincey/suspiria-de-profundis", 629 | "uid": "https://standardebooks.org/ebooks/thomas-de-quincey/suspiria-de-profundis", 630 | "subtitle": "Thomas De Quincey", 631 | "title": "Suspiria de Profundis" 632 | }, 633 | { 634 | "arg": "https://standardebooks.org/ebooks/upton-sinclair/the-jungle", 635 | "uid": "https://standardebooks.org/ebooks/upton-sinclair/the-jungle", 636 | "subtitle": "Upton Sinclair", 637 | "title": "The Jungle" 638 | }, 639 | { 640 | "arg": "https://standardebooks.org/ebooks/voltaire/candide/the-modern-library", 641 | "uid": "https://standardebooks.org/ebooks/voltaire/candide/the-modern-library", 642 | "subtitle": "Voltaire", 643 | "title": "Candide" 644 | }, 645 | { 646 | "arg": "https://standardebooks.org/ebooks/wilkie-collins/no-name", 647 | "uid": "https://standardebooks.org/ebooks/wilkie-collins/no-name", 648 | "subtitle": "Wilkie Collins", 649 | "title": "No Name" 650 | }, 651 | { 652 | "arg": "https://standardebooks.org/ebooks/william-hazlitt/table-talk", 653 | "uid": "https://standardebooks.org/ebooks/william-hazlitt/table-talk", 654 | "subtitle": "William Hazlitt", 655 | "title": "Table-Talk" 656 | }, 657 | { 658 | "arg": "https://standardebooks.org/ebooks/william-hope-hodgson/the-house-on-the-borderland", 659 | "uid": "https://standardebooks.org/ebooks/william-hope-hodgson/the-house-on-the-borderland", 660 | "subtitle": "William Hope Hodgson", 661 | "title": "The House on the Borderland" 662 | }, 663 | { 664 | "arg": "https://standardebooks.org/ebooks/william-makepeace-thackeray/the-luck-of-barry-lyndon", 665 | "uid": "https://standardebooks.org/ebooks/william-makepeace-thackeray/the-luck-of-barry-lyndon", 666 | "subtitle": "William Makepeace Thackeray", 667 | "title": "The Luck of Barry Lyndon" 668 | }, 669 | { 670 | "arg": "https://standardebooks.org/ebooks/william-makepeace-thackeray/vanity-fair", 671 | "uid": "https://standardebooks.org/ebooks/william-makepeace-thackeray/vanity-fair", 672 | "subtitle": "William Makepeace Thackeray", 673 | "title": "Vanity Fair" 674 | }, 675 | { 676 | "arg": "https://standardebooks.org/ebooks/william-morris/the-wood-beyond-the-world", 677 | "uid": "https://standardebooks.org/ebooks/william-morris/the-wood-beyond-the-world", 678 | "subtitle": "William Morris", 679 | "title": "The Wood Beyond the World" 680 | }, 681 | { 682 | "arg": "https://standardebooks.org/ebooks/william-wollaston/the-religion-of-nature-delineated", 683 | "uid": "https://standardebooks.org/ebooks/william-wollaston/the-religion-of-nature-delineated", 684 | "subtitle": "William Wollaston", 685 | "title": "The Religion of Nature Delineated" 686 | }, 687 | { 688 | "arg": "https://standardebooks.org/ebooks/william-wordsworth_samuel-taylor-coleridge/lyrical-ballads", 689 | "uid": "https://standardebooks.org/ebooks/william-wordsworth_samuel-taylor-coleridge/lyrical-ballads", 690 | "subtitle": "William Wordsworth", 691 | "title": "Lyrical Ballads" 692 | }, 693 | { 694 | "arg": "https://standardebooks.org/ebooks/w-w-jacobs/the-lady-of-the-barge/maurice-greiffenhagen", 695 | "uid": "https://standardebooks.org/ebooks/w-w-jacobs/the-lady-of-the-barge/maurice-greiffenhagen", 696 | "subtitle": "W. W. Jacobs", 697 | "title": "The Lady of the Barge" 698 | } 699 | ] 700 | } -------------------------------------------------------------------------------- /demo/ebooks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-09-09 9 | # 10 | 11 | """ 12 | """ 13 | 14 | from __future__ import print_function, absolute_import 15 | 16 | import json 17 | import os 18 | from xml.etree import ElementTree as ET 19 | 20 | XML = os.path.expanduser('~/Desktop/standard_ebooks.xml') 21 | JSON = os.path.join(os.path.dirname(__file__), 'books.json') 22 | 23 | tree = ET.parse(XML) 24 | root = tree.getroot() 25 | 26 | items = [] 27 | 28 | for entry in root: 29 | if entry.tag != 'entry': 30 | continue 31 | 32 | title = entry.find('title').text 33 | url = entry.find('id').text 34 | author = entry.find('author').find('name').text 35 | it = dict(title=title, subtitle=author, arg=url) 36 | print(it) 37 | items.append(it) 38 | 39 | with open(JSON, 'wb') as fp: 40 | json.dump(dict(items=items), fp, indent=2) 41 | -------------------------------------------------------------------------------- /demo/fuzzy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-09-09 9 | # 10 | 11 | """Add fuzzy search to your Alfred 3 Script Filters. 12 | 13 | This script is a replacement for Alfred's "Alfred filters results" 14 | feature that provides a fuzzy search algorithm. 15 | 16 | To use in your Script Filter, you must export the user query to 17 | the ``query`` environment variable, and call your own script via this 18 | one. 19 | 20 | If your Script Filter (using Language = /bin/bash) looks like this: 21 | 22 | /usr/bin/python myscript.py 23 | 24 | Change it to this: 25 | 26 | export query="$1" 27 | ./fuzzy.py /usr/bin/python myscript.py 28 | 29 | Your script will be run once per session (while the use is using your 30 | workflow) to retrieve and cache all items, then the items are filtered 31 | against the user query on their titles using a fuzzy matching algorithm. 32 | 33 | """ 34 | 35 | from __future__ import print_function, absolute_import 36 | 37 | import json 38 | import os 39 | from subprocess import check_output 40 | import sys 41 | import time 42 | from unicodedata import normalize 43 | 44 | # Name of workflow variable storing session ID 45 | SID = os.getenv('session_var') or 'fuzzy_session_id' 46 | 47 | # Workflow's cache directory 48 | CACHEDIR = os.getenv('alfred_workflow_cache') 49 | 50 | # Bonus for adjacent matches 51 | adj_bonus = int(os.getenv('adj_bonus') or '5') 52 | # Bonus if match is uppercase 53 | camel_bonus = int(os.getenv('camel_bonus') or '10') 54 | # Penalty for each character before first match 55 | lead_penalty = int(os.getenv('lead_penalty') or '-3') 56 | # Max total ``lead_penalty`` 57 | max_lead_penalty = int(os.getenv('max_lead_penalty') or '-9') 58 | # Bonus if after a separator 59 | sep_bonus = int(os.getenv('sep_bonus') or '10') 60 | # Penalty for each unmatched character 61 | unmatched_penalty = int(os.getenv('unmatched_penalty') or '-1') 62 | # Characters considered word separators 63 | separators = os.getenv('separators') or '_-.([/ ' 64 | 65 | 66 | def log(s, *args): 67 | """Simple STDERR logger.""" 68 | if args: 69 | s = s % args 70 | print('[fuzzy] ' + s, file=sys.stderr) 71 | 72 | 73 | def fold_diacritics(u): 74 | """Remove diacritics from Unicode string.""" 75 | u = normalize('NFD', u) 76 | s = u.encode('us-ascii', 'ignore') 77 | return unicode(s) 78 | 79 | 80 | def isascii(u): 81 | """Return ``True`` if Unicode string contains only ASCII characters.""" 82 | return u == fold_diacritics(u) 83 | 84 | 85 | def decode(s): 86 | """Decode and NFC-normalise string.""" 87 | if not isinstance(s, unicode): 88 | if isinstance(s, str): 89 | s = s.decode('utf-8') 90 | else: 91 | s = unicode(s) 92 | 93 | return normalize('NFC', s) 94 | 95 | 96 | class Fuzzy(object): 97 | """Fuzzy comparison of strings. 98 | 99 | Attributes: 100 | adj_bonus (int): Bonus for adjacent matches 101 | camel_bonus (int): Bonus if match is uppercase 102 | lead_penalty (int): Penalty for each character before first match 103 | max_lead_penalty (int): Max total ``lead_penalty`` 104 | sep_bonus (int): Bonus if after a separator 105 | separators (str): Characters to consider separators 106 | unmatched_penalty (int): Penalty for each unmatched character 107 | 108 | """ 109 | 110 | def __init__(self, adj_bonus=adj_bonus, sep_bonus=sep_bonus, 111 | camel_bonus=camel_bonus, lead_penalty=lead_penalty, 112 | max_lead_penalty=max_lead_penalty, 113 | unmatched_penalty=unmatched_penalty, 114 | separators=separators): 115 | self.adj_bonus = adj_bonus 116 | self.sep_bonus = sep_bonus 117 | self.camel_bonus = camel_bonus 118 | self.lead_penalty = lead_penalty 119 | self.max_lead_penalty = max_lead_penalty 120 | self.unmatched_penalty = unmatched_penalty 121 | self.separators = separators 122 | self._cache = {} 123 | 124 | def filter_feedback(self, fb, query): 125 | """Filter feedback dict. 126 | 127 | The ``items`` in feedback dict are compared with ``query``. 128 | Items that don't match are removed and the remainder 129 | are sorted by best match. 130 | 131 | If the ``match`` field is set on items, that is used, otherwise 132 | the items' ``title`` fields are used. 133 | 134 | Args: 135 | fb (dict): Parsed Alfred feedback JSON 136 | query (str): Query to filter items against 137 | 138 | Returns: 139 | dict: ``fb`` with items sorted/removed. 140 | """ 141 | fold = isascii(query) 142 | items = [] 143 | 144 | for it in fb['items']: 145 | # use `match` field by preference; fallback to `title` 146 | terms = it['match'] if 'match' in it else it['title'] 147 | if fold: 148 | terms = fold_diacritics(terms) 149 | 150 | ok, score = self.match(query, terms) 151 | if not ok: 152 | continue 153 | 154 | items.append((score, it)) 155 | 156 | items.sort(reverse=True) 157 | fb['items'] = [it for _, it in items] 158 | return fb 159 | 160 | # https://gist.github.com/menzenski/f0f846a254d269bd567e2160485f4b89 161 | def match(self, query, terms): 162 | """Return match boolean and match score. 163 | 164 | Args: 165 | query (str): Query to match against 166 | terms (str): String to score against query 167 | 168 | Returns: 169 | (bool, float): Whether ``terms`` matches ``query`` at all 170 | and a match score. The higher the score, the better 171 | the match. 172 | """ 173 | # Check in-memory cache for previous match 174 | key = (query, terms) 175 | if key in self._cache: 176 | return self._cache[key] 177 | 178 | # Scoring bonuses 179 | adj_bonus = self.adj_bonus 180 | sep_bonus = self.sep_bonus 181 | camel_bonus = self.camel_bonus 182 | lead_penalty = self.lead_penalty 183 | max_lead_penalty = self.max_lead_penalty 184 | unmatched_penalty = self.unmatched_penalty 185 | separators = self.separators 186 | 187 | score, q_idx, t_idx, q_len, t_len = 0, 0, 0, len(query), len(terms) 188 | prev_match, prev_lower = False, False 189 | prev_sep = True # so that matching first letter gets sep_bonus 190 | best_letter, best_lower, best_letter_idx = None, None, None 191 | best_letter_score = 0 192 | matched_indices = [] 193 | 194 | while t_idx != t_len: 195 | p_char = query[q_idx] if (q_idx != q_len) else None 196 | s_char = terms[t_idx] 197 | p_lower = p_char.lower() if p_char else None 198 | s_lower, s_upper = s_char.lower(), s_char.upper() 199 | 200 | next_match = p_char and p_lower == s_lower 201 | rematch = best_letter and best_lower == s_lower 202 | 203 | advanced = next_match and best_letter 204 | p_repeat = best_letter and p_char and best_lower == p_lower 205 | 206 | if advanced or p_repeat: 207 | score += best_letter_score 208 | matched_indices.append(best_letter_idx) 209 | best_letter, best_lower, best_letter_idx = None, None, None 210 | best_letter_score = 0 211 | 212 | if next_match or rematch: 213 | new_score = 0 214 | 215 | # apply penalty for each letter before the first match 216 | # using max because penalties are negative (so max = smallest) 217 | if q_idx == 0: 218 | score += max(t_idx * lead_penalty, max_lead_penalty) 219 | 220 | # apply bonus for consecutive matches 221 | if prev_match: 222 | new_score += adj_bonus 223 | 224 | # apply bonus for matches after a separator 225 | if prev_sep: 226 | new_score += sep_bonus 227 | 228 | # apply bonus across camelCase boundaries 229 | if prev_lower and s_char == s_upper and s_lower != s_upper: 230 | new_score += camel_bonus 231 | 232 | # update query index if the next query letter was matched 233 | if next_match: 234 | q_idx += 1 235 | 236 | # update best letter match (may be next or rematch) 237 | if new_score >= best_letter_score: 238 | # apply penalty for now-skipped letter 239 | if best_letter is not None: 240 | score += unmatched_penalty 241 | best_letter = s_char 242 | best_lower = best_letter.lower() 243 | best_letter_idx = t_idx 244 | best_letter_score = new_score 245 | 246 | prev_match = True 247 | 248 | else: 249 | score += unmatched_penalty 250 | prev_match = False 251 | 252 | prev_lower = s_char == s_lower and s_lower != s_upper 253 | prev_sep = s_char in separators 254 | 255 | t_idx += 1 256 | 257 | if best_letter: 258 | score += best_letter_score 259 | matched_indices.append(best_letter_idx) 260 | 261 | res = (q_idx == q_len, score) 262 | self._cache[key] = res # cache score 263 | 264 | return res 265 | 266 | 267 | class Cache(object): 268 | """Caches script output for the session. 269 | 270 | Attributes: 271 | cache_dir (str): Directory where script output is cached 272 | cmd (list): Command to run your script 273 | 274 | """ 275 | 276 | def __init__(self, cmd): 277 | """Create new cache for a command.""" 278 | self.cmd = cmd 279 | self.cache_dir = os.path.join(CACHEDIR, '_fuzzy') 280 | self._cache_path = None 281 | self._session_id = None 282 | self._from_cache = False 283 | 284 | def load(self): 285 | """Return parsed Alfred feedback from cache or command. 286 | 287 | Returns: 288 | dict: Parsed Alfred feedback. 289 | 290 | """ 291 | sid = self.session_id 292 | if self._from_cache and os.path.exists(self.cache_path): 293 | log('loading cached items ...') 294 | with open(self.cache_path) as fp: 295 | js = fp.read() 296 | else: 297 | log('running command %r ...', self.cmd) 298 | js = check_output(self.cmd) 299 | 300 | fb = json.loads(js) 301 | log('loaded %d item(s)', len(fb.get('items', []))) 302 | 303 | if not self._from_cache: # add session ID 304 | if 'variables' in fb: 305 | fb['variables'][SID] = sid 306 | else: 307 | fb['variables'] = {SID: sid} 308 | 309 | log('added session id %r to results', sid) 310 | 311 | with open(self.cache_path, 'wb') as fp: 312 | json.dump(fb, fp) 313 | log('cached script results to %r', self.cache_path) 314 | 315 | return fb 316 | 317 | @property 318 | def session_id(self): 319 | """ID for this session.""" 320 | if not self._session_id: 321 | sid = os.getenv(SID) 322 | if sid: 323 | self._session_id = sid 324 | self._from_cache = True 325 | else: 326 | self._session_id = str(os.getpid()) 327 | 328 | return self._session_id 329 | 330 | @property 331 | def cache_path(self): 332 | """Return cache path for this session.""" 333 | if not self._cache_path: 334 | if not os.path.exists(self.cache_dir): 335 | os.makedirs(self.cache_dir, 0700) 336 | log('created cache dir %r', self.cache_dir) 337 | 338 | self._cache_path = os.path.join(self.cache_dir, 339 | self.session_id + '.json') 340 | 341 | return self._cache_path 342 | 343 | def clear(self): 344 | """Delete cached files.""" 345 | if not os.path.exists(self.cache_dir): 346 | return 347 | 348 | for fn in os.listdir(self.cache_dir): 349 | os.unlink(os.path.join(self.cache_dir, fn)) 350 | 351 | log('cleared old cache files') 352 | 353 | 354 | def main(): 355 | """Perform fuzzy search on JSON output by specified command.""" 356 | start = time.time() 357 | log('.') # ensure logging output starts on a new line 358 | cmd = sys.argv[1:] 359 | query = os.getenv('query') 360 | log('cmd=%r, query=%r, session_id=%r', cmd, query, 361 | os.getenv(SID)) 362 | 363 | cache = Cache(cmd) 364 | fb = cache.load() 365 | 366 | if query: 367 | query = decode(query) 368 | Fuzzy().filter_feedback(fb, query) 369 | 370 | log('%d item(s) match %r', len(fb['items']), query) 371 | 372 | json.dump(fb, sys.stdout) 373 | log('filtered in %0.2fs', time.time() - start) 374 | 375 | 376 | if __name__ == '__main__': 377 | main() 378 | -------------------------------------------------------------------------------- /demo/gutenberg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-09-09 9 | # 10 | 11 | """ 12 | """ 13 | 14 | from __future__ import print_function, absolute_import 15 | 16 | import json 17 | import os 18 | 19 | from bs4 import BeautifulSoup as BS 20 | 21 | HTML = os.path.expanduser('~/Desktop/french.html') 22 | JSON = os.path.join(os.path.dirname(__file__), 'french_books.json') 23 | # HTML = os.path.expanduser('~/Desktop/german.html') 24 | # JSON = os.path.join(os.path.dirname(__file__), 'german_books.json') 25 | 26 | 27 | items = [] 28 | 29 | soup = BS(open(HTML), 'html.parser') 30 | for tag in soup.find_all('li', 'pgdbetext'): 31 | book = tag.a 32 | title = book.get_text() 33 | url = 'http://www.gutenberg.org' + book['href'] 34 | it = dict(title=title, subtitle=url, arg=url, uid=url) 35 | items.append(it) 36 | 37 | print('%d books' % len(items)) 38 | 39 | with open(JSON, 'wb') as fp: 40 | json.dump(dict(items=items), fp, indent=2) 41 | -------------------------------------------------------------------------------- /demo/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzy/5ef71fa0eede9cea9a0434c6ebdb6bcbf982ac1e/demo/icon.png -------------------------------------------------------------------------------- /demo/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.deanishe.alfred.fuzzy 7 | connections 8 | 9 | 08C8E84F-D822-49B3-9ECD-87F71DBCD4A3 10 | 11 | 12 | destinationuid 13 | 98B11330-54C9-48C1-85EC-48709365D828 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 251A4DB5-A272-4BBE-B11E-CAE1923DA259 23 | 24 | 25 | destinationuid 26 | 53BBDAAF-6AFF-4AF3-85CC-C464A4DD09F4 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 98B11330-54C9-48C1-85EC-48709365D828 36 | 37 | 38 | destinationuid 39 | 251A4DB5-A272-4BBE-B11E-CAE1923DA259 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | 49 | createdby 50 | Dean Jackson 51 | description 52 | Demonstrate Fuzzy Matching 53 | disabled 54 | 55 | name 56 | Fuzzy Demo 57 | objects 58 | 59 | 60 | config 61 | 62 | argumenttrimmode 63 | 0 64 | argumenttype 65 | 1 66 | fixedorder 67 | 68 | items 69 | [{"title":"Standard Ebooks","arg":"books.json","subtitle":"116 books from StandardEbooks.org"},{"title":"Liverpool Players","arg":"lfc.json","subtitle":"740 football players"},{"title":"German Ebooks","arg":"german_books.json","subtitle":"2048 books from Gutenberg.org"},{"title":"French Ebooks","arg":"french_books.json","subtitle":"3543 books from Gutenberg.org"}] 70 | keyword 71 | fuzz 72 | runningsubtext 73 | 74 | subtext 75 | Demonstration of fuzzy search in Script Filters 76 | title 77 | Fuzzy Match Demo 78 | withspace 79 | 80 | 81 | type 82 | alfred.workflow.input.listfilter 83 | uid 84 | 08C8E84F-D822-49B3-9ECD-87F71DBCD4A3 85 | version 86 | 1 87 | 88 | 89 | config 90 | 91 | alfredfiltersresults 92 | 93 | alfredfiltersresultsmatchmode 94 | 0 95 | argumenttrimmode 96 | 0 97 | argumenttype 98 | 1 99 | escaping 100 | 102 101 | queuedelaycustom 102 | 3 103 | queuedelayimmediatelyinitially 104 | 105 | queuedelaymode 106 | 0 107 | queuemode 108 | 1 109 | runningsubtext 110 | Loading data… 111 | script 112 | # export user query for fuzzy.py 113 | export query="$1" 114 | 115 | # call `cat $filename` via fuzzy.py script 116 | ./fuzzy.py cat $filename 117 | scriptargtype 118 | 1 119 | scriptfile 120 | 121 | subtext 122 | 123 | title 124 | 125 | type 126 | 0 127 | withspace 128 | 129 | 130 | type 131 | alfred.workflow.input.scriptfilter 132 | uid 133 | 251A4DB5-A272-4BBE-B11E-CAE1923DA259 134 | version 135 | 2 136 | 137 | 138 | config 139 | 140 | browser 141 | 142 | spaces 143 | 144 | url 145 | {query} 146 | utf8 147 | 148 | 149 | type 150 | alfred.workflow.action.openurl 151 | uid 152 | 53BBDAAF-6AFF-4AF3-85CC-C464A4DD09F4 153 | version 154 | 1 155 | 156 | 157 | config 158 | 159 | argument 160 | 161 | variables 162 | 163 | filename 164 | {query} 165 | 166 | 167 | type 168 | alfred.workflow.utility.argument 169 | uid 170 | 98B11330-54C9-48C1-85EC-48709365D828 171 | version 172 | 1 173 | 174 | 175 | readme 176 | Fuzzy Demo 177 | ========== 178 | 179 | This workflow demonstrates the usage of the fuzzy.py helper script for Alfred 3 Script Filters. 180 | 181 | The script replaces the option "Alfred filters results" with a fuzzy search. 182 | uidata 183 | 184 | 08C8E84F-D822-49B3-9ECD-87F71DBCD4A3 185 | 186 | note 187 | Choose dataset to search 188 | xpos 189 | 40 190 | ypos 191 | 30 192 | 193 | 251A4DB5-A272-4BBE-B11E-CAE1923DA259 194 | 195 | note 196 | Fuzzy filter dataset 197 | xpos 198 | 330 199 | ypos 200 | 30 201 | 202 | 53BBDAAF-6AFF-4AF3-85CC-C464A4DD09F4 203 | 204 | xpos 205 | 510 206 | ypos 207 | 30 208 | 209 | 98B11330-54C9-48C1-85EC-48709365D828 210 | 211 | note 212 | Set filename and clear query 213 | xpos 214 | 230 215 | ypos 216 | 60 217 | 218 | 219 | version 220 | 0.2 221 | webaddress 222 | https://github.com/deanishe/alfred-fuzzy 223 | 224 | 225 | -------------------------------------------------------------------------------- /fuzzy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-09-09 9 | # 10 | 11 | """Add fuzzy search to your Alfred 3 Script Filters. 12 | 13 | This script is a replacement for Alfred's "Alfred filters results" 14 | feature that provides a fuzzy search algorithm. 15 | 16 | To use in your Script Filter, you must export the user query to 17 | the ``query`` environment variable, and call your own script via this 18 | one. 19 | 20 | If your Script Filter (using Language = /bin/bash) looks like this: 21 | 22 | /usr/bin/python myscript.py 23 | 24 | Change it to this: 25 | 26 | export query="$1" 27 | ./fuzzy.py /usr/bin/python myscript.py 28 | 29 | Your script will be run once per session (while the user is using your 30 | workflow) to retrieve and cache all items, then the items are filtered 31 | against the user query using a fuzzy matching algorithm. 32 | 33 | Items are filtered on their `match` field if present, otherwise on 34 | their `title` field. 35 | 36 | """ 37 | 38 | from __future__ import print_function, absolute_import 39 | 40 | import json 41 | import os 42 | from subprocess import check_output 43 | import sys 44 | import time 45 | from unicodedata import normalize 46 | 47 | # Name of workflow variable storing session ID 48 | SID = os.getenv('session_var') or 'fuzzy_session_id' 49 | 50 | # Workflow's cache directory 51 | CACHEDIR = os.getenv('alfred_workflow_cache') 52 | 53 | # Bonus for adjacent matches 54 | adj_bonus = int(os.getenv('adj_bonus') or '5') 55 | # Bonus if match is uppercase 56 | camel_bonus = int(os.getenv('camel_bonus') or '10') 57 | # Penalty for each character before first match 58 | lead_penalty = int(os.getenv('lead_penalty') or '-3') 59 | # Max total ``lead_penalty`` 60 | max_lead_penalty = int(os.getenv('max_lead_penalty') or '-9') 61 | # Bonus if after a separator 62 | sep_bonus = int(os.getenv('sep_bonus') or '10') 63 | # Penalty for each unmatched character 64 | unmatched_penalty = int(os.getenv('unmatched_penalty') or '-1') 65 | # Characters considered word separators 66 | separators = os.getenv('separators') or '_-.([/ ' 67 | 68 | 69 | def log(s, *args): 70 | """Simple STDERR logger.""" 71 | if args: 72 | s = s % args 73 | print('[fuzzy] ' + s, file=sys.stderr) 74 | 75 | 76 | def fold_diacritics(u): 77 | """Remove diacritics from Unicode string.""" 78 | u = normalize('NFD', u) 79 | s = u.encode('us-ascii', 'ignore') 80 | return unicode(s) 81 | 82 | 83 | def isascii(u): 84 | """Return ``True`` if Unicode string contains only ASCII characters.""" 85 | return u == fold_diacritics(u) 86 | 87 | 88 | def decode(s): 89 | """Decode and NFC-normalise string.""" 90 | if not isinstance(s, unicode): 91 | if isinstance(s, str): 92 | s = s.decode('utf-8') 93 | else: 94 | s = unicode(s) 95 | 96 | return normalize('NFC', s) 97 | 98 | 99 | class Fuzzy(object): 100 | """Fuzzy comparison of strings. 101 | 102 | Attributes: 103 | adj_bonus (int): Bonus for adjacent matches 104 | camel_bonus (int): Bonus if match is uppercase 105 | lead_penalty (int): Penalty for each character before first match 106 | max_lead_penalty (int): Max total ``lead_penalty`` 107 | sep_bonus (int): Bonus if after a separator 108 | separators (str): Characters to consider separators 109 | unmatched_penalty (int): Penalty for each unmatched character 110 | 111 | """ 112 | 113 | def __init__(self, adj_bonus=adj_bonus, sep_bonus=sep_bonus, 114 | camel_bonus=camel_bonus, lead_penalty=lead_penalty, 115 | max_lead_penalty=max_lead_penalty, 116 | unmatched_penalty=unmatched_penalty, 117 | separators=separators): 118 | self.adj_bonus = adj_bonus 119 | self.sep_bonus = sep_bonus 120 | self.camel_bonus = camel_bonus 121 | self.lead_penalty = lead_penalty 122 | self.max_lead_penalty = max_lead_penalty 123 | self.unmatched_penalty = unmatched_penalty 124 | self.separators = separators 125 | self._cache = {} 126 | 127 | def filter_feedback(self, fb, query): 128 | """Filter feedback dict. 129 | 130 | The ``items`` in feedback dict are compared with ``query``. 131 | Items that don't match are removed and the remainder 132 | are sorted by best match. 133 | 134 | If the ``match`` field is set on items, that is used, otherwise 135 | the items' ``title`` fields are used. 136 | 137 | Args: 138 | fb (dict): Parsed Alfred feedback JSON 139 | query (str): Query to filter items against 140 | 141 | Returns: 142 | dict: ``fb`` with items sorted/removed. 143 | """ 144 | fold = isascii(query) 145 | items = [] 146 | 147 | for it in fb['items']: 148 | # use `match` field by preference; fallback to `title` 149 | terms = it['match'] if 'match' in it else it['title'] 150 | if fold: 151 | terms = fold_diacritics(terms) 152 | 153 | ok, score = self.match(query, terms) 154 | if not ok: 155 | continue 156 | 157 | items.append((score, it)) 158 | 159 | items.sort(reverse=True) 160 | fb['items'] = [it for _, it in items] 161 | return fb 162 | 163 | # https://gist.github.com/menzenski/f0f846a254d269bd567e2160485f4b89 164 | def match(self, query, terms): 165 | """Return match boolean and match score. 166 | 167 | Args: 168 | query (str): Query to match against 169 | terms (str): String to score against query 170 | 171 | Returns: 172 | (bool, float): Whether ``terms`` matches ``query`` at all 173 | and a match score. The higher the score, the better 174 | the match. 175 | """ 176 | # Check in-memory cache for previous match 177 | key = (query, terms) 178 | if key in self._cache: 179 | return self._cache[key] 180 | 181 | # Scoring bonuses 182 | adj_bonus = self.adj_bonus 183 | sep_bonus = self.sep_bonus 184 | camel_bonus = self.camel_bonus 185 | lead_penalty = self.lead_penalty 186 | max_lead_penalty = self.max_lead_penalty 187 | unmatched_penalty = self.unmatched_penalty 188 | separators = self.separators 189 | 190 | score, q_idx, t_idx, q_len, t_len = 0, 0, 0, len(query), len(terms) 191 | prev_match, prev_lower = False, False 192 | prev_sep = True # so that matching first letter gets sep_bonus 193 | best_letter, best_lower, best_letter_idx = None, None, None 194 | best_letter_score = 0 195 | matched_indices = [] 196 | 197 | while t_idx != t_len: 198 | p_char = query[q_idx] if (q_idx != q_len) else None 199 | s_char = terms[t_idx] 200 | p_lower = p_char.lower() if p_char else None 201 | s_lower, s_upper = s_char.lower(), s_char.upper() 202 | 203 | next_match = p_char and p_lower == s_lower 204 | rematch = best_letter and best_lower == s_lower 205 | 206 | advanced = next_match and best_letter 207 | p_repeat = best_letter and p_char and best_lower == p_lower 208 | 209 | if advanced or p_repeat: 210 | score += best_letter_score 211 | matched_indices.append(best_letter_idx) 212 | best_letter, best_lower, best_letter_idx = None, None, None 213 | best_letter_score = 0 214 | 215 | if next_match or rematch: 216 | new_score = 0 217 | 218 | # apply penalty for each letter before the first match 219 | # using max because penalties are negative (so max = smallest) 220 | if q_idx == 0: 221 | score += max(t_idx * lead_penalty, max_lead_penalty) 222 | 223 | # apply bonus for consecutive matches 224 | if prev_match: 225 | new_score += adj_bonus 226 | 227 | # apply bonus for matches after a separator 228 | if prev_sep: 229 | new_score += sep_bonus 230 | 231 | # apply bonus across camelCase boundaries 232 | if prev_lower and s_char == s_upper and s_lower != s_upper: 233 | new_score += camel_bonus 234 | 235 | # update query index if the next query letter was matched 236 | if next_match: 237 | q_idx += 1 238 | 239 | # update best letter match (may be next or rematch) 240 | if new_score >= best_letter_score: 241 | # apply penalty for now-skipped letter 242 | if best_letter is not None: 243 | score += unmatched_penalty 244 | best_letter = s_char 245 | best_lower = best_letter.lower() 246 | best_letter_idx = t_idx 247 | best_letter_score = new_score 248 | 249 | prev_match = True 250 | 251 | else: 252 | score += unmatched_penalty 253 | prev_match = False 254 | 255 | prev_lower = s_char == s_lower and s_lower != s_upper 256 | prev_sep = s_char in separators 257 | 258 | t_idx += 1 259 | 260 | if best_letter: 261 | score += best_letter_score 262 | matched_indices.append(best_letter_idx) 263 | 264 | res = (q_idx == q_len, score) 265 | self._cache[key] = res # cache score 266 | 267 | return res 268 | 269 | 270 | class Cache(object): 271 | """Caches script output for the session. 272 | 273 | Attributes: 274 | cache_dir (str): Directory where script output is cached 275 | cmd (list): Command to run your script 276 | 277 | """ 278 | 279 | def __init__(self, cmd): 280 | """Create new cache for a command.""" 281 | self.cmd = cmd 282 | self.cache_dir = os.path.join(CACHEDIR, '_fuzzy') 283 | self._cache_path = None 284 | self._session_id = None 285 | self._from_cache = False 286 | 287 | def load(self): 288 | """Return parsed Alfred feedback from cache or command. 289 | 290 | Returns: 291 | dict: Parsed Alfred feedback. 292 | 293 | """ 294 | sid = self.session_id 295 | if self._from_cache and os.path.exists(self.cache_path): 296 | log('loading cached items ...') 297 | with open(self.cache_path) as fp: 298 | js = fp.read() 299 | else: 300 | log('running command %r ...', self.cmd) 301 | js = check_output(self.cmd) 302 | 303 | fb = json.loads(js) 304 | log('loaded %d item(s)', len(fb.get('items', []))) 305 | 306 | if not self._from_cache: # add session ID 307 | if 'variables' in fb: 308 | fb['variables'][SID] = sid 309 | else: 310 | fb['variables'] = {SID: sid} 311 | 312 | log('added session id %r to results', sid) 313 | 314 | with open(self.cache_path, 'wb') as fp: 315 | json.dump(fb, fp) 316 | log('cached script results to %r', self.cache_path) 317 | 318 | return fb 319 | 320 | @property 321 | def session_id(self): 322 | """ID for this session.""" 323 | if not self._session_id: 324 | sid = os.getenv(SID) 325 | if sid: 326 | self._session_id = sid 327 | self._from_cache = True 328 | else: 329 | self._session_id = str(os.getpid()) 330 | 331 | return self._session_id 332 | 333 | @property 334 | def cache_path(self): 335 | """Return cache path for this session.""" 336 | if not self._cache_path: 337 | if not os.path.exists(self.cache_dir): 338 | os.makedirs(self.cache_dir, 0700) 339 | log('created cache dir %r', self.cache_dir) 340 | 341 | self._cache_path = os.path.join(self.cache_dir, 342 | self.session_id + '.json') 343 | 344 | return self._cache_path 345 | 346 | def clear(self): 347 | """Delete cached files.""" 348 | if not os.path.exists(self.cache_dir): 349 | return 350 | 351 | for fn in os.listdir(self.cache_dir): 352 | os.unlink(os.path.join(self.cache_dir, fn)) 353 | 354 | log('cleared old cache files') 355 | 356 | 357 | def main(): 358 | """Perform fuzzy search on JSON output by specified command.""" 359 | start = time.time() 360 | log('.') # ensure logging output starts on a new line 361 | cmd = sys.argv[1:] 362 | query = os.getenv('query') 363 | log('cmd=%r, query=%r, session_id=%r', cmd, query, 364 | os.getenv(SID)) 365 | 366 | cache = Cache(cmd) 367 | fb = cache.load() 368 | 369 | if query: 370 | query = decode(query) 371 | Fuzzy().filter_feedback(fb, query) 372 | 373 | log('%d item(s) match %r', len(fb['items']), query) 374 | 375 | json.dump(fb, sys.stdout) 376 | log('filtered in %0.2fs', time.time() - start) 377 | 378 | 379 | if __name__ == '__main__': 380 | main() 381 | --------------------------------------------------------------------------------