├── .env ├── .githooks ├── post-commit └── pre-commit ├── .github └── workflows │ └── continuous-integration.yml ├── .gitignore ├── .golangci.toml ├── Alfred Booksearch.afdesign ├── LICENCE.txt ├── README.md ├── demo.gif ├── doc ├── README.md ├── configuration.md ├── scripts.md └── usage.md ├── go.mod ├── go.sum ├── icon.png ├── icons ├── author.png ├── book.png ├── clipboard.png ├── config.png ├── delete.png ├── docs.png ├── error.png ├── goodreads.png ├── help.png ├── issue.png ├── link.png ├── locked.png ├── more.png ├── ok.png ├── reload.png ├── save.png ├── script.png ├── series.png ├── shelf-selected.png ├── shelf.png ├── spinner-0.png ├── spinner-1.png ├── spinner-2.png ├── update-available.png ├── update-ok.png ├── url.png └── warning.png ├── info.plist ├── magefile.go ├── main.go ├── modd.conf ├── pkg ├── cli │ ├── author.go │ ├── cache.go │ ├── cli.go │ ├── config.go │ ├── icons.go │ ├── modifiers.go │ ├── options.go │ ├── script_helpers.go │ ├── scripts.go │ ├── search.go │ ├── series.go │ └── shelves.go └── gr │ ├── auth.go │ ├── book.go │ ├── book_test.go │ ├── feed.go │ ├── feed_test.go │ ├── goodreads.go │ ├── http.go │ ├── series.go │ ├── series_test.go │ ├── shelf.go │ ├── shelf_test.go │ ├── testdata │ ├── alex_verus.xml │ ├── currently-reading.xml │ ├── dresden_files.xml │ ├── fantasy.rss │ ├── forged.xml │ ├── jim_butcher.xml │ ├── shockwave.xml │ └── to-read.rss │ ├── text.go │ ├── text_test.go │ └── user.go ├── scripts ├── Add to Currently Reading.zsh ├── Add to Shelves.zsh ├── Add to Want to Read.zsh ├── Copy Goodreads Link.zsh ├── Mark as Read.zsh ├── Open Author Page.zsh ├── Open Book Page.zsh ├── View Author’s Books.zsh ├── View Series.zsh └── View Similar Books.zsh └── vars.py /.env: -------------------------------------------------------------------------------- 1 | # When sourced, creates an Alfred-like environment needed by modd 2 | 3 | # getvar | Read a value from info.plist 4 | getvar() { 5 | local v="$1" 6 | /usr/libexec/PlistBuddy -c "Print :$v" info.plist 7 | } 8 | 9 | export alfred_workflow_bundleid=$( getvar "bundleid" ) 10 | export alfred_workflow_version=$( getvar "version" ) 11 | export alfred_workflow_name=$( getvar "name" ) 12 | export USER_ID=$( getvar "variables:USER_ID" ) 13 | # export USER_FEED_KEY=$( getvar "variables:USER_FEED_KEY" ) 14 | 15 | export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/${alfred_workflow_bundleid}" 16 | export alfred_workflow_data="${HOME}/Library/Application Support/Alfred/Workflow Data/${alfred_workflow_bundleid}" 17 | export alfred_version="4.0.2" 18 | 19 | # Alfred 3 environment if Alfred 4+ prefs file doesn't exist. 20 | if [[ ! -f "$HOME/Library/Application Support/Alfred/prefs.json" ]]; then 21 | export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-3/Workflow Data/${alfred_workflow_bundleid}" 22 | export alfred_workflow_data="${HOME}/Library/Application Support/Alfred 3/Workflow Data/${alfred_workflow_bundleid}" 23 | export alfred_version="3.8.1" 24 | fi 25 | -------------------------------------------------------------------------------- /.githooks/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | script="$( git rev-parse --show-toplevel )/vars.py" 4 | test -x "$script" && "$script" -ar 5 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | names=(USER_ID USER_NAME ACTION_CTRL ACTION_CTRL_CMD ACTION_OPT_CMD ACTION_OPT_CTRL ACTION_SHIFT) 4 | present=() 5 | 6 | for name in $names; do 7 | value="$( /usr/libexec/PlistBuddy -c "Print :variables:${name}" info.plist 2>/dev/null )" 8 | test -z "$value" || present+=($name) 9 | done 10 | 11 | if [[ "${#present}" -gt 0 ]]; then 12 | print -P "%F{red}Please remove the following variables from info.plist before committing:%f" 13 | print -l $present 14 | exit 1 15 | fi 16 | 17 | set -e 18 | 19 | golint -set_exit_status ./... 20 | golangci-lint run -c .golangci.toml 21 | print -P "%F{green}linted OK%f" 22 | go mod tidy 23 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | env: 12 | alfred_version: '4.1' 13 | alfred_preferences: 'Alfred.alfredpreferences' 14 | name: Lint and test 15 | runs-on: macos-latest 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.14.x 21 | 22 | - name: Check out code 23 | uses: actions/checkout@v2 24 | 25 | - name: Install linting dependencies 26 | run: | 27 | go get golang.org/x/lint/golint 28 | go get github.com/golangci/golangci-lint/cmd/golangci-lint 29 | 30 | - name: Lint Go source code 31 | run: | 32 | golint -set_exit_status ./... 33 | golangci-lint run -c .golangci.toml 34 | 35 | - name: Run unit tests 36 | run: go test -v ./... 37 | 38 | - name: Build workflow 39 | run: | 40 | go get github.com/magefile/mage 41 | mkdir -vp Alfred.alfredpreferences/workflows 42 | mage -v build 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *_private.go 2 | 3 | /build 4 | /dist 5 | /vendor 6 | .autoenv*.zsh 7 | /vars.json 8 | 9 | # vim turds 10 | /tags 11 | [._]*.s[a-v][a-z] 12 | [._]*.sw[a-p] 13 | [._]s[a-v][a-z] 14 | [._]sw[a-p] 15 | *~ 16 | /tags 17 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | [run] 2 | deadline = "5m" 3 | 4 | [linters] 5 | disable-all = true 6 | enable = [ 7 | "deadcode", 8 | # "goconst", 9 | "gocritic", 10 | "gofmt", 11 | "goimports", 12 | "gosimple", 13 | "ineffassign", 14 | "scopelint", 15 | "staticcheck", 16 | "stylecheck", 17 | "unconvert", 18 | "unused", 19 | "whitespace", 20 | ] 21 | 22 | [linter-settings] 23 | [linter-settings.errcheck] 24 | check-blank = true 25 | check-type-assertions = true 26 | 27 | [linter-settings.goimports] 28 | local-prefixes = "github.com/deanishe/alfred-booksearch" 29 | 30 | [issues] 31 | max-same-issues = 50 32 | max-issues-per-linter = 50 33 | # exclude = ['ST1005'] 34 | 35 | [[issues.exclude-rules]] 36 | linters = ['stylecheck'] 37 | text = "ST1005:" 38 | 39 | [[issues.exclude-rules]] 40 | linters = ['gocritic'] 41 | text = "captLocal:" 42 | -------------------------------------------------------------------------------- /Alfred Booksearch.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/Alfred Booksearch.afdesign -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 | 7 | Goodreads Book Search for Alfred 8 | ================================ 9 | 10 | ![][demo] 11 | 12 | Search for books and authors in [Alfred 4+][alfred]. 13 | 14 | 15 | 16 | - [Download & installation](#download--installation) 17 | - [Usage](#usage) 18 | - [Configuration & customisation](#configuration--customisation) 19 | - [Licensing & thanks](#licensing--thanks) 20 | 21 | 22 | 23 | 24 | 25 | Download & installation 26 | ----------------------- 27 | 28 | [Grab the workflow from the releases page][download]. Download the 29 | `Book-Search-X.X.X.alfredworkflow` file and double-click it to install. 30 | 31 | 32 | 33 | Usage 34 | ----- 35 | 36 | Basic usage is `bk ` to search for a book and `bkshlf []` to view your bookshelves. See [Usage][usage] for more details. 37 | 38 | 39 | 40 | Configuration & customisation 41 | ----------------------------- 42 | 43 | See [Configuration][configuration] for details. 44 | 45 | 46 | 47 | Licensing & thanks 48 | ------------------ 49 | 50 | This workflow is released under the [MIT Licence][mit]. It is based on [AwGo][awgo] ([MIT][mit]). The icons are based on [Font Awesome][awesome] ([SIL][sil]). 51 | 52 | 53 | [alfred]: https://alfredapp.com/ 54 | [docs]: https://github.com/deanishe/alfred-booksearch/blob/master/doc/README.md 55 | [usage]: https://github.com/deanishe/alfred-booksearch/blob/master/doc/usage.md 56 | [configuration]: https://github.com/deanishe/alfred-booksearch/blob/master/doc/configuration.md 57 | [customisation]: https://github.com/deanishe/alfred-booksearch/blob/master/doc/customisation.md 58 | [awgo]: https://github.com/deanishe/awgo 59 | [download]: https://github.com/deanishe/alfred-booksearch/releases/latest 60 | [issues]: https://github.com/deanishe/alfred-booksearch/issues 61 | [sil]: http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL 62 | [mit]: https://opensource.org/licenses/MIT 63 | [awesome]: http://fortawesome.github.io/Font-Awesome/ 64 | [demo]: https://raw.githubusercontent.com/deanishe/alfred-booksearch/master/demo.gif 65 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/demo.gif -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | 2 | Documentation 3 | ============= 4 | 5 | - [Usage](./usage.md) 6 | - [Configuration](./configuration.md) 7 | - [Scripts](./scripts.md) 8 | -------------------------------------------------------------------------------- /doc/configuration.md: -------------------------------------------------------------------------------- 1 | 2 | Configuration 3 | ============= 4 | 5 | The workflow offers a few options and links under the `bkconf` keyword, but it's primarily configured via its [configuration sheet][confsheet] (the `[x]` button in Alfred Preferences). 6 | 7 | 8 | 9 | 10 | - [Inline configuration](#inline-configuration) 11 | - [Workflow configuration sheet](#workflow-configuration-sheet) 12 | - [Adding custom actions](#adding-custom-actions) 13 | 14 | 15 | 16 | 17 | 18 | Inline configuration 19 | -------------------- 20 | 21 | Keyword: `bkconf` 22 | 23 | 24 | #### Workflow Update Available / Workflow Is Up To Date ### 25 | 26 | If a newer version of the workflow is available, "Workflow Update Available" is shown. Action this item to update the workflow. 27 | 28 | 29 | #### Workflow Authorised / Workflow Not Authorised #### 30 | 31 | Whether you've authorised the workflow to access your Goodreads account via OAuth. If "Workflow Not Authorised" is shown, action the item to go to goodreads.com and authorise the workflow. 32 | 33 | You can deauthorise the workflow (i.e. delete the OAuth tokens) with `⌘↩`. 34 | 35 | 36 | #### Open Scripts Folder #### 37 | 38 | Open custom scripts folder (see [scripts][scripts] for details). 39 | 40 | 41 | #### Open Docs #### 42 | 43 | Open this documentation in your browser. 44 | 45 | 46 | #### Get Help #### 47 | 48 | Open workflow issue tracker in your browser. 49 | 50 | 51 | #### Report Bug #### 52 | 53 | Open workflow issue tracker in your browser. 54 | 55 | 56 | 57 | Workflow configuration sheet 58 | ---------------------------- 59 | 60 | There are only a couple of configuration options by default, but you can add more to customise the workflow. 61 | 62 | | Variable | Default Value | Description | 63 | |------------------|------------------|---------------------------------------------------------------------------------------------------------| 64 | | `ACTION_DEFAULT` | `Open Book Page` | The script run when you press `↩` on a book item. | 65 | | `ACTION_ALT` | `View Series` | The script run when you press `⌥↩` on a book item. | 66 | | `EXPORT_DETAILS` | `false` | Whether all book details should be fetched before running a script (see [scripts][scripts] for details) | 67 | | `USER_ID` | | Your Goodreads ID. Saved by the workflow when you log in. | 68 | | `USER_NAME` | | Your Goodreads username. Saved by the workflow when you log in. | 69 | 70 | 71 | 72 | Adding custom actions 73 | --------------------- 74 | 75 | The actions you can perform on books are primarily defined by scripts. Several scripts are included with the workflow (in the `scripts` subdirectory of the workflow's folder), and you can also add your own (see [scripts][scripts]). 76 | 77 | You can assign any built-in or custom script a hotkey via the configuration by creating a variable with a name of the form `ACTION_` and the name of the script (without file extension) as its value. For example: 78 | 79 | | Variable | Value | Description | 80 | |-----------------------|----------------------|--------------------------------------------------------------------------------------| 81 | | `ACTION_CTRL` | `Mark as Read` | Run the built-in `Mark as Read.zsh` script when you press `^↩` on a book item | 82 | | `ACTION_CTRL_SHIFT` | `View Similar Books` | Run the built-in `View Similar Books.zsh` script when you press `^⇧↩` on a book item | 83 | | `ACTION_CMD_OPT_CTRL` | `My Custom Script` | Run your custom script named `My Custom Script` when you press `^⌥⌘↩` on a book item | 84 | 85 | You can combine modifier keys arbitrarily. 86 | 87 | To simplify adding custom hotkeys, hitting `⌘C` on an action in the "All Actions…" list (`⌘↩` on a book item) will copy its name to the clipboard for easy pasting into the configuration sheet. 88 | 89 | [↑ Documentation][top] 90 | 91 | [top]: ./README.md 92 | [scripts]: ./scripts.md 93 | [confsheet]: https://www.alfredapp.com/help/workflows/advanced/variables/#environment 94 | -------------------------------------------------------------------------------- /doc/scripts.md: -------------------------------------------------------------------------------- 1 | Scripts 2 | ======= 3 | 4 | The actions you can perform on book results in the workflow are defined by scripts. The workflow includes several built-in scripts for common actions, but you can also define your own. 5 | 6 | You can also assign hotkeys to scripts, so you can run the script directly from a book item (see [configuration][configuration]). 7 | 8 | 9 | 10 | - [Built-in scripts/actions](#built-in-scriptsactions) 11 | - [Writing custom scripts](#writing-custom-scripts) 12 | - [Environment variables](#environment-variables) 13 | - [Default variables](#default-variables) 14 | - [Details variables](#details-variables) 15 | - [Formatted versions](#formatted-versions) 16 | - [JSON](#json) 17 | - [Helper functions](#helper-functions) 18 | - [Script icons](#script-icons) 19 | - [Example script](#example-script) 20 | 21 | 22 | 23 | 24 | Built-in scripts/actions 25 | ------------------------ 26 | 27 | The workflow includes the following scripts (in the internal `scripts` subdirectory): 28 | 29 | | Script | Description | 30 | |----------------------------|------------------------------------------------| 31 | | `Add to Currently Reading` | Add book to your "Currently Reading" bookshelf | 32 | | `Add to Shelves` | Add book to one or more shelves | 33 | | `Add to Want to Read` | Add book to your "Want to Read" bookshelf | 34 | | `Copy Goodreads Link` | Copy URL of book's page on goodreads.com | 35 | | `Mark as Read` | Add book to your "Read" bookshelf | 36 | | `Open Author Page` | Open author's page on goodreads.com | 37 | | `Open Book Page` | Open book's page on goodreads.com | 38 | | `View Author’s Books` | View list of author's books in Alfred | 39 | | `View Series` | View all books in a book's series in Alfred | 40 | | `View Similar Books` | Open list of similar books on goodreads.com | 41 | 42 | 43 | 44 | Writing custom scripts 45 | ---------------------- 46 | 47 | You can add new actions to the workflow by saving custom scripts in the user scripts directory. Use `bkconf` > `Open Scripts Folder` to open this folder in Finder. **Do not put your own scripts in the workflow's internal `scripts` directory: they'll be overwritten/deleted when the workflow is updated.** 48 | 49 | A "script" may be an executable file or any of [the script types understood by AwGo][script-types]. Its base name (i.e. without file extension) will be the name used by the workflow. If a custom script has the same name as a built-in one, it will override the built-in. 50 | 51 | 52 | 53 | ### Environment variables ### 54 | 55 | When the workflow executes a script, it passes book properties via environment variables. Unfortunately, different parts of the Goodreads API provide different levels of detail about books, so while some variables are always available, others require the workflow to fetch the book's details from the API. As this can take a few seconds if the details aren't already cached, it isn't done by default (though you can force that behaviour by setting `EXPORT_DETAILS` to `true` in the workflow's configuration sheet). So if your script requires more than basic info about the book, it must call the workflow binary to retrieve it. 56 | 57 | This is done by calling `./alfred-booksearch -export` (all scripts are run with the workflow's directory as the working directory). 58 | 59 | In a shell script, you can export book properties to environment variables with: 60 | 61 | ```bash 62 | eval "$( ./alfred-booksearch -export )" 63 | ``` 64 | 65 | In other languages, you can get book properties as JSON with: 66 | 67 | ```bash 68 | ./alfred-booksearch -export -json 69 | ``` 70 | 71 | 72 | #### Default variables #### 73 | 74 | These variables are always available (provided the book has corresponding properties): 75 | 76 | | Variable | Description | 77 | |-------------------|--------------------------------------------------| 78 | | `BOOK_ID` | Goodreads ID of the book | 79 | | `BOOK_URL` | URL of book's page on goodreads.com | 80 | | `TITLE` | The book title | 81 | | `TITLE_NO_SERIES` | Book title without series info | 82 | | `SERIES` | Title of series book is part of (if it's in one) | 83 | | `AUTHOR` | Name of the author | 84 | | `AUTHOR_ID` | Author's Goodreads ID | 85 | | `AUTHOR_URL` | URL of author's page on goodreads.com | 86 | | `YEAR` | Year book was published (often not available) | 87 | | `RATING` | Book rating (0.0–5.0) | 88 | | `IMAGE_URL` | URL of book's cover (often not available) | 89 | | `USER_ID` | Your Goodreads user ID | 90 | | `USER_NAME` | Your Goodreads username | 91 | 92 | 93 | 94 | #### Details variables #### 95 | 96 | The following variables are only available if you export the books details: 97 | 98 | | Variable | Description | 99 | |------------------------|-----------------------------------| 100 | | `DESCRIPTION` | Plaintext description of the book | 101 | | `DESCRIPTION_HTML` | HTML description of the book | 102 | | `DESCRIPTION_MARKDOWN` | Markdown description of book | 103 | | `SERIES_ID` | Series' Goodreads ID | 104 | | `ISBN` | Book's ISBN | 105 | | `ISBN13` | Book's ISBN 13 | 106 | 107 | 108 | 109 | #### Formatted versions #### 110 | 111 | Two additional, URL-escaped variants of each of the above variables are also exported to make it easier to insert them into URLs. 112 | 113 | Add the suffix `_QUOTED` for a path-escaped (i.e. spaces are replaced with `%20`) version of the variable, or the suffix `_QUOTED_PLUS` for a query-escaped version (i.e. spaces are replaced with `+`). 114 | 115 | For example, `TITLE_QUOTED` is the path-escaped book's title, and `TITLE_QUOTED_PLUS` is the query-escaped book's title. 116 | 117 | 118 | 119 | ### JSON ### 120 | 121 | The JSON emitted by `./alfred-booksearch -export -json` has the following format: 122 | 123 | ```json 124 | { 125 | "ID": 23106013, 126 | "WorkID": 42654036, 127 | "ISBN": "0593199308", 128 | "ISBN13": "9780593199305", 129 | "Title": "Battle Ground (The Dresden Files, #17)", 130 | "TitleNoSeries": "Battle Ground", 131 | "Series": { 132 | "Title": "The Dresden Files", 133 | "Position": 17, 134 | "ID": 40346, 135 | "Books": null 136 | }, 137 | "Author": { 138 | "ID": 10746, 139 | "Name": "Jim Butcher", 140 | "URL": "https://www.goodreads.com/author/show/10746" 141 | }, 142 | "PubDate": "2020-09-29", 143 | "Rating": 4.46, 144 | "Description": "THINGS ARE ABOUT TO GET SERIOUS FOR HARRY DRESDEN, CHICAGO’S ONLY PROFESSIONAL WIZARD, in the next entry in the #1 New York Times bestselling Dresden Files.

Harry has faced terrible odds before. He has a long history of fighting enemies above his weight class. The Red Court of vampires. The fallen angels of the Order of the Blackened Denarius. The Outsiders.

But this time it’s different. A being more powerful and dangerous on an order of magnitude beyond what the world has seen in a millennium is coming. And she’s bringing an army. The Last Titan has declared war on the city of Chicago, and has come to subjugate humanity, obliterating any who stand in her way.

Harry’s mission is simple but impossible: Save the city by killing a Titan. And the attempt will change Harry’s life, Chicago, and the mortal world forever.", 145 | "URL": "https://www.goodreads.com/book/show/23106013", 146 | "ImageURL": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1587778549l/23106013._SX98_.jpg" 147 | } 148 | 149 | ``` 150 | 151 | 152 | Helper functions 153 | ---------------- 154 | 155 | In addition to `-export`, the workflow binary also provides some additional helper functions to allow you to perform workflow actions or direct its behaviour. For example, you can run `./alfred-booksearch -hide=(true|false)` to tell the workflow whether to hide Alfred's window after your script is executed. 156 | 157 | **Note: You may only call `alfred-booksearch` _once_ in your script with any of the following options because it communicates with the workflow via JSON.** 158 | 159 | | Flag | Description | 160 | |----------------------------------------|--------------------------------------------------------------------------------------------------------------| 161 | | `-action ` | Set next action for workflow to run, e.g. `-action search` to open the book search after running your script | 162 | | `-add ...` | Add book to named shelves, e.g. `-add to-read` | 163 | | `-beep` | Play "morse" sound effect | 164 | | `-notify [-message <message>]` | Show a notification | 165 | | `-passvars=true/false` | Pass workflow variables to next action | 166 | | `-hide=true/false` | Hide/show Alfred after script is run | 167 | | `-query <query>` | Set query to pass to next action | 168 | 169 | 170 | 171 | <a id="script-icons"></a> 172 | Script icons 173 | ------------ 174 | 175 | You can assign an icon to a script by putting an icon with the same base name (i.e. without file extension) as the script in the user scripts directory. Supported icon types are PNG, JPG, GIF, ICNS. 176 | 177 | 178 | <a id="example-script"></a> 179 | Example script 180 | -------------- 181 | 182 | As an example, here's how to add a "Search on Amazon.com" script. (Check out the built-in script for more examples.) 183 | 184 | 1. Enter `bkconf scripts` into Alfred, and action the "Open Scripts Directory" item. 185 | 2. Create a new file called "Search on Amazon.com.zsh" in the folder. 186 | 3. Add the following to the script: 187 | 188 | ```bash 189 | # use _QUOTED_PLUS variants, as that's the format Amazon's search URL uses 190 | url="https://www.amazon.com/s?i=stripbooks&k=${TITLE_NO_SERIES_QUOTED_PLUS}+${AUTHOR_QUOTED_PLUS}" 191 | # open the URL in default browser 192 | /usr/bin/open "${url}" 193 | # ensure Alfred's window closes 194 | ./alfred-booksearch -hide 195 | ``` 196 | 197 | There's no need to make the script executable, as the workflow knows to run .zsh files with /bin/zsh. 198 | 199 | 4. Give your script a custom icon by saving an Amazon icon (such as [this one][amazon-icon]) to the same directory with the name `Search on Amazon.com.png`. 200 | 5. Search for a book (keyword `bk`), then use `⌘↩` to show all actions. You should see `Search on Amazon.com` in there. 201 | 6. Select the `Search on Amazon.com` action and hit `⌘C` to copy its name to the clipboard. 202 | 7. Open the workflow in Alfred Preferences and then open its configuration sheet (the `[x]` icon). 203 | 8. Add a new variable called `ACTION_SHIFT`, place the cursor in the Value cell and press `⌘V` to paste the clipboard contents. 204 | 9. Click "Save" (or press `⌘S`) 205 | 206 | You should now be able to search for a book on amazon.com by hitting `⇧↩` on a book in the workflow's search results. 207 | 208 | [↑ Documentation][top] 209 | 210 | [top]: ./README.md 211 | [configuration]: ./configuration.md 212 | [script-types]: https://godoc.org/github.com/deanishe/awgo/util#Runner 213 | [amazon-icon]: https://github.com/deanishe/alfred-searchio/raw/000243deca20c79024d27a50c7b301c44a5de4a9/src/icons/engines/amazon.png 214 | -------------------------------------------------------------------------------- /doc/usage.md: -------------------------------------------------------------------------------- 1 | 2 | Usage 3 | ===== 4 | 5 | When you first run the workflow, it will ask you to log into Goodreads via OAuth. This is necessary so the workflow can read and edit your bookshelves. 6 | 7 | - `bk <query>` — Search for a book 8 | - Common book actions (see below) 9 | - `bkshlf [<query>]` — View your bookshelves 10 | - `↩` — View books on bookshelf 11 | - Common book actions (see below) 12 | - Enter `shelves` to go back to list of all bookshelves 13 | - `⌘↩` — View bookshelf on goodreads.com 14 | - `bkconf [<query>]` — Workflow configuration 15 | - Common book actions 16 | - `↩` — Open book on goodreads.com 17 | - `⌘↩` — Show all book actions 18 | - `⌥↩` — View book series 19 | - `...` — Run custom action (see [configuration][configuration]) 20 | 21 | 22 | [↑ Documentation][top] 23 | 24 | [top]: ./README.md 25 | [configuration]: ./configuration.md 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.deanishe.net/alfred-booksearch 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar v1.3.2 // indirect 7 | github.com/deanishe/awgo v0.27.1 8 | github.com/disintegration/imaging v1.6.2 9 | github.com/fxtlabs/date v0.0.0-20150819233934-d9ab6e2a88a9 10 | github.com/keegancsmith/shell v0.0.0-20160208231706-ccb53e0c7c5c 11 | github.com/magefile/mage v1.10.0 12 | github.com/microcosm-cc/bluemonday v1.0.4 13 | github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 14 | github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09 15 | github.com/pkg/errors v0.9.1 16 | github.com/stretchr/testify v1.6.1 17 | go.deanishe.net/fuzzy v1.0.0 18 | golang.org/x/image v0.0.0-20200801110659-972c09e46d76 // indirect 19 | golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 3 | github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY= 4 | github.com/bmatcuk/doublestar v1.3.1/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 5 | github.com/bmatcuk/doublestar v1.3.2 h1:mzUncgFmpzNUhIITFqGdZ8nUU0O7JTJzRO8VdkeLCSo= 6 | github.com/bmatcuk/doublestar v1.3.2/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 7 | github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= 8 | github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/deanishe/awgo v0.27.1 h1:Vf8v7yaGWN3fibT+db1Mfw95Q/rD/1Qbf4ahGa4tMSY= 14 | github.com/deanishe/awgo v0.27.1/go.mod h1:Qen3509y1/sj7a5syefWc6FQHO1LE/tyoTyN9jRv1vU= 15 | github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= 16 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 17 | github.com/fxtlabs/date v0.0.0-20150819233934-d9ab6e2a88a9 h1:NERIc41aohgojUAgWCCnN5B8dIXZsBo2UC04LR3tbao= 18 | github.com/fxtlabs/date v0.0.0-20150819233934-d9ab6e2a88a9/go.mod h1:UoIEyXCyEJ1Zu3ejiUOSngl9U5Oe9S+qaNiYiUex2nk= 19 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 20 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 21 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 22 | github.com/keegancsmith/shell v0.0.0-20160208231706-ccb53e0c7c5c h1:6wy/0GuTK44yNuZ36eaqww+vWdVrFqByuixpwCczb3M= 23 | github.com/keegancsmith/shell v0.0.0-20160208231706-ccb53e0c7c5c/go.mod h1:qbjfLhTSXb/4ZbhLyMVBWsgwT3KBdhkYbGGN0qdHHQs= 24 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 25 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 26 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 27 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 28 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 29 | github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g= 30 | github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 31 | github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg= 32 | github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= 33 | github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= 34 | github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= 35 | github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09 h1:DXR0VtCesBD2ss3toN9OEeXszpQmW9dc3SvUbUfiBC0= 36 | github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09/go.mod h1:1rLVY/DWf3U6vSZgH16S7pymfrhK2lcUlXjgGglw/lY= 37 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 38 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 39 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 44 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 45 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 46 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 47 | go.deanishe.net/env v0.5.1 h1:WiOncK5uJj8Um57Vj2dc1bq1lMN7fgRag9up7I3LZy0= 48 | go.deanishe.net/env v0.5.1/go.mod h1:ihEYfDm0K0hq3f5ACTCQDrMTWxH9fTiA1lh1i0aMqm0= 49 | go.deanishe.net/fuzzy v1.0.0 h1:3Qp6PCX0DLb9z03b5OHwAGsbRSkgJpSLncsiDdXDt4Y= 50 | go.deanishe.net/fuzzy v1.0.0/go.mod h1:2yEEMfG7jWgT1s5EO0TteVWmx2MXFBRMr5cMm84bQNY= 51 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 52 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 53 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= 54 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 55 | golang.org/x/image v0.0.0-20200801110659-972c09e46d76 h1:U7GPaoQyQmX+CBRWXKrvRzWTbd+slqeSh8uARsIyhAw= 56 | golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 57 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= 58 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 59 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 60 | golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= 61 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 62 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 63 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 64 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 67 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 72 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 73 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 74 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 75 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 76 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 77 | howett.net/plist v0.0.0-20200419221736-3b63eb3a43b5 h1:AQkaJpH+/FmqRjmXZPELom5zIERYZfwTjnHpfoVMQEc= 78 | howett.net/plist v0.0.0-20200419221736-3b63eb3a43b5/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 79 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- 1 | ./icons/book.png -------------------------------------------------------------------------------- /icons/author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/author.png -------------------------------------------------------------------------------- /icons/book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/book.png -------------------------------------------------------------------------------- /icons/clipboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/clipboard.png -------------------------------------------------------------------------------- /icons/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/config.png -------------------------------------------------------------------------------- /icons/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/delete.png -------------------------------------------------------------------------------- /icons/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/docs.png -------------------------------------------------------------------------------- /icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/error.png -------------------------------------------------------------------------------- /icons/goodreads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/goodreads.png -------------------------------------------------------------------------------- /icons/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/help.png -------------------------------------------------------------------------------- /icons/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/issue.png -------------------------------------------------------------------------------- /icons/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/link.png -------------------------------------------------------------------------------- /icons/locked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/locked.png -------------------------------------------------------------------------------- /icons/more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/more.png -------------------------------------------------------------------------------- /icons/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/ok.png -------------------------------------------------------------------------------- /icons/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/reload.png -------------------------------------------------------------------------------- /icons/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/save.png -------------------------------------------------------------------------------- /icons/script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/script.png -------------------------------------------------------------------------------- /icons/series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/series.png -------------------------------------------------------------------------------- /icons/shelf-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/shelf-selected.png -------------------------------------------------------------------------------- /icons/shelf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/shelf.png -------------------------------------------------------------------------------- /icons/spinner-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/spinner-0.png -------------------------------------------------------------------------------- /icons/spinner-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/spinner-1.png -------------------------------------------------------------------------------- /icons/spinner-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/spinner-2.png -------------------------------------------------------------------------------- /icons/update-available.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/update-available.png -------------------------------------------------------------------------------- /icons/update-ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/update-ok.png -------------------------------------------------------------------------------- /icons/url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/url.png -------------------------------------------------------------------------------- /icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-booksearch/9d226c277484c68bdc520ab7fa434652f1fe187a/icons/warning.png -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // +build mage 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/deanishe/awgo/util" 14 | "github.com/deanishe/awgo/util/build" 15 | "github.com/magefile/mage/mg" 16 | "github.com/magefile/mage/sh" 17 | ) 18 | 19 | // Default target to run when none is specified 20 | // If not set, running mage will list available targets 21 | // var Default = Build 22 | 23 | var ( 24 | info *build.Info 25 | env map[string]string 26 | ldflags string 27 | workDir string 28 | buildDir = "./build" 29 | distDir = "./dist" 30 | iconsDir = "./icons" 31 | ) 32 | 33 | func init() { 34 | var err error 35 | if info, err = build.NewInfo(); err != nil { 36 | panic(err) 37 | } 38 | if workDir, err = os.Getwd(); err != nil { 39 | panic(err) 40 | } 41 | env = info.Env() 42 | env["API_KEY"] = os.Getenv("GOODREADS_API_KEY") 43 | env["API_SECRET"] = os.Getenv("GOODREADS_API_SECRET") 44 | env["VERSION"] = info.Version 45 | env["PKG_CLI"] = "go.deanishe.net/alfred-booksearch/pkg/cli" 46 | env["PKG_GR"] = "go.deanishe.net/alfred-booksearch/pkg/gr" 47 | ldflags = `-X "$PKG_GR.version=$VERSION" -X "$PKG_CLI.version=$VERSION" -X "$PKG_CLI.apiKey=$API_KEY" -X "$PKG_CLI.apiSecret=$API_SECRET"` 48 | } 49 | 50 | func mod(args ...string) error { 51 | argv := append([]string{"mod"}, args...) 52 | return sh.RunWith(env, "go", argv...) 53 | } 54 | 55 | // Aliases are mage command aliases. 56 | var Aliases = map[string]interface{}{ 57 | "b": Build, 58 | "c": Clean, 59 | "d": Dist, 60 | "l": Link, 61 | } 62 | 63 | // Build builds workflow in ./build 64 | func Build() error { 65 | mg.Deps(cleanBuild) 66 | fmt.Println("building ...") 67 | 68 | err := sh.RunWith(env, 69 | "go", "build", "-ldflags", ldflags, 70 | "-o", "./build/alfred-booksearch", ".", 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | globs := build.Globs( 77 | "*.png", 78 | "info.plist", 79 | "*.html", 80 | "README.md", 81 | "LICENCE.txt", 82 | "icons/*.png", 83 | "scripts/*", 84 | ) 85 | 86 | if err := build.SymlinkGlobs(buildDir, globs...); err != nil { 87 | return err 88 | } 89 | 90 | scriptIcons := []struct { 91 | script, icon string 92 | }{ 93 | {"Add to Currently Reading", "shelf"}, 94 | {"Add to Shelves", "shelf"}, 95 | {"Add to Want to Read", "shelf"}, 96 | {"Copy Goodreads Link", "link"}, 97 | {"Mark as Read", "shelf"}, 98 | {"Open Author Page", "author"}, 99 | {"Open Book Page", "link"}, 100 | {"View Author’s Books", "author"}, 101 | {"View Series", "series"}, 102 | {"View Series Online", "link"}, 103 | {"View Similar Books", "link"}, 104 | } 105 | 106 | for _, st := range scriptIcons { 107 | target := filepath.Join(buildDir, "icons", st.icon+".png") 108 | link := filepath.Join(buildDir, "scripts", st.script+".png") 109 | if err := build.Symlink(link, target, true); err != nil { 110 | return err 111 | } 112 | } 113 | icons := []struct { 114 | src, dst string 115 | }{ 116 | {"icons/config.png", "6B3CB906-52D2-4266-8E5F-2F3C1155A05C.png"}, 117 | {"icons/shelf.png", "303CAB58-86FE-497C-995C-11F659969015.png"}, 118 | } 119 | 120 | for _, i := range icons { 121 | src, dst := filepath.Join(buildDir, i.src), filepath.Join(buildDir, i.dst) 122 | if err := build.Symlink(dst, src, true); err != nil { 123 | return err 124 | } 125 | } 126 | return nil 127 | } 128 | 129 | // Run run workflow 130 | func Run() error { 131 | mg.Deps(Build) 132 | fmt.Println("running ...") 133 | return sh.RunWith(env, buildDir+"/alfred-booksearch", "-h") 134 | } 135 | 136 | // Dist build an .alfredworkflow file in ./dist 137 | func Dist() error { 138 | mg.SerialDeps(Clean, Build) 139 | fmt.Printf("exporting %q to %q ...\n", buildDir, distDir) 140 | p, err := build.Export(buildDir, distDir) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | fmt.Printf("built workflow file %q\n", p) 146 | return nil 147 | } 148 | 149 | // Config display configuration 150 | func Config() { 151 | fmt.Println(" Workflow name:", info.Name) 152 | fmt.Println(" Bundle ID:", info.BundleID) 153 | fmt.Println(" Workflow version:", info.Version) 154 | fmt.Println(" Preferences file:", info.AlfredPrefsBundle) 155 | fmt.Println(" Sync folder:", info.AlfredSyncDir) 156 | fmt.Println("Workflow directory:", info.AlfredWorkflowDir) 157 | fmt.Println(" Data directory:", info.DataDir) 158 | fmt.Println(" Cache directory:", info.CacheDir) 159 | } 160 | 161 | // Link symlinks ./build directory to Alfred's workflow directory. 162 | func Link() error { 163 | mg.Deps(Build) 164 | 165 | fmt.Println("linking ./build to workflow directory ...") 166 | target := filepath.Join(info.AlfredWorkflowDir, info.BundleID) 167 | // fmt.Printf("target: %s\n", target) 168 | 169 | if util.PathExists(target) { 170 | fmt.Println("removing existing workflow ...") 171 | } 172 | // try to remove it anyway, as dangling symlinks register as existing 173 | if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) { 174 | return err 175 | } 176 | 177 | src, err := filepath.Abs(buildDir) 178 | if err != nil { 179 | return err 180 | } 181 | return build.Symlink(target, src, true) 182 | } 183 | 184 | // Deps ensure dependencies 185 | func Deps() error { 186 | mg.Deps(cleanDeps) 187 | fmt.Println("downloading deps ...") 188 | return mod("download") 189 | } 190 | 191 | // Vendor copy dependencies to ./vendor 192 | func Vendor() error { 193 | mg.Deps(Deps) 194 | fmt.Println("vendoring deps ...") 195 | return mod("vendor") 196 | } 197 | 198 | // Clean remove build files 199 | func Clean() { 200 | fmt.Println("cleaning ...") 201 | mg.Deps(cleanBuild, cleanMage, cleanDeps) 202 | } 203 | 204 | func cleanDeps() error { 205 | return mod("tidy", "-v") 206 | } 207 | 208 | // remove & recreate directory 209 | func cleanDir(name string) error { 210 | if err := sh.Rm(name); err != nil { 211 | return err 212 | } 213 | return os.MkdirAll(name, 0755) 214 | } 215 | 216 | func cleanBuild() error { 217 | return cleanDir(buildDir) 218 | } 219 | 220 | func cleanMage() error { 221 | return sh.Run("mage", "-clean") 222 | } 223 | 224 | // CleanIcons delete all generated icons from ./icons 225 | func CleanIcons() error { 226 | return cleanDir(iconsDir) 227 | } 228 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package main 5 | 6 | import "go.deanishe.net/alfred-booksearch/pkg/cli" 7 | 8 | func main() { cli.Run() } 9 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | 2 | magefile.go 3 | magefile_*.go { 4 | prep: " 5 | # verifying magefile 6 | mage -l 7 | " 8 | } 9 | 10 | modd.conf 11 | **/*.go 12 | !proxy/*.go 13 | !mage_*.go 14 | !vendor/** { 15 | prep: " 16 | # run unit tests 17 | go test -v @dirmods \ 18 | && mage -v run 19 | " 20 | } 21 | 22 | icons/* 23 | scripts/* { 24 | prep: " 25 | # build workflow 26 | mage -v build 27 | " 28 | } -------------------------------------------------------------------------------- /pkg/cli/author.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package cli 6 | 7 | import ( 8 | "log" 9 | "path/filepath" 10 | "time" 11 | 12 | aw "github.com/deanishe/awgo" 13 | "github.com/deanishe/awgo/util" 14 | 15 | "go.deanishe.net/alfred-booksearch/pkg/gr" 16 | ) 17 | 18 | // show books by author 19 | func runAuthor() { 20 | if !authorisedStatus() { 21 | return 22 | } 23 | 24 | wf.Var("last_action", "author") 25 | wf.Var("last_query", opts.Query) 26 | 27 | var ( 28 | books []gr.Book 29 | key = "authors/" + cachefileID(opts.AuthorID) 30 | rerun = wf.IsRunning(booksJob) 31 | ) 32 | 33 | if wf.Cache.Expired(key, opts.MaxCache.Search) { 34 | rerun = true 35 | if err := runJob(booksJob, "-savebooks"); err != nil { 36 | wf.FatalError(err) 37 | } 38 | } 39 | 40 | if wf.Cache.Exists(key) { 41 | checkErr(wf.Cache.LoadJSON(key, &books)) 42 | log.Printf("loaded %d book(s) from cache", len(books)) 43 | } else { 44 | wf.NewItem("Loading Books…"). 45 | Subtitle("Results will appear momentarily"). 46 | Icon(spinnerIcon()) 47 | } 48 | 49 | // Search for books 50 | log.Printf("authorName=%q, authorID=%d, sinceLastRequest=%v", opts.AuthorName, opts.AuthorID, time.Since(opts.LastRequestParsed)) 51 | 52 | var ( 53 | icons = newIconCache(iconCacheDir) 54 | mods = LoadModifiers() 55 | ) 56 | 57 | for _, b := range books { 58 | bookItem(b, icons, mods) 59 | } 60 | 61 | addNavActions() 62 | 63 | if !opts.QueryEmpty() { 64 | wf.Filter(opts.Query) 65 | } 66 | 67 | wf.WarnEmpty("No Matching Books", "Try a different query?") 68 | 69 | if icons.HasQueue() { 70 | var err error 71 | if err = icons.Close(); err == nil { 72 | err = runJob(iconsJob, "-icons") 73 | } 74 | logIfError(err, "cache icons: %v") 75 | } 76 | 77 | if rerun || wf.IsRunning(iconsJob) { 78 | wf.Rerun(rerunInterval) 79 | } 80 | 81 | wf.SendFeedback() 82 | } 83 | 84 | // cache books by a given author 85 | func runCacheAuthorList() { 86 | wf.Configure(aw.TextErrors(true)) 87 | if !opts.Authorised() { 88 | return 89 | } 90 | 91 | var ( 92 | key = "authors/" + cachefileID(opts.AuthorID) 93 | page = 1 94 | pageCount int 95 | books, res []gr.Book 96 | meta gr.PageData 97 | last time.Time 98 | // Whether to write partial result sets or wait until everything 99 | // has been downloaded. 100 | writePartial bool 101 | err error 102 | ) 103 | util.MustExist(filepath.Dir(filepath.Join(wf.CacheDir(), key))) 104 | log.Printf("[authors] caching books by %q (%d) ...", opts.AuthorName, opts.AuthorID) 105 | // log.Printf("[authors] cache: %s", key) 106 | 107 | writePartial = !wf.Cache.Exists(key) 108 | 109 | for { 110 | if pageCount > 0 && page > pageCount { 111 | break 112 | } 113 | 114 | if !last.IsZero() && time.Since(last) < time.Second { 115 | delay := time.Second - time.Since(last) 116 | log.Printf("[authors] pausing %v till next request ...", delay) 117 | time.Sleep(delay) 118 | } 119 | last = time.Now() 120 | 121 | res, meta, err = api.AuthorBooks(opts.AuthorID, page) 122 | checkErr(err) 123 | 124 | if pageCount == 0 { 125 | n := meta.Total 126 | if n > opts.MaxBooks { 127 | n = opts.MaxBooks 128 | } 129 | pageCount = n / 30 130 | if n%30 > 0 { 131 | pageCount++ 132 | } 133 | } 134 | books = append(books, res...) 135 | if writePartial { 136 | checkErr(wf.Cache.StoreJSON(key, books)) 137 | } 138 | log.Printf("[authors] cached page %d/%d, %d book(s) for %q", page, pageCount, len(books), opts.AuthorName) 139 | page++ 140 | } 141 | 142 | checkErr(wf.Cache.StoreJSON(key, books)) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/cli/cache.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package cli 6 | 7 | import ( 8 | "io/ioutil" 9 | "log" 10 | "math/rand" 11 | "os" 12 | "path/filepath" 13 | "sort" 14 | "sync" 15 | "time" 16 | 17 | aw "github.com/deanishe/awgo" 18 | "github.com/deanishe/awgo/util" 19 | "github.com/pkg/errors" 20 | 21 | "go.deanishe.net/alfred-booksearch/pkg/gr" 22 | ) 23 | 24 | const feedsKey = "FeedsLastUpdate.json" 25 | 26 | func init() { 27 | rand.Seed(time.Now().UnixNano()) 28 | } 29 | 30 | func runIcons() { 31 | wf.Configure(aw.TextErrors(true)) 32 | icons := newIconCache(iconCacheDir) 33 | if icons.HasQueue() { 34 | checkErr(icons.ProcessQueue()) 35 | } 36 | } 37 | 38 | // Fetch RSS feeds and cache icons. 39 | func runFeeds() { 40 | wf.Configure(aw.TextErrors(true)) 41 | // fetch RSS feeds 42 | if !wf.Cache.Exists(shelvesKey) { 43 | log.Printf("[feeds] no shelves") 44 | return 45 | } 46 | 47 | if opts.UserID == 0 { 48 | log.Printf("[feeds] user ID not set; not fetching RSS feeds") 49 | return 50 | } 51 | 52 | var ( 53 | icons = newIconCache(iconCacheDir) 54 | shelves []gr.Shelf 55 | err error 56 | ) 57 | 58 | checkErr(wf.Cache.LoadJSON(shelvesKey, &shelves)) 59 | checkErr(wf.Cache.StoreJSON(feedsKey, time.Now())) 60 | 61 | log.Println("[feeds] fetching RSS feeds...") 62 | for _, s := range shelves { 63 | var feed gr.Feed 64 | if feed, err = api.FetchFeed(opts.UserID, s.Name); err == nil { 65 | log.Printf("[feeds] %d book(s) in feed %q", len(feed.Books), s.Name) 66 | icons.Add(feed.Books...) 67 | } 68 | logIfError(err, "fetch feed %q: %v", s.Name) 69 | } 70 | 71 | if icons.HasQueue() { 72 | log.Printf("[feeds] %d icon(s) queued for download", len(icons.Queue)) 73 | if err = icons.Close(); err == nil { 74 | err = runJob(iconsJob, "-icons") 75 | } 76 | checkErr(err) 77 | } 78 | } 79 | 80 | // Check for workflow update + clear stale cache files. 81 | func runHousekeeping() { 82 | wf.Configure(aw.TextErrors(true)) 83 | 84 | // wait a bit for current search to complete before clearing caches 85 | // time.Sleep(15) 86 | 87 | wg := sync.WaitGroup{} 88 | wg.Add(3) 89 | 90 | // check for update 91 | go func() { 92 | defer wg.Done() 93 | log.Println("[housekeeping] checking for updates...") 94 | logIfError(wf.CheckForUpdate(), "[housekeeping] update check: %v") 95 | }() 96 | 97 | // clean covers cache 98 | go func() { 99 | defer wg.Done() 100 | log.Println("[housekeeping] cleaning cover cache...") 101 | dc := &dirCleaner{ 102 | root: iconCacheDir, 103 | maxAge: func() time.Duration { 104 | // fuzzy age of max cache age +/- 72 hours 105 | delta := time.Hour * time.Duration(rand.Int31n(72)) 106 | return opts.MaxCache.Icons - (time.Hour * 72) + delta 107 | }, 108 | } 109 | logIfError(dc.Clean(), "[housekeeping] clean icon cache: %v") 110 | }() 111 | 112 | // clean other caches 113 | go func() { 114 | defer wg.Done() 115 | dirs := []string{authorsCacheDir, booksCacheDir, searchCacheDir} 116 | ages := []time.Duration{opts.MaxCache.Default, opts.MaxCache.Default, opts.MaxCache.Search} 117 | for i, dir := range dirs { 118 | i := i 119 | log.Printf("[housekeeping] cleaning %s cache...", filepath.Base(dir)) 120 | dc := &dirCleaner{ 121 | root: dir, 122 | maxAge: func() time.Duration { return ages[i] }, 123 | } 124 | logIfError(dc.Clean(), "[housekeeping] clean query cache: %v") 125 | } 126 | }() 127 | 128 | wg.Wait() 129 | } 130 | 131 | type cacheDir struct { 132 | info os.FileInfo 133 | path string 134 | } 135 | 136 | // cacheDirs sorts directories by name. 137 | type cacheDirs []cacheDir 138 | 139 | // Implement sort.Interface 140 | func (s cacheDirs) Len() int { return len(s) } 141 | func (s cacheDirs) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 142 | func (s cacheDirs) Less(i, j int) bool { return s[i].path < s[j].path } 143 | 144 | // removes old files and empty directories from a cache directory. 145 | type dirCleaner struct { 146 | root string 147 | maxAge func() time.Duration 148 | dirs []cacheDir 149 | } 150 | 151 | func (dc *dirCleaner) addDir(fi os.FileInfo, path string) { 152 | dc.dirs = append(dc.dirs, cacheDir{fi, path}) 153 | } 154 | 155 | func (dc *dirCleaner) Clean() error { 156 | if err := dc.cleanFiles(); err != nil { 157 | return err 158 | } 159 | return dc.cleanDirs() 160 | } 161 | 162 | func (dc *dirCleaner) cleanDirs() error { 163 | sort.Sort(sort.Reverse(cacheDirs(dc.dirs))) 164 | for _, dir := range dc.dirs { 165 | if dir.path == dc.root { 166 | continue 167 | } 168 | 169 | if time.Since(dir.info.ModTime()) < time.Hour*72 { 170 | continue 171 | } 172 | 173 | infos, err := ioutil.ReadDir(dir.path) 174 | if err != nil { 175 | return err 176 | } 177 | if len(infos) == 0 { 178 | log.Printf("[housekeeping] deleting empty directory %q ...", util.PrettyPath(dir.path)) 179 | if err := os.Remove(dir.path); err != nil { 180 | return errors.Wrap(err, util.PrettyPath(dir.path)) 181 | } 182 | } 183 | } 184 | return nil 185 | } 186 | 187 | func (dc *dirCleaner) cleanFiles() error { 188 | clean := func(p string, fi os.FileInfo, err error) error { 189 | if err != nil { 190 | return err 191 | } 192 | if fi.IsDir() { 193 | dc.addDir(fi, p) 194 | return nil 195 | } 196 | // delete cached queries (.json) and covers (.png). 197 | x := filepath.Ext(fi.Name()) 198 | if x != ".json" && x != ".png" { 199 | return nil 200 | } 201 | 202 | age := time.Since(fi.ModTime()) 203 | if age > dc.maxAge() { 204 | log.Printf("[housekeeping] deleting %q (%v) ...", util.PrettyPath(p), age) 205 | if err := os.Remove(p); err != nil { 206 | return errors.Wrap(err, util.PrettyPath(p)) 207 | } 208 | } 209 | return nil 210 | } 211 | 212 | return filepath.Walk(dc.root, clean) 213 | } 214 | -------------------------------------------------------------------------------- /pkg/cli/cli.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // Package cli implements the Book Search workflow for Alfred. 5 | package cli 6 | 7 | import ( 8 | "crypto/sha256" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | 18 | aw "github.com/deanishe/awgo" 19 | "github.com/deanishe/awgo/keychain" 20 | "github.com/deanishe/awgo/update" 21 | "github.com/deanishe/awgo/util" 22 | "github.com/pkg/errors" 23 | 24 | "go.deanishe.net/alfred-booksearch/pkg/gr" 25 | ) 26 | 27 | // Configuration values set via LD_FLAGS. 28 | var ( 29 | // Goodreads API key & secret. 30 | apiKey = "" 31 | apiSecret = "" 32 | 33 | // Workflow version. 34 | version = "" 35 | 36 | navActions []navAction 37 | ) 38 | 39 | const ( 40 | // workflow links 41 | repo = "deanishe/alfred-booksearch" 42 | helpURL = "https://github.com/deanishe/alfred-booksearch/tree/master/doc" 43 | issueTrackerURL = "https://github.com/deanishe/alfred-booksearch/issues" 44 | 45 | // background job names 46 | booksJob = "booklist" 47 | cacheJob = "housekeeping" 48 | iconsJob = "icons" 49 | shelfJob = "shelf" 50 | shelvesJob = "shelves" 51 | feedsJob = "feeds" 52 | userJob = "user" 53 | seriesJob = "series" 54 | bookJob = "book" 55 | 56 | tokensKey = "oauth_tokens" 57 | 58 | // how often to re-run workflow if a background job is running 59 | rerunInterval = 0.2 60 | ) 61 | 62 | var ( 63 | // cache directories 64 | authorsCacheDir string 65 | booksCacheDir string 66 | iconCacheDir string 67 | searchCacheDir string 68 | seriesCacheDir string 69 | shelvesCacheDir string 70 | 71 | scriptsDir string 72 | userScriptsDir string 73 | 74 | api *gr.Client 75 | store *keychainStore 76 | wf *aw.Workflow 77 | ) 78 | 79 | func init() { 80 | aw.IconError = iconError 81 | aw.IconWarning = iconWarning 82 | wf = aw.New( 83 | aw.HelpURL(issueTrackerURL), 84 | update.GitHub(repo), 85 | ) 86 | authorsCacheDir = filepath.Join(wf.CacheDir(), "authors") 87 | booksCacheDir = filepath.Join(wf.CacheDir(), "books") 88 | iconCacheDir = filepath.Join(wf.CacheDir(), "covers") 89 | searchCacheDir = filepath.Join(wf.CacheDir(), "queries") 90 | shelvesCacheDir = filepath.Join(wf.CacheDir(), "shelves") 91 | seriesCacheDir = filepath.Join(wf.CacheDir(), "series") 92 | 93 | scriptsDir = "scripts" 94 | userScriptsDir = filepath.Join(wf.DataDir(), "scripts") 95 | 96 | navActions = []navAction{ 97 | {"Search", "Search for books", "search", iconBook}, 98 | {"Shelves", "List bookshelves", "shelves", iconShelf}, 99 | {"Configuration", "Workflow configuration", "config", iconConfig}, 100 | } 101 | } 102 | 103 | // Logger for goodreads library. 104 | type logger struct{} 105 | 106 | func (l logger) Printf(format string, args ...interface{}) { 107 | log.Output(3, fmt.Sprintf(format, args...)) 108 | } 109 | func (l logger) Print(args ...interface{}) { 110 | log.Output(3, fmt.Sprint(args...)) 111 | } 112 | 113 | var _ gr.Logger = logger{} 114 | 115 | type navAction struct { 116 | title string 117 | subtitle string 118 | action string 119 | icon *aw.Icon 120 | } 121 | 122 | // keychainStore implements gr.TokenStore. 123 | type keychainStore struct { 124 | name string 125 | token, secret string 126 | } 127 | 128 | // Save saves token & secret to Keychain. 129 | func (s *keychainStore) Save(token, secret string) error { 130 | kc := keychain.New(s.name) 131 | if err := kc.Set(tokensKey, token+" "+secret); err != nil { 132 | return errors.Wrap(err, "save token to Keychain") 133 | } 134 | s.token, s.secret = token, secret 135 | return nil 136 | } 137 | 138 | // Load returns OAuth token and secret. 139 | func (s *keychainStore) Load() (token, secret string, err error) { 140 | return s.token, s.secret, nil 141 | } 142 | 143 | var _ gr.TokenStore = (*keychainStore)(nil) 144 | 145 | func runHelp() error { 146 | wf.Configure(aw.TextErrors(true)) 147 | fs.Usage() 148 | return nil 149 | } 150 | 151 | // Run executes the workflow 152 | func Run() { 153 | wf.Run(run) 154 | } 155 | 156 | func run() { 157 | checkErr(opts.Prepare(wf.Args())) 158 | 159 | if opts.FlagNoop { 160 | return 161 | } 162 | 163 | if opts.FlagHelp { 164 | runHelp() 165 | return 166 | } 167 | 168 | if opts.FlagOpen { 169 | _, err := util.RunCmd(exec.Command("/usr/bin/open", opts.Query)) 170 | notifyIfError(err, "open failed", true) 171 | return 172 | } 173 | 174 | checkErr(bootstrap()) 175 | 176 | if opts.FlagAuthor { 177 | runAuthor() 178 | return 179 | } 180 | 181 | if opts.FlagCacheAuthor { 182 | runCacheAuthorList() 183 | return 184 | } 185 | 186 | if opts.FlagAuthorise { 187 | runAuthorise() 188 | return 189 | } 190 | 191 | if opts.FlagDeauthorise { 192 | runDeauthorise() 193 | return 194 | } 195 | 196 | if opts.FlagUserInfo { 197 | runUserInfo() 198 | return 199 | } 200 | 201 | if opts.FlagHousekeeping { 202 | runHousekeeping() 203 | return 204 | } 205 | 206 | if opts.FlagFeeds { 207 | runFeeds() 208 | return 209 | } 210 | 211 | if opts.FlagConf { 212 | runConfig() 213 | return 214 | } 215 | 216 | if opts.FlagIcons { 217 | runIcons() 218 | return 219 | } 220 | 221 | if opts.FlagShelves { 222 | runShelves() 223 | return 224 | } 225 | 226 | if opts.FlagShelf { 227 | runShelf() 228 | return 229 | } 230 | 231 | if opts.FlagSelectShelves { 232 | runSelectShelves() 233 | return 234 | } 235 | 236 | if opts.FlagCacheShelf { 237 | runCacheShelf() 238 | return 239 | } 240 | 241 | if opts.FlagCacheShelves { 242 | runCacheShelves() 243 | return 244 | } 245 | 246 | if opts.FlagReloadShelf { 247 | runReloadShelf() 248 | return 249 | } 250 | 251 | if opts.FlagReloadShelves { 252 | runReloadShelves() 253 | return 254 | } 255 | 256 | if opts.FlagAddToShelves { 257 | runAddToShelves() 258 | return 259 | } 260 | 261 | if opts.FlagRemoveFromShelf { 262 | runRemoveFromShelf() 263 | return 264 | } 265 | 266 | if opts.FlagSeries { 267 | runSeries() 268 | return 269 | } 270 | 271 | if opts.FlagCacheSeries { 272 | runCacheSeries() 273 | return 274 | } 275 | 276 | if opts.FlagCacheBook { 277 | runCacheBook() 278 | return 279 | } 280 | 281 | if opts.FlagScript { 282 | runScript() 283 | return 284 | } 285 | 286 | if opts.FlagScripts { 287 | runScripts() 288 | return 289 | } 290 | 291 | if opts.FlagExport { 292 | runExport(opts.FlagJSON) 293 | return 294 | } 295 | 296 | if opts.FlagBeep { 297 | runBeep() 298 | return 299 | } 300 | 301 | if opts.FlagSearch { 302 | runSearch() 303 | return 304 | } 305 | 306 | var runVars bool 307 | fs.Visit(func(f *flag.Flag) { 308 | if f.Name == "action" || f.Name == "hide" || f.Name == "passvars" || f.Name == "notify" || f.Name == "query" { 309 | runVars = true 310 | } 311 | }) 312 | if runVars { 313 | runVariables() 314 | return 315 | } 316 | } 317 | 318 | // Create cache directories & fetch essential data. 319 | func bootstrap() error { 320 | util.MustExist(authorsCacheDir) 321 | util.MustExist(booksCacheDir) 322 | util.MustExist(iconCacheDir) 323 | util.MustExist(searchCacheDir) 324 | util.MustExist(seriesCacheDir) 325 | util.MustExist(shelvesCacheDir) 326 | util.MustExist(userScriptsDir) 327 | 328 | store = &keychainStore{ 329 | name: wf.BundleID(), 330 | token: opts.AccessToken, 331 | secret: opts.AccessSecret, 332 | } 333 | 334 | var err error 335 | if api, err = gr.New(apiKey, apiSecret, store); err != nil { 336 | return errors.Wrap(err, "create API client") 337 | } 338 | api.Log = logger{} 339 | 340 | if !opts.Authorised() { 341 | return nil 342 | } 343 | 344 | // fetch user ID & name if not already set 345 | if opts.UserID == 0 { 346 | if err := runJob(userJob, "-userinfo"); err != nil { 347 | return err 348 | } 349 | } else if wf.Cache.Exists(shelvesKey) { 350 | if !wf.IsRunning(feedsJob) { 351 | var t time.Time 352 | if wf.Cache.Exists(feedsKey) { 353 | if err := wf.Cache.LoadJSON(feedsKey, &t); err != nil { 354 | return err 355 | } 356 | } 357 | 358 | if time.Since(t) > opts.MaxCache.Feeds { 359 | if err := runJob(feedsJob, "-feeds"); err != nil { 360 | return err 361 | } 362 | } 363 | } 364 | } else if err := runJob(shelvesJob, "-saveshelves"); err != nil { 365 | return err 366 | } 367 | 368 | return nil 369 | } 370 | 371 | // Show "update available" message and check for update if due. 372 | func updateStatus() { 373 | if wf.UpdateAvailable() && opts.Query == "" { 374 | wf.NewItem("Update Available!"). 375 | Subtitle("↩ or ⇥ to install update"). 376 | Valid(false). 377 | Autocomplete("workflow:update"). 378 | Icon(iconUpdateAvailable) 379 | } 380 | 381 | if wf.UpdateCheckDue() && !wf.IsRunning(cacheJob) { 382 | logIfError(runJob(cacheJob, "-housekeeping"), "check for update: %v") 383 | } 384 | } 385 | 386 | // Show "authorise workflow" action if workflow has no OAuth token. 387 | func authorisedStatus() bool { 388 | if !opts.Authorised() { 389 | wf.NewItem("Authorise Workflow"). 390 | Subtitle("Action this item to authorise workflow to access your Goodreads account"). 391 | Arg("-authorise"). 392 | Valid(true). 393 | Icon(iconLocked). 394 | Var("hide_alfred", "true") 395 | 396 | wf.SendFeedback() 397 | return false 398 | } 399 | _, err := api.AuthedClient() 400 | checkErr(err) 401 | return true 402 | } 403 | 404 | func addNavActions(ignore ...string) { 405 | if len(opts.Query) < 3 { 406 | return 407 | } 408 | 409 | ig := make(map[string]bool, len(ignore)) 410 | for _, s := range ignore { 411 | ig[s] = true 412 | } 413 | 414 | for _, a := range navActions { 415 | if ig[a.action] || !strings.HasPrefix(a.action, strings.ToLower(opts.Query)) { 416 | continue 417 | } 418 | wf.NewItem(a.title). 419 | Subtitle(a.subtitle). 420 | Arg("-noop"). 421 | UID(a.action). 422 | Valid(true). 423 | Icon(a.icon). 424 | Var("action", a.action). 425 | Var("last_action", ""). 426 | Var("last_query", ""). 427 | Var("query", ""). 428 | Var("hide_alfred", "") 429 | } 430 | } 431 | 432 | func hash(s string) string { 433 | return fmt.Sprintf("%x", sha256.Sum256([]byte(s))) 434 | } 435 | 436 | func notify(title, msg string, action ...string) error { 437 | v := aw.NewArgVars(). 438 | Var("notification_title", title). 439 | Var("notification_text", msg). 440 | Var("hide_alfred", "true") 441 | 442 | if len(action) > 0 { 443 | v.Var("action", action[0]) 444 | } 445 | 446 | return v.Send() 447 | } 448 | 449 | func notifyError(title string, err error, command ...string) error { 450 | return notify("💀 "+title+" 💀", err.Error(), command...) 451 | } 452 | 453 | func notifyIfError(err error, title string, fatal bool) { 454 | if err == nil { 455 | return 456 | } 457 | _ = notify("💀 "+title+" 💀", err.Error()) 458 | log.Fatalf("[ERROR] %s: %v", title, err) 459 | } 460 | 461 | func logIfError(err error, format string, args ...interface{}) { 462 | if err == nil { 463 | return 464 | } 465 | args = append(args, err) 466 | log.Printf("[ERROR] "+format, args...) 467 | } 468 | 469 | func checkErr(err error) { 470 | if err == nil { 471 | return 472 | } 473 | panic(err) 474 | } 475 | 476 | // start a named background job, passing the given arguments to this executable. 477 | func runJob(name string, args ...string) error { 478 | if wf.IsRunning(name) { 479 | return nil 480 | } 481 | return wf.RunInBackground(name, exec.Command(os.Args[0], args...)) 482 | } 483 | -------------------------------------------------------------------------------- /pkg/cli/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-14 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | 11 | aw "github.com/deanishe/awgo" 12 | "github.com/deanishe/awgo/keychain" 13 | "go.deanishe.net/alfred-booksearch/pkg/gr" 14 | ) 15 | 16 | // initiate OAuth workflow 17 | func runAuthorise() { 18 | wf.Configure(aw.TextErrors(true)) 19 | // delete existing token (if any) 20 | logIfError(keychain.New(wf.BundleID()).Delete("oauth_tokens"), "delete existing tokens") 21 | notifyIfError(api.Authorise(), "Authentication Failed", true) 22 | notifyIfError(getUserInfo(), "Authentication Failed", true) 23 | checkErr(notify("OAuth Authentication", "Workflow authorised", "search")) 24 | } 25 | 26 | // delete OAuth credentials 27 | func runDeauthorise() { 28 | wf.Configure(aw.TextErrors(true)) 29 | logIfError(keychain.New(wf.BundleID()).Delete(tokensKey), "delete existing tokens") 30 | err := wf.Config.Set("USER_ID", "", false). 31 | Set("USER_NAME", "", false).Do() 32 | notifyIfError(err, "Deauthorisation Failed", true) 33 | notify("Workflow Deauthorised", "", "config") 34 | } 35 | 36 | // Show workflow configuration. 37 | func runConfig() { 38 | wf.Var("last_action", "config") 39 | wf.Var("last_query", opts.Query) 40 | 41 | title := "Workflow Is Up To Date" 42 | subtitle := "↩ or ⇥ to check for update" 43 | icon := iconUpdateOK 44 | if wf.UpdateAvailable() { 45 | title = "Workflow Update Available" 46 | subtitle = "↩ or ⇥ to install" 47 | icon = iconUpdateAvailable 48 | } 49 | 50 | wf.NewItem(title). 51 | Subtitle(subtitle). 52 | Valid(false). 53 | Autocomplete("workflow:update"). 54 | Icon(icon) 55 | 56 | title = "Workflow Authorised" 57 | subtitle = "Workflow has an OAuth token" 58 | icon = iconOK 59 | var arg string 60 | valid := false 61 | action := "config" 62 | hide := "" 63 | 64 | if !opts.Authorised() { 65 | title = "Workflow Not Authorised" 66 | subtitle = "↩ to authorise workflow via OAuth" 67 | icon = iconLocked 68 | arg = "-authorise" 69 | action = "" 70 | valid = true 71 | hide = "true" 72 | } 73 | 74 | it := wf.NewItem(title). 75 | Subtitle(subtitle). 76 | Icon(icon). 77 | Arg(arg). 78 | Valid(valid). 79 | Var("action", action). 80 | Var("hide_alfred", hide) 81 | 82 | if opts.Authorised() { 83 | it.NewModifier(aw.ModCmd). 84 | Subtitle("Delete OAuth token"). 85 | Icon(iconDelete). 86 | Arg("-deauthorise"). 87 | Var("action", "config"). 88 | Var("hide_alfred", "") 89 | } 90 | 91 | wf.NewItem("Open Scripts Folder"). 92 | Subtitle("Open custom scripts folder"). 93 | Arg("-open", userScriptsDir). 94 | Copytext(userScriptsDir). 95 | Valid(true). 96 | Icon(iconScript) 97 | 98 | wf.NewItem("Open Docs"). 99 | Subtitle("Open workflow documentation in your browser"). 100 | Arg("-open", helpURL). 101 | Copytext(helpURL). 102 | Valid(true). 103 | Icon(iconDocs) 104 | 105 | wf.NewItem("Get Help"). 106 | Subtitle("Open workflow issue tracker in your browser"). 107 | Arg("-open", issueTrackerURL). 108 | Copytext(issueTrackerURL). 109 | Valid(true). 110 | Icon(iconHelp) 111 | 112 | wf.NewItem("Report Bug"). 113 | Subtitle("Open workflow issue tracker in your browser"). 114 | Arg("-open", issueTrackerURL). 115 | Copytext(issueTrackerURL). 116 | Valid(true). 117 | Icon(iconIssue) 118 | 119 | addNavActions("config") 120 | 121 | if !opts.QueryEmpty() { 122 | _ = wf.Filter(opts.Query) 123 | } 124 | wf.WarnEmpty("No Matches", "Try a different query?") 125 | 126 | wf.SendFeedback() 127 | } 128 | 129 | // fetch user info from API. 130 | func runUserInfo() { 131 | wf.Configure(aw.TextErrors(true)) 132 | if !opts.Authorised() { 133 | return 134 | } 135 | 136 | checkErr(getUserInfo()) 137 | } 138 | 139 | func getUserInfo() error { 140 | var ( 141 | user gr.User 142 | err error 143 | ) 144 | if user, err = api.UserInfo(); err != nil { 145 | return err 146 | } 147 | 148 | log.Printf("[user] id=%d, name=%s", user.ID, user.Name) 149 | 150 | return wf.Config. 151 | Set("USER_ID", fmt.Sprintf("%d", user.ID), false). 152 | Set("USER_NAME", user.Name, false). 153 | Do() 154 | } 155 | -------------------------------------------------------------------------------- /pkg/cli/icons.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "bytes" 8 | "encoding/csv" 9 | "fmt" 10 | "image" 11 | "image/png" 12 | "log" 13 | "net" 14 | "net/http" 15 | "os" 16 | "path/filepath" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "time" 21 | 22 | aw "github.com/deanishe/awgo" 23 | "github.com/deanishe/awgo/util" 24 | "github.com/disintegration/imaging" 25 | "github.com/natefinch/atomic" 26 | "github.com/pkg/errors" 27 | "go.deanishe.net/alfred-booksearch/pkg/gr" 28 | ) 29 | 30 | // Workflow icons 31 | var ( 32 | iconBook = &aw.Icon{Value: "icons/book.png"} 33 | iconConfig = &aw.Icon{Value: "icons/config.png"} 34 | iconDelete = &aw.Icon{Value: "icons/delete.png"} 35 | iconDocs = &aw.Icon{Value: "icons/docs.png"} 36 | iconError = &aw.Icon{Value: "icons/error.png"} 37 | iconHelp = &aw.Icon{Value: "icons/help.png"} 38 | iconIssue = &aw.Icon{Value: "icons/issue.png"} 39 | iconLocked = &aw.Icon{Value: "icons/locked.png"} 40 | iconMore = &aw.Icon{Value: "icons/more.png"} 41 | iconOK = &aw.Icon{Value: "icons/ok.png"} 42 | iconReload = &aw.Icon{Value: "icons/reload.png"} 43 | iconSave = &aw.Icon{Value: "icons/save.png"} 44 | iconScript = &aw.Icon{Value: "icons/script.png"} 45 | iconShelf = &aw.Icon{Value: "icons/shelf.png"} 46 | iconShelfSelected = &aw.Icon{Value: "icons/shelf-selected.png"} 47 | iconUpdateAvailable = &aw.Icon{Value: "icons/update-available.png"} 48 | iconUpdateOK = &aw.Icon{Value: "icons/update-ok.png"} 49 | iconWarning = &aw.Icon{Value: "icons/warning.png"} 50 | // iconAuthor = &aw.Icon{Value: "icons/author.png"} 51 | // iconLink = &aw.Icon{Value: "icons/link.png"} 52 | // iconURL = &aw.Icon{Value: "icons/url.png"} 53 | // iconDefault = &aw.Icon{Value: "icon.png"} 54 | 55 | spinnerIcons = []*aw.Icon{ 56 | {Value: "icons/spinner-0.png"}, 57 | {Value: "icons/spinner-1.png"}, 58 | {Value: "icons/spinner-2.png"}, 59 | // {Value: "icons/spinner-3.png"}, 60 | } 61 | ) 62 | 63 | var ( 64 | userAgent string 65 | httpClient = &http.Client{ 66 | Transport: &http.Transport{ 67 | Dial: (&net.Dialer{ 68 | Timeout: 60 * time.Second, 69 | KeepAlive: 60 * time.Second, 70 | }).Dial, 71 | TLSHandshakeTimeout: 30 * time.Second, 72 | ResponseHeaderTimeout: 30 * time.Second, 73 | ExpectContinueTimeout: 10 * time.Second, 74 | }, 75 | } 76 | ) 77 | 78 | func init() { 79 | userAgent = "Alfred Booksearch Workflow " + version + " (+https://github.com/deanishe/alfred-booksearch)" 80 | } 81 | 82 | type cacheIcon struct { 83 | ID int64 84 | URL string 85 | Path string 86 | } 87 | 88 | type iconCache struct { 89 | Dir string 90 | Queue []cacheIcon 91 | queueFile string 92 | seen map[int64]bool 93 | } 94 | 95 | func newIconCache(dir string) *iconCache { 96 | util.MustExist(dir) 97 | icons := &iconCache{ 98 | Dir: dir, 99 | Queue: []cacheIcon{}, 100 | queueFile: filepath.Join(dir, "queue.txt"), 101 | seen: map[int64]bool{}, 102 | } 103 | if err := icons.loadQueue(); err != nil { 104 | panic(err) 105 | } 106 | 107 | return icons 108 | } 109 | 110 | // Add URLs to queue. 111 | func (c *iconCache) Add(books ...gr.Book) { 112 | for _, b := range books { 113 | // ignore PNGs, as they're placeholders (real covers are JPG) 114 | if filepath.Ext(b.ImageURL) == ".png" { 115 | continue 116 | } 117 | if !c.seen[b.ID] { 118 | if !c.Exists(b) { 119 | // log.Printf("[icons] queuing for cover retrieval: %s", b) 120 | c.Queue = append(c.Queue, cacheIcon{ 121 | ID: b.ID, 122 | URL: b.ImageURL, 123 | }) 124 | } 125 | c.seen[b.ID] = true 126 | } 127 | } 128 | } 129 | 130 | // BookIcon returns icon for a Book. 131 | func (c *iconCache) BookIcon(b gr.Book) *aw.Icon { 132 | p := c.cachefile(b.ID) 133 | if util.PathExists(p) { 134 | return &aw.Icon{Value: p} 135 | } 136 | // Assume that any PNG is a placeholder (actual covers are JPEGs) 137 | if filepath.Ext(b.ImageURL) == ".png" { 138 | return iconBook 139 | } 140 | // Queue icon for caching 141 | c.Add(b) 142 | return iconBook 143 | } 144 | 145 | // Exists returns true if book's icon is already cached. 146 | func (c *iconCache) Exists(b gr.Book) bool { 147 | return util.PathExists(c.cachefile(b.ID)) 148 | } 149 | 150 | // cachefile returns path of cache file for URL/name s. 151 | func (c *iconCache) cachefile(id int64) string { 152 | return filepath.Join(c.Dir, cachefileID(id, "png")) 153 | } 154 | 155 | // HasQueue returns true if there are Queued files. 156 | func (c *iconCache) HasQueue() bool { return len(c.Queue) > 0 } 157 | 158 | func (c *iconCache) loadQueue() error { 159 | var ( 160 | seen = map[int64]bool{} 161 | f *os.File 162 | r *csv.Reader 163 | records [][]string 164 | err error 165 | ) 166 | if f, err = os.Open(c.queueFile); err != nil { 167 | if !os.IsNotExist(err) { 168 | return errors.Wrap(err, "read icon queue") 169 | } 170 | return nil 171 | } 172 | defer f.Close() 173 | 174 | c.Queue = []cacheIcon{} 175 | r = csv.NewReader(f) 176 | r.Comma = '\t' 177 | r.FieldsPerRecord = 2 178 | if records, err = r.ReadAll(); err != nil { 179 | return errors.Wrap(err, "load queue") 180 | } 181 | for _, row := range records { 182 | id, _ := strconv.ParseInt(row[0], 10, 64) 183 | if seen[id] { 184 | continue 185 | } 186 | c.Queue = append(c.Queue, cacheIcon{ID: id, URL: row[1]}) 187 | seen[id] = true 188 | } 189 | 190 | if err = f.Close(); err != nil { 191 | return errors.Wrap(err, "close queue") 192 | } 193 | // clear queue 194 | if err = atomic.WriteFile(c.queueFile, &bytes.Buffer{}); err != nil { 195 | return errors.Wrap(err, "clear queue") 196 | } 197 | 198 | return nil 199 | } 200 | 201 | // Close atomically writes queue to disk. 202 | func (c *iconCache) Close() error { 203 | var ( 204 | buf = &bytes.Buffer{} 205 | w = csv.NewWriter(buf) 206 | ) 207 | w.Comma = '\t' 208 | 209 | for _, icon := range c.Queue { 210 | if err := w.Write([]string{fmt.Sprintf("%d", icon.ID), icon.URL}); err != nil { 211 | return errors.Wrapf(err, "write icon %#v", icon) 212 | } 213 | } 214 | 215 | w.Flush() 216 | if err := w.Error(); err != nil { 217 | return errors.Wrap(err, "write TSV") 218 | } 219 | 220 | if err := atomic.WriteFile(c.queueFile, buf); err != nil { 221 | return errors.Wrap(err, "write queue file") 222 | } 223 | 224 | log.Printf("[icons] %d icon(s) queued for download", len(c.Queue)) 225 | c.Queue = []cacheIcon{} 226 | return nil 227 | } 228 | 229 | // ProcessQueue downloads pending icons. 230 | func (c *iconCache) ProcessQueue() error { 231 | if !c.HasQueue() { 232 | return nil 233 | } 234 | 235 | type status struct { 236 | icon cacheIcon 237 | err error 238 | } 239 | 240 | var ( 241 | wg sync.WaitGroup 242 | pool = make(chan struct{}, 5) // Allow 5 parallel downloads 243 | ch = make(chan status) 244 | ) 245 | wg.Add(len(c.Queue)) 246 | 247 | for _, icon := range c.Queue { 248 | icon.Path = c.cachefile(icon.ID) 249 | go func(icon cacheIcon) { 250 | defer wg.Done() 251 | 252 | pool <- struct{}{} 253 | defer func() { <-pool }() 254 | 255 | var ( 256 | img image.Image 257 | buf = &bytes.Buffer{} 258 | err error 259 | ) 260 | 261 | if util.PathExists(icon.Path) { 262 | return 263 | } 264 | 265 | if img, err = remoteImage(icon.URL); err != nil { 266 | ch <- status{err: errors.Wrapf(err, "download %q", icon.URL)} 267 | return 268 | } 269 | img = squareImage(img) 270 | if err = os.MkdirAll(filepath.Dir(icon.Path), 0700); err != nil { 271 | ch <- status{err: errors.Wrapf(err, "cache directory %q", filepath.Dir(icon.Path))} 272 | return 273 | } 274 | 275 | if err = png.Encode(buf, img); err != nil { 276 | ch <- status{err: errors.Wrapf(err, "convert image %q", icon.URL)} 277 | return 278 | } 279 | 280 | if err = atomic.WriteFile(icon.Path, buf); err != nil { 281 | ch <- status{err: errors.Wrapf(err, "save image %q", icon.URL)} 282 | return 283 | } 284 | 285 | ch <- status{icon: icon} 286 | }(icon) 287 | } 288 | 289 | go func() { 290 | wg.Wait() 291 | close(pool) 292 | close(ch) 293 | }() 294 | 295 | var ( 296 | n int 297 | err error 298 | ) 299 | for st := range ch { 300 | n++ 301 | if st.err != nil { 302 | logIfError(st.err, "cache icon: %v") 303 | err = st.err 304 | } else { 305 | log.Printf("[icons] [%3d/%d] cached %q to %q", n, len(c.Queue), st.icon.URL, st.icon.Path) 306 | } 307 | } 308 | c.Queue = []cacheIcon{} 309 | return err 310 | } 311 | 312 | func cachefile(key string, ext ...string) string { 313 | ext = append([]string{filepath.Ext(key)}, ext...) 314 | s := hash(key) 315 | path := s[0:2] + "/" + s[2:4] + "/" + s 316 | return path + strings.Join(ext, "") 317 | } 318 | 319 | func cachefileID(id int64, ext ...string) string { 320 | x := "json" 321 | if len(ext) > 0 { 322 | x = ext[0] 323 | } 324 | s := fmt.Sprintf("%d", id) 325 | for len(s) < 4 { 326 | s = "0" + s 327 | } 328 | return fmt.Sprintf("%s/%s/%d.%s", s[0:2], s[2:4], id, x) 329 | } 330 | 331 | func remoteImage(URL string) (image.Image, error) { 332 | var ( 333 | img image.Image 334 | req *http.Request 335 | r *http.Response 336 | err error 337 | ) 338 | 339 | if req, err = http.NewRequest("GET", URL, nil); err != nil { 340 | return nil, errors.Wrap(err, "build HTTP request") 341 | } 342 | req.Header.Set("User-Agent", userAgent) 343 | 344 | if r, err = httpClient.Do(req); err != nil { 345 | return nil, errors.Wrap(err, "retrieve URL") 346 | } 347 | defer r.Body.Close() 348 | log.Printf("[%d] %s", r.StatusCode, URL) 349 | 350 | if r.StatusCode > 299 { 351 | return nil, errors.Wrap(fmt.Errorf("%s: %s", URL, r.Status), "retrieve URL") 352 | } 353 | 354 | if img, _, err = image.Decode(r.Body); err != nil { 355 | return nil, errors.Wrap(err, "decode image") 356 | } 357 | return img, nil 358 | } 359 | 360 | func squareImage(img image.Image) image.Image { 361 | max := img.Bounds().Max 362 | n := max.X 363 | if max.Y > n { 364 | n = max.Y 365 | } 366 | bg := image.NewRGBA(image.Rect(0, 0, n, n)) 367 | return imaging.OverlayCenter(bg, img, 1.0) 368 | } 369 | 370 | // spinnerIcon returns a spinner icon. It rotates by 15 deg on every 371 | // subsequent call. Use with wf.Reload(0.1) to implement an animated 372 | // spinner. 373 | func spinnerIcon() *aw.Icon { 374 | n := wf.Config.GetInt("RELOAD_PROGRESS", 0) 375 | wf.Var("RELOAD_PROGRESS", fmt.Sprintf("%d", n+1)) 376 | return spinnerIcons[n%3] 377 | } 378 | -------------------------------------------------------------------------------- /pkg/cli/modifiers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "strings" 11 | 12 | aw "github.com/deanishe/awgo" 13 | ) 14 | 15 | var validMods = map[string]struct{}{ 16 | "cmd": {}, 17 | "alt": {}, 18 | "opt": {}, 19 | "ctrl": {}, 20 | "shift": {}, 21 | "fn": {}, 22 | } 23 | 24 | // Modifier is a user-specified template. 25 | type Modifier struct { 26 | Keys []aw.ModKey 27 | Script Script 28 | } 29 | 30 | // String implements Stringer. 31 | func (m Modifier) String() string { 32 | // keys := make([]string, len(m.Keys)) 33 | // for i, k := range m.Keys { 34 | // keys[i] = string(k) 35 | // } 36 | return fmt.Sprintf("Modifier{Keys: %v, Script: %q}", m.Keys, m.Script.Path) 37 | } 38 | 39 | /* 40 | func lookup(data map[string]string) func(string) string { 41 | return func(key string) string { return data[key] } 42 | } 43 | 44 | // For applies Book to template. 45 | func (m Modifier) For(b gr.Book) ([]aw.ModKey, string) { 46 | data := map[string]string{} 47 | for k, v := range b.Data() { 48 | data[k] = url.QueryEscape(v) 49 | data[k+"Alt"] = url.PathEscape(v) 50 | data[k+"Raw"] = v 51 | } 52 | return m.Keys, os.Expand(m.Value, lookup(data)) 53 | } 54 | 55 | 56 | // String formats Modifier for printing. 57 | func (m Modifier) String() string { 58 | return fmt.Sprintf("Modifier{Keys: %+v, Value: %q}", m.Keys, m.Value) 59 | } 60 | */ 61 | 62 | func parseEnv() map[string]string { 63 | env := map[string]string{} 64 | for _, s := range os.Environ() { 65 | i := strings.Index(s, "=") 66 | if i < 0 || i == len(s)-1 { 67 | continue 68 | } 69 | env[s[0:i]] = s[i+1:] 70 | } 71 | return env 72 | } 73 | 74 | // LoadModifiers loads user's custom hotkeys. 75 | func LoadModifiers() []Modifier { 76 | var ( 77 | scripts = LoadScripts() 78 | mods []Modifier 79 | script Script 80 | ok bool 81 | ) 82 | for k, name := range parseEnv() { 83 | if k == "ACTION_DEFAULT" || !strings.HasPrefix(k, "ACTION_") { 84 | continue 85 | } 86 | var keys []aw.ModKey 87 | for _, s := range strings.Split(strings.ToLower(k[7:]), "_") { 88 | if _, ok := validMods[s]; ok { 89 | keys = append(keys, aw.ModKey(s)) 90 | } 91 | } 92 | if len(keys) == 0 { 93 | log.Printf("[actions] invalid modifiers: %s", k[7:]) 94 | continue 95 | } 96 | if script, ok = scripts[name]; !ok { 97 | log.Printf("[actions] unknown script: %s", name) 98 | continue 99 | } 100 | 101 | log.Printf("[modifiers] %v -> %q", keys, name) 102 | mods = append(mods, Modifier{Keys: keys, Script: script}) 103 | } 104 | return mods 105 | } 106 | 107 | /* 108 | func LoadModifiers() []Modifier { 109 | var mods []Modifier 110 | for _, s := range os.Environ() { 111 | i := strings.Index(s, "=") 112 | if i < 0 || i == len(s)-1 { 113 | continue 114 | } 115 | key, value := s[0:i], s[i+1:] 116 | if !strings.HasPrefix(key, "URL_") { 117 | continue 118 | } 119 | key = key[4:] 120 | if m, err := newModifier(key, value); err != nil { 121 | log.Printf("[ERROR] %v", err) 122 | } else { 123 | log.Printf("mod=%v", m) 124 | mods = append(mods, m) 125 | } 126 | } 127 | return mods 128 | } 129 | */ 130 | 131 | /* 132 | func newModifier(key, value string) (Modifier, error) { 133 | m := Modifier{ 134 | Name: os.Getenv("NAME_" + key), 135 | Value: value, 136 | } 137 | for _, k := range strings.Split(strings.ToLower(key), "_") { 138 | if _, ok := validMods[k]; ok { 139 | m.Keys = append(m.Keys, aw.ModKey(k)) 140 | } 141 | } 142 | if len(m.Keys) == 0 { 143 | return Modifier{}, fmt.Errorf("invalid modifiers: %s", key) 144 | } 145 | return m, nil 146 | } 147 | */ 148 | -------------------------------------------------------------------------------- /pkg/cli/options.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-14 4 | 5 | package cli 6 | 7 | import ( 8 | "flag" 9 | "log" 10 | "strings" 11 | "time" 12 | 13 | "github.com/deanishe/awgo/keychain" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | const ( 18 | // defaults 19 | minCacheAge = 3 * time.Minute 20 | maxBooksPerAuthor = 100 21 | minBooksPerAuthor = 30 22 | ) 23 | 24 | var ( 25 | fs *flag.FlagSet 26 | opts *options 27 | ) 28 | 29 | func init() { 30 | opts = &options{ 31 | MaxBooks: maxBooksPerAuthor, 32 | LastRequestParsed: time.Time{}, 33 | } 34 | // default cache values 35 | opts.MaxCache.Default = 24 * time.Hour 36 | opts.MaxCache.Search = 12 * time.Hour 37 | opts.MaxCache.Icons = 336 * time.Hour // 14 days 38 | opts.MaxCache.Shelf = 5 * time.Minute 39 | opts.MaxCache.Feeds = 90 * time.Minute 40 | } 41 | 42 | type options struct { 43 | MaxBooks int // How many books to load per author 44 | MaxCache struct { // How long data are cached 45 | Default time.Duration 46 | Search time.Duration 47 | Shelf time.Duration 48 | Icons time.Duration 49 | Feeds time.Duration 50 | } 51 | AccessToken string // OAuth token 52 | AccessSecret string // OAuth secret 53 | MinQueryLength int // Minimum length of search query 54 | 55 | // Whether to always export book details to scripts 56 | ExportDetails bool 57 | 58 | // RSS feed/shelves data 59 | UserID int64 // User's Goodreads ID 60 | UserName string // User's Goodreads username (may not be set) 61 | 62 | // Time of last request to Goodreads API. 63 | // Requests are throttled to 1/sec. 64 | LastRequest string 65 | LastRequestParsed time.Time 66 | 67 | // Scripts 68 | DefaultScript string `env:"ACTION_DEFAULT"` 69 | 70 | // Workflow data 71 | BookID int64 72 | BookTitle string `env:"TITLE"` 73 | AuthorID int64 74 | AuthorName string 75 | ShelfID int64 76 | ShelfName string 77 | ShelfTitle string 78 | SeriesID int64 79 | SeriesName string `env:"SERIES"` 80 | 81 | // Alternate actions 82 | FlagAuthor bool `env:"-"` 83 | FlagCacheAuthor bool `env:"-"` 84 | FlagShelf bool `env:"-"` 85 | FlagCacheShelf bool `env:"-"` 86 | FlagShelves bool `env:"-"` 87 | FlagAddToShelves bool `env:"-"` 88 | FlagRemoveFromShelf bool `env:"-"` 89 | FlagSelectShelf bool `env:"-"` 90 | FlagSelectShelves bool `env:"-"` 91 | FlagCacheShelves bool `env:"-"` 92 | FlagReloadShelf bool `env:"-"` 93 | FlagReloadShelves bool `env:"-"` 94 | FlagFeeds bool `env:"-"` 95 | FlagConf bool `env:"-"` 96 | FlagAuthorise bool `env:"-"` 97 | FlagDeauthorise bool `env:"-"` 98 | FlagOpen bool `env:"-"` 99 | FlagScript bool `env:"-"` 100 | FlagScripts bool `env:"-"` 101 | FlagSearch bool `env:"-"` 102 | FlagSeries bool `env:"-"` 103 | FlagCacheSeries bool `env:"-"` 104 | FlagCacheBook bool `env:"-"` 105 | FlagUserInfo bool `env:"-"` 106 | FlagHousekeeping bool `env:"-"` 107 | FlagIcons bool `env:"-"` 108 | FlagHelp bool `env:"-"` 109 | FlagNoop bool `env:"-"` 110 | 111 | // script helper functions 112 | FlagExport bool `env:"-"` 113 | FlagJSON bool `env:"-"` 114 | FlagBeep bool `env:"-"` 115 | FlagNotify string `env:"-"` 116 | FlagNotifyMessage string `env:"-"` 117 | FlagAction string `env:"-"` 118 | FlagHide bool `env:"-"` 119 | FlagPassvars bool `env:"-"` 120 | FlagQuery string `env:"-"` 121 | 122 | // Search query. Populated from first argument. 123 | Query string `env:"-"` 124 | // All args 125 | Args []string `env:"-"` 126 | } 127 | 128 | // QueryEmpty returns true if trimmed query is empty. 129 | func (opts *options) QueryEmpty() bool { return strings.TrimSpace(opts.Query) == "" } 130 | 131 | // QueryTooShort returns true if query is empty. 132 | func (opts *options) QueryTooShort() bool { 133 | return len(strings.TrimSpace(opts.Query)) < opts.MinQueryLength 134 | } 135 | 136 | // Authorised returns true if workflow has an OAuth token. 137 | func (opts *options) Authorised() bool { return opts.AccessToken != "" } 138 | 139 | func (opts *options) Prepare(args []string) error { 140 | log.Printf("argv=%#v", args) 141 | 142 | fs = flag.NewFlagSet("alfred-booksearch", flag.ExitOnError) 143 | 144 | fs.BoolVar(&opts.FlagSearch, "search", false, "search for books") 145 | fs.BoolVar(&opts.FlagConf, "conf", false, "show workflow configuration") 146 | 147 | fs.BoolVar(&opts.FlagAuthor, "author", false, "list books for author") 148 | fs.BoolVar(&opts.FlagCacheAuthor, "savebooks", false, "cache all books by author") 149 | 150 | fs.BoolVar(&opts.FlagSeries, "series", false, "list books in a series") 151 | fs.BoolVar(&opts.FlagCacheSeries, "saveseries", false, "cache all books in a series") 152 | 153 | fs.BoolVar(&opts.FlagCacheBook, "savebook", false, "cache book details") 154 | 155 | fs.BoolVar(&opts.FlagShelf, "shelf", false, "list books on shelf") 156 | fs.BoolVar(&opts.FlagShelves, "shelves", false, "list user shelves") 157 | fs.BoolVar(&opts.FlagCacheShelf, "saveshelf", false, "cache user shelf") 158 | fs.BoolVar(&opts.FlagCacheShelves, "saveshelves", false, "cache all user shelves") 159 | fs.BoolVar(&opts.FlagAddToShelves, "add", false, "add book to shelves") 160 | fs.BoolVar(&opts.FlagRemoveFromShelf, "remove", false, "remove book from shelf") 161 | fs.BoolVar(&opts.FlagSelectShelves, "selection", false, "select shelves to add a book to") 162 | fs.BoolVar(&opts.FlagSelectShelf, "select", false, "toggle shelf selected") 163 | fs.BoolVar(&opts.FlagReloadShelf, "reload", false, "reload shelf") 164 | fs.BoolVar(&opts.FlagReloadShelves, "reloadshelves", false, "reload shelves") 165 | 166 | fs.BoolVar(&opts.FlagFeeds, "feeds", false, "fetch RSS feeds") 167 | fs.BoolVar(&opts.FlagHousekeeping, "housekeeping", false, "check for a new version & clear stale caches") 168 | fs.BoolVar(&opts.FlagIcons, "icons", false, "download queued icons") 169 | fs.BoolVar(&opts.FlagAuthorise, "authorise", false, "intiate OAuth authorisation flow") 170 | fs.BoolVar(&opts.FlagDeauthorise, "deauthorise", false, "delete OAuth credentials") 171 | fs.BoolVar(&opts.FlagUserInfo, "userinfo", false, "retrieve user info from API") 172 | fs.BoolVar(&opts.FlagHelp, "h", false, "show this message and exit") 173 | fs.BoolVar(&opts.FlagOpen, "open", false, "open URL/file") 174 | 175 | fs.BoolVar(&opts.FlagScript, "script", false, "run named script") 176 | fs.BoolVar(&opts.FlagScripts, "scripts", false, "show scripts") 177 | 178 | fs.BoolVar(&opts.FlagExport, "export", false, "export book details as shell variables") 179 | fs.BoolVar(&opts.FlagJSON, "json", false, "export book data as JSON") 180 | 181 | fs.BoolVar(&opts.FlagBeep, "beep", false, `play "morse" sound`) 182 | fs.BoolVar(&opts.FlagNoop, "noop", false, "do nothing") 183 | 184 | fs.StringVar(&opts.FlagNotify, "notify", "", "show notification") 185 | fs.StringVar(&opts.FlagNotifyMessage, "message", "", "show notification") 186 | fs.StringVar(&opts.FlagAction, "action", "", "next action") 187 | fs.BoolVar(&opts.FlagHide, "hide", false, "hide Alfred") 188 | fs.BoolVar(&opts.FlagPassvars, "passvars", false, "pass variables to next action") 189 | fs.StringVar(&opts.FlagQuery, "query", "", "search query") 190 | 191 | if err := fs.Parse(args); err != nil { 192 | return errors.Wrap(err, "parse CLI args") 193 | } 194 | 195 | logIfError(wf.Config.To(opts), "load configuration: %v") 196 | 197 | opts.Args = fs.Args() 198 | if len(opts.Args) > 0 { 199 | opts.Query = strings.TrimSpace(opts.Args[0]) 200 | } 201 | log.Printf("query=%q", opts.Query) 202 | 203 | // Ensure sensible minimums 204 | if opts.MaxBooks < minBooksPerAuthor { 205 | opts.MaxBooks = minBooksPerAuthor 206 | } 207 | if opts.MaxCache.Search < minCacheAge { 208 | opts.MaxCache.Search = minCacheAge 209 | } 210 | if opts.MaxCache.Default < minCacheAge { 211 | opts.MaxCache.Default = minCacheAge 212 | } 213 | if opts.MaxCache.Icons < minCacheAge { 214 | opts.MaxCache.Icons = minCacheAge 215 | } 216 | if opts.MaxCache.Shelf < minCacheAge { 217 | opts.MaxCache.Shelf = minCacheAge 218 | } 219 | if opts.MaxCache.Feeds < minCacheAge { 220 | opts.MaxCache.Feeds = minCacheAge 221 | } 222 | 223 | if opts.MinQueryLength == 0 { 224 | opts.MinQueryLength = 2 225 | } 226 | 227 | if opts.DefaultScript == "" { 228 | opts.DefaultScript = "View Book Online" 229 | } 230 | 231 | // Try to read API key from Keychain 232 | if opts.AccessToken == "" { 233 | kc := keychain.New(wf.BundleID()) 234 | if s, err := kc.Get(tokensKey); err == nil { 235 | parts := strings.Split(s, " ") 236 | opts.AccessToken, opts.AccessSecret = parts[0], parts[1] 237 | } else if err != keychain.ErrNotFound { 238 | return errors.Wrap(err, "Keychain") 239 | } 240 | 241 | wf.Var("ACCESS_TOKEN", opts.AccessToken) 242 | wf.Var("ACCESS_SECRET", opts.AccessSecret) 243 | } 244 | 245 | if opts.LastRequest != "" { 246 | if err := opts.LastRequestParsed.UnmarshalText([]byte(opts.LastRequest)); err != nil { 247 | return errors.Wrap(err, "parse LastRequest") 248 | } 249 | } 250 | // log.Println("opts=" + spew.Sdump(opts)) 251 | return nil 252 | } 253 | -------------------------------------------------------------------------------- /pkg/cli/script_helpers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-08-02 4 | 5 | package cli 6 | 7 | import ( 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "log" 12 | 13 | aw "github.com/deanishe/awgo" 14 | "github.com/keegancsmith/shell" 15 | ) 16 | 17 | // Play "morse" sound 18 | func runBeep() { 19 | wf.Configure(aw.TextErrors(true)) 20 | log.Print("[beep] playing sound") 21 | wf.Alfred.RunTrigger("beep", "") 22 | } 23 | 24 | func runVariables() { 25 | wf.Configure(aw.TextErrors(true)) 26 | v := aw.NewArgVars() 27 | 28 | fs.Visit(func(f *flag.Flag) { 29 | log.Printf("[variables] %s=%s", f.Name, f.Value) 30 | switch f.Name { 31 | case "notify": 32 | v.Var("notification_title", f.Value.String()) 33 | 34 | case "message": 35 | v.Var("notification_text", f.Value.String()) 36 | 37 | case "action": 38 | v.Var("action", f.Value.String()) 39 | 40 | case "hide": 41 | if !opts.FlagHide { 42 | v.Var("hide_alfred", "") 43 | } 44 | 45 | case "passvars": 46 | var s string 47 | if opts.FlagPassvars { 48 | s = "true" 49 | } 50 | v.Var("passvars", s) 51 | 52 | case "query": 53 | v.Var("query", f.Value.String()) 54 | } 55 | }) 56 | 57 | // if hidden, clear all settings 58 | if opts.FlagHide { 59 | v.Var("hide_alfred", "true") 60 | v.Var("action", "") 61 | v.Var("passvars", "") 62 | v.Var("query", "") 63 | } 64 | 65 | checkErr(v.Send()) 66 | } 67 | 68 | // Export book details as shell variables 69 | func runExport(asJSON bool) { 70 | wf.Configure(aw.TextErrors(true)) 71 | 72 | b, err := bookDetails(opts.BookID) 73 | if err != nil { 74 | notifyError("Fetch Book Details", err) 75 | log.Fatalf("fetch book details: %v", err) 76 | } 77 | 78 | if asJSON { 79 | data, err := json.MarshalIndent(b, "", " ") 80 | checkErr(err) 81 | fmt.Print(string(data)) 82 | return 83 | } 84 | 85 | for k, v := range bookVariables(b) { 86 | fmt.Println(shell.Sprintf("export %s=%s", k, v)) 87 | // fmt.Printf("export %s=%s\n", k, shell.EscapeArg(v)) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pkg/cli/scripts.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-29 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | "net/url" 12 | "os" 13 | "path/filepath" 14 | "sort" 15 | "strings" 16 | "time" 17 | 18 | aw "github.com/deanishe/awgo" 19 | "github.com/deanishe/awgo/util" 20 | "github.com/pkg/errors" 21 | "go.deanishe.net/alfred-booksearch/pkg/gr" 22 | ) 23 | 24 | var ( 25 | runner util.Runner 26 | imageExts = map[string]struct{}{ 27 | ".png": {}, 28 | ".icns": {}, 29 | ".gif": {}, 30 | ".jpg": {}, 31 | ".jpeg": {}, 32 | } 33 | ) 34 | 35 | func init() { 36 | runner = util.Runners{util.Executable, util.Script} 37 | } 38 | 39 | // Show scripts in Alfred. 40 | func runScripts() { 41 | updateStatus() 42 | if !authorisedStatus() { 43 | return 44 | } 45 | 46 | var scripts []Script 47 | for _, s := range LoadScripts() { 48 | scripts = append(scripts, s) 49 | } 50 | sort.Sort(Scripts(scripts)) 51 | 52 | for _, s := range scripts { 53 | wf.NewItem(s.Name). 54 | Arg("-script", s.Name). 55 | UID(s.Name). 56 | Copytext(s.Name). 57 | Valid(true). 58 | Icon(s.Icon). 59 | Var("hide_alfred", "true"). 60 | Var("action", "") 61 | } 62 | 63 | addNavActions() 64 | 65 | if !opts.QueryEmpty() { 66 | wf.Filter(opts.Query) 67 | } 68 | 69 | wf.WarnEmpty("No Matching Scripts", "Try a different query?") 70 | wf.SendFeedback() 71 | } 72 | 73 | // Execute specified script. 74 | func runScript() { 75 | wf.Configure(aw.TextErrors(true)) 76 | 77 | scripts := LoadScripts() 78 | if s, ok := scripts[opts.Query]; ok { 79 | if opts.ExportDetails { // add all book variables to environment 80 | b, err := bookDetails(opts.BookID) 81 | if err != nil { 82 | notifyError("Fetch Book Details", err) 83 | log.Fatalf("[ERROR] book details: %v", err) 84 | } 85 | for k, v := range bookVariables(b) { 86 | os.Setenv(k, v) 87 | } 88 | } 89 | 90 | log.Printf("[actions] running script %q ...", util.PrettyPath(s.Path)) 91 | data, err := util.RunCmd(runner.Cmd(s.Path)) 92 | if err != nil { 93 | notifyError(fmt.Sprintf("Run Script %q", s.Name), err) 94 | log.Fatalf("[ERROR] run script %q: %v", s.Name, err) 95 | } 96 | 97 | fmt.Print(string(data)) 98 | return 99 | } 100 | notifyError("Unknown Script", errors.New(opts.Query)) 101 | } 102 | 103 | // Script is a built-in or user script. 104 | type Script struct { 105 | Name string 106 | Path string 107 | Icon *aw.Icon 108 | } 109 | 110 | // Scripts sorts Scripts by name. 111 | type Scripts []Script 112 | 113 | // Implement sort.Interface 114 | func (s Scripts) Len() int { return len(s) } 115 | func (s Scripts) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 116 | func (s Scripts) Less(i, j int) bool { return s[i].Name < s[j].Name } 117 | 118 | // LoadScripts reads built-in and user scripts. 119 | func LoadScripts() map[string]Script { 120 | var ( 121 | files = map[string]string{} 122 | icons = map[string]*aw.Icon{} 123 | ) 124 | 125 | for _, dir := range []string{scriptsDir, userScriptsDir} { 126 | infos, err := ioutil.ReadDir(dir) 127 | if err != nil { 128 | log.Printf("[scripts] [ERROR] read script directory %q: %v", dir, err) 129 | continue 130 | } 131 | 132 | for _, fi := range infos { 133 | var ( 134 | filename = fi.Name() 135 | path = filepath.Join(dir, filename) 136 | ext = strings.ToLower(filepath.Ext(filename)) 137 | name = filename[0 : len(filename)-len(ext)] 138 | ) 139 | if _, ok := imageExts[ext]; ok { 140 | icons[name] = &aw.Icon{Value: path} 141 | } else if runner.CanRun(path) { 142 | files[name] = path 143 | } 144 | } 145 | } 146 | 147 | var ( 148 | scripts = map[string]Script{} 149 | icon *aw.Icon 150 | ok bool 151 | ) 152 | for name, path := range files { 153 | if icon, ok = icons[name]; !ok { 154 | icon = iconScript 155 | } 156 | scripts[name] = Script{name, path, icon} 157 | } 158 | 159 | for _, s := range scripts { 160 | log.Printf("[scripts] name=%q, path=%q", s.Name, util.PrettyPath(s.Path)) 161 | } 162 | 163 | return scripts 164 | } 165 | 166 | // returns Book populated with all details. 167 | func bookDetails(id int64) (gr.Book, error) { 168 | key := "books/" + cachefileID(id) 169 | reload := func() (interface{}, error) { 170 | earliest := opts.LastRequestParsed.Add(time.Second * 1) 171 | now := time.Now() 172 | if earliest.After(now) { 173 | d := earliest.Sub(now) 174 | log.Printf("[throttled] waiting for %v ...", d) 175 | time.Sleep(d) 176 | } 177 | data, _ := time.Now().MarshalText() 178 | wf.Var("LAST_REQUEST", string(data)) 179 | return api.BookDetails(id) 180 | } 181 | 182 | var b gr.Book 183 | util.MustExist(filepath.Dir(filepath.Join(wf.CacheDir(), key))) 184 | if err := wf.Cache.LoadOrStoreJSON(key, opts.MaxCache.Default, reload, &b); err != nil { 185 | return gr.Book{}, errors.Wrap(err, "book details") 186 | } 187 | 188 | return b, nil 189 | } 190 | 191 | func bookVariables(b gr.Book) map[string]string { 192 | data := map[string]string{} 193 | for k, v := range b.Data() { 194 | data[k] = v 195 | data[k+"_QUOTED"] = url.PathEscape(v) 196 | data[k+"_QUOTED_PLUS"] = url.QueryEscape(v) 197 | } 198 | return data 199 | } 200 | -------------------------------------------------------------------------------- /pkg/cli/search.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package cli 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "path/filepath" 10 | "time" 11 | 12 | aw "github.com/deanishe/awgo" 13 | "github.com/deanishe/awgo/util" 14 | 15 | "go.deanishe.net/alfred-booksearch/pkg/gr" 16 | ) 17 | 18 | // Search Goodreads 19 | func runSearch() { 20 | updateStatus() 21 | if !authorisedStatus() { 22 | return 23 | } 24 | 25 | if opts.LastRequest != "" { 26 | data, _ := opts.LastRequestParsed.MarshalText() 27 | wf.Var("LAST_REQUEST", string(data)) 28 | } 29 | 30 | // Search for books 31 | log.Printf("query=%q, sinceLastRequest=%v", opts.Query, time.Since(opts.LastRequestParsed)) 32 | 33 | if opts.QueryTooShort() { 34 | wf.NewItem("Query Too Short"). 35 | Subtitle("Keep typing…") 36 | wf.SendFeedback() 37 | return 38 | } 39 | 40 | wf.Var("last_action", "search") 41 | wf.Var("last_query", opts.Query) 42 | 43 | var ( 44 | icons = newIconCache(iconCacheDir) 45 | books = cachingSearch(opts.Query) 46 | mods = LoadModifiers() 47 | ) 48 | 49 | for _, b := range books { 50 | bookItem(b, icons, mods) 51 | } 52 | 53 | wf.WarnEmpty("No Books Found", "Try a different query?") 54 | 55 | if icons.HasQueue() { 56 | var err error 57 | if err = icons.Close(); err == nil { 58 | err = runJob(iconsJob, "-icons") 59 | } 60 | logIfError(err, "cache icons: %v") 61 | } 62 | 63 | if wf.IsRunning(iconsJob) { 64 | wf.Rerun(rerunInterval) 65 | } 66 | wf.SendFeedback() 67 | } 68 | 69 | func cachingSearch(query string) (results []gr.Book) { 70 | key := "queries/" + cachefile(hash(query), ".json") 71 | reload := func() (interface{}, error) { 72 | earliest := opts.LastRequestParsed.Add(time.Second * 1) 73 | now := time.Now() 74 | if earliest.After(now) { 75 | d := earliest.Sub(now) 76 | log.Printf("[throttled] waiting for %v ...", d) 77 | time.Sleep(d) 78 | } 79 | data, _ := time.Now().MarshalText() 80 | wf.Var("LAST_REQUEST", string(data)) 81 | return api.Search(query) 82 | } 83 | 84 | util.MustExist(filepath.Dir(filepath.Join(wf.CacheDir(), key))) 85 | if err := wf.Cache.LoadOrStoreJSON(key, opts.MaxCache.Search, reload, &results); err != nil { 86 | panic(err) 87 | } 88 | return 89 | } 90 | 91 | // return an aw.Item for Book. 92 | func bookItem(b gr.Book, icons *iconCache, mods []Modifier) *aw.Item { 93 | var date, subtitle, rating string 94 | 95 | if !b.PubDate.IsZero() { 96 | date = fmt.Sprintf(" (%s)", b.PubDate.Format("2006")) 97 | } 98 | if b.Rating != 0 { 99 | rating = fmt.Sprintf(" ⭑ %0.2f", b.Rating) 100 | } 101 | 102 | subtitle = b.Author.Name + date + rating 103 | 104 | it := wf.NewItem(b.Title). 105 | Subtitle(subtitle). 106 | Match(b.Title+" "+b.Author.Name). 107 | Arg("-script", opts.DefaultScript). 108 | Copytext(b.Title). 109 | Valid(true). 110 | UID(fmt.Sprintf("%d", b.ID)). 111 | Icon(icons.BookIcon(b)). 112 | Var("hide_alfred", "true"). 113 | Var("query", opts.Query). 114 | Var("passvars", "true"). 115 | Var("action", "") 116 | 117 | if b.Description != "" { 118 | it.Largetype(b.DescriptionText()) 119 | } 120 | 121 | for k, v := range bookVariables(b) { 122 | it.Var(k, v) 123 | } 124 | 125 | it.NewModifier(aw.ModCmd). 126 | Subtitle("All Actions…"). 127 | Arg("-noop"). 128 | Icon(iconMore). 129 | Var("hide_alfred", ""). 130 | Var("action", "scripts"). 131 | Var("query", "") 132 | 133 | for _, m := range mods { 134 | it.NewModifier(m.Keys...). 135 | Subtitle(m.Script.Name). 136 | Arg("-script", m.Script.Name). 137 | Icon(m.Script.Icon) 138 | } 139 | 140 | return it 141 | } 142 | -------------------------------------------------------------------------------- /pkg/cli/series.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-08-08 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "path/filepath" 11 | "strconv" 12 | 13 | aw "github.com/deanishe/awgo" 14 | "github.com/deanishe/awgo/util" 15 | "go.deanishe.net/alfred-booksearch/pkg/gr" 16 | ) 17 | 18 | // show books in a series 19 | func runSeries() { 20 | updateStatus() 21 | if !authorisedStatus() { 22 | return 23 | } 24 | 25 | id := opts.SeriesID 26 | 27 | if id == 0 { 28 | var ( 29 | book gr.Book 30 | key = "books/" + cachefileID(opts.BookID) 31 | ) 32 | if !wf.Cache.Exists(key) { 33 | if !wf.IsRunning(bookJob) { 34 | checkErr(runJob(bookJob, "-savebook")) 35 | } 36 | wf.Rerun(rerunInterval) 37 | wf.NewItem("Loading Series…"). 38 | Subtitle("Results will appear momentarily"). 39 | Icon(spinnerIcon()) 40 | wf.SendFeedback() 41 | return 42 | } 43 | 44 | checkErr(wf.Cache.LoadJSON(key, &book)) 45 | id = book.Series.ID 46 | } 47 | 48 | if id == 0 { 49 | wf.Fatal("Book is not part of a series") 50 | } 51 | 52 | // log.Printf("[series] seriesID=%d", id) 53 | wf.Var("last_action", "series") 54 | wf.Var("last_query", opts.Query) 55 | wf.Var("SERIES_ID", fmt.Sprintf("%d", id)) 56 | 57 | var ( 58 | key = "series/" + cachefileID(id) 59 | icons = newIconCache(iconCacheDir) 60 | mods = LoadModifiers() 61 | series gr.Series 62 | rerun = wf.IsRunning(seriesJob) 63 | ) 64 | 65 | if wf.Cache.Expired(key, opts.MaxCache.Default) { 66 | rerun = true 67 | checkErr(runJob(seriesJob, "-saveseries", fmt.Sprintf("%d", id))) 68 | } 69 | 70 | if wf.Cache.Exists(key) { 71 | checkErr(wf.Cache.LoadJSON(key, &series)) 72 | log.Printf("loaded series %q from cache", series.Title) 73 | } else { 74 | wf.NewItem("Loading Series…"). 75 | Subtitle("Results will appear momentarily"). 76 | Icon(spinnerIcon()) 77 | } 78 | 79 | log.Printf("[series] %d book(s) in series %q", len(series.Books), series.Title) 80 | 81 | if opts.QueryEmpty() { 82 | wf.Configure(aw.SuppressUIDs(true)) 83 | } 84 | 85 | for _, b := range series.Books { 86 | bookItem(b, icons, mods) 87 | } 88 | 89 | addNavActions() 90 | 91 | if !opts.QueryEmpty() { 92 | wf.Filter(opts.Query) 93 | } 94 | 95 | wf.WarnEmpty("No Matching Books", "Try a different query?") 96 | 97 | if icons.HasQueue() { 98 | var err error 99 | if err = icons.Close(); err == nil { 100 | err = runJob(iconsJob, "-icons") 101 | } 102 | logIfError(err, "cache icons: %v") 103 | } 104 | 105 | if rerun || wf.IsRunning(iconsJob) { 106 | wf.Rerun(rerunInterval) 107 | } 108 | 109 | wf.SendFeedback() 110 | } 111 | 112 | // save series list to cache 113 | func runCacheSeries() { 114 | wf.Configure(aw.TextErrors(true)) 115 | if !opts.Authorised() { 116 | return 117 | } 118 | 119 | var ( 120 | id, _ = strconv.ParseInt(opts.Query, 10, 64) 121 | key = "series/" + cachefileID(id) 122 | series gr.Series 123 | err error 124 | ) 125 | 126 | series, err = api.Series(id) 127 | checkErr(err) 128 | 129 | util.MustExist(filepath.Dir(filepath.Join(wf.CacheDir(), key))) 130 | checkErr(wf.Cache.StoreJSON(key, series)) 131 | } 132 | 133 | func runCacheBook() { 134 | wf.Configure(aw.TextErrors(true)) 135 | if !opts.Authorised() { 136 | return 137 | } 138 | _, err := bookDetails(opts.BookID) 139 | checkErr(err) 140 | } 141 | -------------------------------------------------------------------------------- /pkg/cli/shelves.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package cli 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "sort" 11 | "time" 12 | 13 | aw "github.com/deanishe/awgo" 14 | 15 | "go.deanishe.net/alfred-booksearch/pkg/gr" 16 | "go.deanishe.net/fuzzy" 17 | ) 18 | 19 | const shelvesKey = "shelves.json" 20 | 21 | // Show books on shelf 22 | func runShelf() { 23 | updateStatus() 24 | if !authorisedStatus() { 25 | return 26 | } 27 | 28 | var ( 29 | shelf gr.Shelf 30 | key = "shelves/" + opts.ShelfName + ".json" 31 | rerun = wf.IsRunning(shelfJob) 32 | ) 33 | 34 | if wf.Cache.Expired(key, opts.MaxCache.Shelf) { 35 | rerun = true 36 | checkErr(runJob(shelfJob, "-saveshelf")) 37 | } 38 | 39 | if wf.Cache.Exists(key) { 40 | checkErr(wf.Cache.LoadJSON(key, &shelf)) 41 | } else { 42 | wf.NewItem("Loading Books…"). 43 | Subtitle("Results will appear momentarily"). 44 | Icon(spinnerIcon()) 45 | } 46 | 47 | wf.Var("last_action", "shelf") 48 | wf.Var("hide_alfred", "") 49 | wf.Var("last_query", opts.Query) 50 | 51 | var ( 52 | icons = newIconCache(iconCacheDir) 53 | mods = LoadModifiers() 54 | ) 55 | 56 | log.Printf("query=%q", opts.Query) 57 | 58 | // show books in list order if there's no query 59 | if opts.QueryEmpty() { 60 | wf.Configure(aw.SuppressUIDs(true)) 61 | } 62 | 63 | for _, b := range shelf.Books { 64 | it := bookItem(b, icons, mods) 65 | 66 | it.NewModifier(aw.ModCtrl). 67 | Subtitle("Remove from Shelf"). 68 | Arg("-remove", shelf.Name). 69 | Icon(iconDelete). 70 | Var("action", "shelf"). 71 | Var("query", opts.Query). 72 | Var("passvars", "true") 73 | } 74 | 75 | // add alternate actions 76 | if len(opts.Query) > 2 { 77 | wf.NewItem("Reload"). 78 | Subtitle("Reload shelf from server"). 79 | Arg("-reload"). 80 | UID("reload"). 81 | Icon(iconReload). 82 | Valid(true). 83 | Var("last_query", "") 84 | 85 | // wf.NewItem("Shelves"). 86 | // Subtitle("Go back to shelves list"). 87 | // Match("shelves"). 88 | // Arg("-noop"). 89 | // UID("shelves"). 90 | // Icon(iconBook). 91 | // Valid(true). 92 | // Var("action", "shelves"). 93 | // Var("last_query", ""). 94 | // Var("last_action", "") 95 | } 96 | 97 | addNavActions() 98 | 99 | if !opts.QueryEmpty() { 100 | wf.Filter(opts.Query) 101 | } 102 | 103 | wf.WarnEmpty("No Matching Books", "Try a different query?") 104 | 105 | if icons.HasQueue() { 106 | var err error 107 | if err = icons.Close(); err == nil { 108 | err = runJob(iconsJob, "-icons") 109 | } 110 | logIfError(err, "cache icons: %v") 111 | } 112 | 113 | if rerun || wf.IsRunning(iconsJob) { 114 | wf.Rerun(rerunInterval) 115 | } 116 | 117 | wf.SendFeedback() 118 | } 119 | 120 | // Show user's shelves 121 | func runShelves() { 122 | updateStatus() 123 | if !authorisedStatus() { 124 | return 125 | } 126 | 127 | if opts.UserID == 0 { 128 | wf.NewItem("User ID is not Set"). 129 | Subtitle("Please deauthorise and re-authorise the workflow"). 130 | Valid(false). 131 | Icon(iconError) 132 | wf.SendFeedback() 133 | return 134 | } 135 | 136 | wf.Var("last_action", "shelves") 137 | wf.Var("last_query", opts.Query) 138 | 139 | var ( 140 | shelves []gr.Shelf 141 | rerun = wf.IsRunning(shelvesJob) 142 | ) 143 | 144 | if wf.Cache.Expired(shelvesKey, opts.MaxCache.Shelf) { 145 | rerun = true 146 | checkErr(runJob(shelvesJob, "-saveshelves")) 147 | } 148 | 149 | if wf.Cache.Exists(shelvesKey) { 150 | checkErr(wf.Cache.LoadJSON(shelvesKey, &shelves)) 151 | log.Printf("loaded %d shelves from cache", len(shelves)) 152 | } else { 153 | wf.NewItem("Loading Shelves…"). 154 | Subtitle("Results will appear momentarily"). 155 | Icon(spinnerIcon()) 156 | } 157 | 158 | if opts.QueryEmpty() { 159 | wf.Configure(aw.SuppressUIDs(true)) 160 | } 161 | 162 | for _, shelf := range shelves { 163 | id := fmt.Sprintf("%d", shelf.ID) 164 | it := wf.NewItem(shelf.Title()). 165 | Subtitle(fmt.Sprintf("%d book(s)", shelf.Size)). 166 | UID(id). 167 | Valid(true). 168 | Icon(iconShelf). 169 | Var("SHELF_ID", id). 170 | Var("SHELF_NAME", shelf.Name). 171 | Var("SHELF_TITLE", shelf.Title()). 172 | Var("action", "shelf"). 173 | Var("passvars", "true") 174 | 175 | it.NewModifier(aw.ModCmd). 176 | Subtitle(fmt.Sprintf("Open “%s” on goodreads.com", shelf.Title())). 177 | Valid(true). 178 | Arg("-open", shelf.URL). 179 | Var("action", ""). 180 | Var("hide_alfred", "true") 181 | } 182 | 183 | // add alternate actions 184 | if len(opts.Query) > 2 { 185 | wf.NewItem("Reload"). 186 | Subtitle("Reload shelves from server"). 187 | Arg("-reloadshelves"). 188 | UID("reload"). 189 | Icon(iconReload). 190 | Valid(true). 191 | Var("action", "shelves"). 192 | Var("hide_alfred", "") 193 | } 194 | 195 | addNavActions("shelves") 196 | 197 | if !opts.QueryEmpty() { 198 | wf.Filter(opts.Query) 199 | } 200 | 201 | log.Printf("query=%q", opts.Query) 202 | 203 | if rerun { 204 | wf.Rerun(rerunInterval) 205 | } 206 | wf.WarnEmpty("No Matching Lists", "Try a different query?") 207 | wf.SendFeedback() 208 | } 209 | 210 | // add book to shelf 211 | func runAddToShelves() { 212 | wf.Configure(aw.TextErrors(true)) 213 | if !opts.Authorised() { 214 | return 215 | } 216 | log.Printf("adding book %d to shelves %v", opts.BookID, opts.Args) 217 | if err := api.AddToShelves(opts.BookID, opts.Args); err != nil { 218 | notifyError("Add to Shelves Failed", err) 219 | log.Fatalf("[ERROR] add to shelf %q: %v", opts.Query, err) 220 | } 221 | 222 | msg := "Added to 1 shelf" 223 | if len(opts.Args) > 1 { 224 | msg = fmt.Sprintf("Added to %d shelves", len(opts.Args)) 225 | } 226 | v := aw.NewArgVars() 227 | v.Var("notification_title", opts.BookTitle). 228 | Var("notification_text", msg) 229 | 230 | // deselect shelves 231 | for _, s := range opts.Args { 232 | v.Var("shelf_"+s, "") 233 | } 234 | checkErr(v.Send()) 235 | } 236 | 237 | // remove book from shelf 238 | func runRemoveFromShelf() { 239 | wf.Configure(aw.TextErrors(true)) 240 | if !opts.Authorised() { 241 | return 242 | } 243 | log.Printf("removing book %d from shelf %q", opts.BookID, opts.Query) 244 | if err := api.RemoveFromShelf(opts.BookID, opts.Query); err != nil { 245 | notifyError("Remove from Shelf Failed", err) 246 | log.Fatalf("[ERROR] remove from shelf %q: %v", opts.Query, err) 247 | } 248 | 249 | title := opts.ShelfTitle 250 | if title == "" { 251 | title = opts.Query 252 | } 253 | notify(opts.BookTitle, fmt.Sprintf("Removed from “%s”", title)) 254 | 255 | // remove book from cache 256 | var ( 257 | key = "shelves/" + opts.ShelfName + ".json" 258 | cleaned []gr.Book 259 | shelf gr.Shelf 260 | ) 261 | if !wf.Cache.Exists(key) { 262 | return 263 | } 264 | if err := wf.Cache.LoadJSON(key, &shelf); err != nil { 265 | log.Fatalf("[ERROR] load cached shelf: %v", err) 266 | } 267 | 268 | for _, b := range shelf.Books { 269 | if b.ID != opts.BookID { 270 | cleaned = append(cleaned, b) 271 | } 272 | } 273 | shelf.Books = cleaned 274 | if err := wf.Cache.StoreJSON(key, shelf); err != nil { 275 | log.Fatalf("[ERROR] cache shelf: %v", err) 276 | } 277 | } 278 | 279 | // update cached shelf 280 | func runReloadShelf() { 281 | wf.Configure(aw.TextErrors(true)) 282 | if !opts.Authorised() { 283 | return 284 | } 285 | checkErr(runJob(shelfJob, "-saveshelf")) 286 | } 287 | 288 | // update cached shelves 289 | func runReloadShelves() { 290 | wf.Configure(aw.TextErrors(true)) 291 | if !opts.Authorised() { 292 | return 293 | } 294 | checkErr(runJob(shelvesJob, "-saveshelves")) 295 | } 296 | 297 | // choose shelves to add a book to 298 | func runSelectShelves() { 299 | if opts.FlagSelectShelf { 300 | wf.Configure(aw.TextErrors(true)) 301 | var ( 302 | key = fmt.Sprintf("shelf_" + opts.Query) 303 | value = "true" 304 | ) 305 | if wf.Config.GetBool(key) { 306 | value = "false" 307 | } 308 | log.Printf("[shelves] shelf=%s, selected=%s", opts.Query, value) 309 | checkErr(aw.NewArgVars().Var("shelf_"+opts.Query, value).Send()) 310 | return 311 | } 312 | 313 | updateStatus() 314 | if !authorisedStatus() { 315 | return 316 | } 317 | 318 | var ( 319 | shelves []gr.Shelf 320 | rerun = wf.IsRunning(shelvesJob) 321 | ) 322 | 323 | if wf.Cache.Expired(shelvesKey, opts.MaxCache.Shelf) { 324 | rerun = true 325 | checkErr(runJob(shelvesJob, "-saveshelves")) 326 | } 327 | 328 | if wf.Cache.Exists(shelvesKey) { 329 | checkErr(wf.Cache.LoadJSON(shelvesKey, &shelves)) 330 | log.Printf("loaded %d shelves from cache", len(shelves)) 331 | } else { 332 | wf.NewItem("Loading Shelves…"). 333 | Subtitle("Results will appear momentarily"). 334 | Icon(spinnerIcon()) 335 | } 336 | 337 | wf.Var("hide_alfred", "").Var("passvars", "true") 338 | 339 | var ( 340 | selected = selectShelves(shelves) 341 | args = append([]string{"-add"}, selected...) 342 | lastAction = wf.Config.Get("last_action") 343 | lastQuery = wf.Config.Get("last_query") 344 | msg = "Add to 1 shelf" 345 | ) 346 | 347 | if len(selected) != 1 { 348 | msg = fmt.Sprintf("Add to %d shelves", len(selected)) 349 | } 350 | 351 | if !opts.QueryEmpty() { 352 | shelves = filterShelves(shelves, opts.Query) 353 | } 354 | 355 | for _, shelf := range shelves { 356 | icon := iconShelf 357 | if shelf.Selected { 358 | icon = iconShelfSelected 359 | } 360 | 361 | it := wf.NewItem(shelf.Title()). 362 | Subtitle(fmt.Sprintf("%d book(s)", shelf.Size)). 363 | Arg("-selection", "-select", shelf.Name). 364 | Valid(true). 365 | Icon(icon). 366 | Var("action", "select"). 367 | Var("query", "") 368 | 369 | if len(selected) > 0 { 370 | it.NewModifier(aw.ModCmd). 371 | Subtitle(msg). 372 | Valid(true). 373 | Arg(args...). 374 | Icon(iconSave). 375 | Var("action", lastAction). 376 | Var("query", lastQuery). 377 | Var("last_query", ""). 378 | Var("last_action", "") 379 | } 380 | } 381 | 382 | log.Printf("query=%q", opts.Query) 383 | 384 | if rerun { 385 | wf.Rerun(rerunInterval) 386 | } 387 | wf.WarnEmpty("No Matching Lists", "Try a different query?") 388 | wf.SendFeedback() 389 | } 390 | 391 | // cache a specific shelf 392 | func runCacheShelf() { 393 | wf.Configure(aw.TextErrors(true)) 394 | if !opts.Authorised() { 395 | return 396 | } 397 | 398 | var ( 399 | key = "shelves/" + opts.ShelfName + ".json" 400 | page = 1 401 | pageCount int 402 | shelf = gr.Shelf{ID: opts.ShelfID, Name: opts.ShelfName} 403 | books []gr.Book 404 | meta gr.PageData 405 | last time.Time 406 | err error 407 | 408 | writePartial = !wf.Cache.Exists(key) 409 | ) 410 | 411 | log.Printf("[shelves] fetching shelf %q ...", opts.ShelfName) 412 | 413 | for { 414 | if pageCount > 0 && page > pageCount { 415 | break 416 | } 417 | 418 | if !last.IsZero() && time.Since(last) < time.Second { 419 | delay := time.Second - time.Since(last) 420 | log.Printf("[shelves] pausing %v till next request ...", delay) 421 | time.Sleep(delay) 422 | } 423 | last = time.Now() 424 | 425 | books, meta, err = api.UserShelf(opts.UserID, opts.ShelfName, page) 426 | checkErr(err) 427 | 428 | if pageCount == 0 { 429 | pageCount = meta.Total / 50 430 | if meta.Total%50 > 0 { 431 | pageCount++ 432 | } 433 | } 434 | 435 | shelf.Books = append(shelf.Books, books...) 436 | shelf.Size = meta.Total 437 | if writePartial { 438 | checkErr(wf.Cache.StoreJSON(key, shelf)) 439 | } 440 | log.Printf("[shelves] cached page %d/%d, %d book(s)", page, pageCount, len(books)) 441 | page++ 442 | } 443 | 444 | checkErr(wf.Cache.StoreJSON(key, shelf)) 445 | } 446 | 447 | // cache list of user's shelves 448 | func runCacheShelves() { 449 | wf.Configure(aw.TextErrors(true)) 450 | if !opts.Authorised() { 451 | return 452 | } 453 | 454 | var ( 455 | page = 1 456 | pageCount int 457 | shelves, res []gr.Shelf 458 | meta gr.PageData 459 | last time.Time 460 | writePartial = !wf.Cache.Exists(shelvesKey) 461 | err error 462 | ) 463 | 464 | log.Println("[shelves] fetching users shelves ...") 465 | 466 | for { 467 | if pageCount > 0 && page > pageCount { 468 | break 469 | } 470 | 471 | if !last.IsZero() && time.Since(last) < time.Second { 472 | delay := time.Second - time.Since(last) 473 | log.Printf("[shelves] pausing %v till next request ...", delay) 474 | time.Sleep(delay) 475 | } 476 | last = time.Now() 477 | 478 | res, meta, err = api.UserShelves(opts.UserID, page) 479 | checkErr(err) 480 | 481 | if pageCount == 0 { 482 | pageCount = meta.Total / 15 483 | if meta.Total%15 > 0 { 484 | pageCount++ 485 | } 486 | } 487 | 488 | shelves = append(shelves, res...) 489 | if writePartial { 490 | checkErr(wf.Cache.StoreJSON(shelvesKey, shelves)) 491 | } 492 | log.Printf("[shelves] cached page %d/%d, %d shelves", page, pageCount, len(shelves)) 493 | page++ 494 | } 495 | 496 | checkErr(wf.Cache.StoreJSON(shelvesKey, shelves)) 497 | } 498 | 499 | // bySelection sorts shelves by selection status 500 | type bySelection []gr.Shelf 501 | 502 | // Implement sort.Interface 503 | func (s bySelection) Len() int { return len(s) } 504 | func (s bySelection) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 505 | func (s bySelection) Less(i, j int) bool { 506 | a, b := s[i], s[j] 507 | if b.Selected && !a.Selected { 508 | return true 509 | } 510 | return false 511 | } 512 | func (s bySelection) Keywords(i int) string { return s[i].Title() } 513 | 514 | func filterShelves(shelves []gr.Shelf, query string) []gr.Shelf { 515 | groups := make([][]gr.Shelf, 2) 516 | for i, r := range fuzzy.New(bySelection(shelves)).Sort(query) { 517 | if !r.Match { 518 | continue 519 | } 520 | s := shelves[i] 521 | var n int 522 | if s.Selected { 523 | n = 1 524 | } 525 | groups[n] = append(groups[n], s) 526 | } 527 | var matches []gr.Shelf 528 | for _, g := range groups { 529 | matches = append(matches, g...) 530 | } 531 | return matches 532 | } 533 | 534 | func selectShelves(shelves []gr.Shelf) (names []string) { 535 | for i, s := range shelves { 536 | s.Selected = wf.Config.GetBool("shelf_" + s.Name) 537 | log.Printf("[shelves] name=%q, selected=%v", s.Name, s.Selected) 538 | shelves[i] = s 539 | if s.Selected { 540 | names = append(names, s.Name) 541 | } 542 | } 543 | sort.Stable(bySelection(shelves)) 544 | return 545 | } 546 | -------------------------------------------------------------------------------- /pkg/gr/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-14 4 | 5 | package gr 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "os/exec" 11 | "time" 12 | 13 | "github.com/deanishe/awgo/util" 14 | "github.com/mrjones/oauth" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | const ( 19 | authServerURL = "localhost:53233" 20 | 21 | oauthTokenURL = "https://www.goodreads.com/oauth/request_token" 22 | oauthAuthoriseURL = "https://www.goodreads.com/oauth/authorize" 23 | oauthAccessURL = "https://www.goodreads.com/oauth/access_token" 24 | ) 25 | 26 | // retrieve OAuth token from disk or Goodreads API 27 | func (c *Client) getAuthToken() (*oauth.AccessToken, error) { 28 | if c.token != nil { 29 | return c.token, nil 30 | } 31 | 32 | var err error 33 | if c.token, err = c.authoriseWorkflow(); err != nil { 34 | return nil, errors.Wrap(err, "get OAuth token") 35 | } 36 | 37 | if err := c.Store.Save(c.token.Token, c.token.Secret); err != nil { 38 | return nil, errors.Wrap(err, "save OAuth token to store") 39 | } 40 | 41 | // initialise API client 42 | if c.apiClient, err = c.oauthConsumer().MakeHttpClient(c.token); err != nil { 43 | return nil, errors.Wrap(err, "make HTTP client") 44 | } 45 | 46 | return c.token, err 47 | } 48 | 49 | func (c *Client) oauthConsumer() *oauth.Consumer { 50 | consumer := oauth.NewConsumer(c.APIKey, c.APISecret, oauth.ServiceProvider{ 51 | RequestTokenUrl: oauthTokenURL, 52 | AuthorizeTokenUrl: oauthAuthoriseURL, 53 | AccessTokenUrl: oauthAccessURL, 54 | }) 55 | consumer.AdditionalAuthorizationUrlParams["name"] = "goodreads" 56 | return consumer 57 | } 58 | 59 | // Authorise initiates authorisation flow. 60 | func (c *Client) Authorise() error { 61 | _, err := c.getAuthToken() 62 | return err 63 | } 64 | 65 | // execute OAuth authorisation flow 66 | func (c *Client) authoriseWorkflow() (*oauth.AccessToken, error) { 67 | type response struct { 68 | token *oauth.AccessToken 69 | err error 70 | } 71 | 72 | var ( 73 | consumer = c.oauthConsumer() 74 | ch = make(chan response) 75 | mux = http.NewServeMux() 76 | srv = &http.Server{ 77 | Addr: authServerURL, 78 | ReadTimeout: time.Second * 10, 79 | WriteTimeout: time.Second * 5, 80 | Handler: mux, 81 | } 82 | tokens = map[string]*oauth.RequestToken{} 83 | rtoken *oauth.RequestToken 84 | reqURL string 85 | err error 86 | ) 87 | 88 | rtoken, reqURL, err = consumer.GetRequestTokenAndUrl("http://" + authServerURL + "/") 89 | if err != nil { 90 | return nil, errors.Wrap(err, "get OAuth request token") 91 | } 92 | tokens[rtoken.Token] = rtoken 93 | 94 | if _, err := util.RunCmd(exec.Command("/usr/bin/open", reqURL)); err != nil { 95 | return nil, errors.Wrap(err, "open OAuth endpoint") 96 | } 97 | 98 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 99 | var ( 100 | values = r.URL.Query() 101 | verificationCode = values.Get("oauth_verifier") 102 | key = values.Get("oauth_token") 103 | token *oauth.AccessToken 104 | ) 105 | 106 | rtoken := tokens[key] 107 | if rtoken == nil { 108 | ch <- response{err: errors.New("no request token")} 109 | return 110 | } 111 | 112 | c.Log.Print("[oauth] authorising request token ...") 113 | token, err = consumer.AuthorizeToken(rtoken, verificationCode) 114 | if err != nil { 115 | return 116 | } 117 | 118 | c.Log.Printf("OAuth AccessToken: %#v", token) 119 | w.WriteHeader(http.StatusOK) 120 | w.Write([]byte(`OK`)) 121 | ch <- response{token: token} 122 | }) 123 | 124 | // start server 125 | go func() { 126 | c.Log.Printf("[oauth] starting local webserver on %s ...", authServerURL) 127 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 128 | ch <- response{err: err} 129 | } 130 | }() 131 | 132 | // automatically close server after 3 minutes 133 | timeout := time.AfterFunc(time.Minute*3, func() { 134 | c.Log.Print("[oauth] automatically stopping server after timeout") 135 | if err := srv.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed { 136 | c.Log.Printf("[oauth] shutdown: %v", err) 137 | ch <- response{err: err} 138 | return 139 | } 140 | ch <- response{err: errors.New("OAuth server timeout exceeded")} 141 | }) 142 | 143 | r := <-ch 144 | timeout.Stop() 145 | 146 | if err := srv.Shutdown(context.Background()); err != nil { 147 | if err != http.ErrServerClosed { 148 | return nil, errors.Wrap(err, "OAuth server") 149 | } 150 | } 151 | 152 | return r.token, r.err 153 | } 154 | 155 | // AuthedClient returns an HTTP client that has Goodreads OAuth tokens. 156 | func (c *Client) AuthedClient() (*http.Client, error) { 157 | if c.apiClient != nil { 158 | return c.apiClient, nil 159 | } 160 | 161 | token, err := c.getAuthToken() 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | c.apiClient, err = c.oauthConsumer().MakeHttpClient(token) 167 | if err != nil { 168 | return nil, errors.Wrap(err, "make HTTP client") 169 | } 170 | 171 | return c.apiClient, nil 172 | } 173 | -------------------------------------------------------------------------------- /pkg/gr/book.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package gr 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "log" 10 | "net/url" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/fxtlabs/date" 17 | "github.com/pkg/errors" 18 | ) 19 | 20 | const ( 21 | apiURL = "https://www.goodreads.com/search/index.xml?q=%s" 22 | authorURL = "https://www.goodreads.com/author/list.xml?id=%d&page=%d" 23 | bookURL = "https://www.goodreads.com/book/show/%d.xml?key=%s" 24 | ) 25 | 26 | var ( 27 | errEmptyQuery = errors.New("empty query") 28 | ) 29 | 30 | // Book is an entry from an RSS or API feed. 31 | // The two types of feeds contain different information, so not 32 | // every field is always set. 33 | type Book struct { 34 | ID int64 35 | WorkID int64 // not in search results 36 | ISBN string // not in search results 37 | ISBN13 string // not in search results 38 | 39 | Title string 40 | TitleNoSeries string 41 | Series Series 42 | Author Author 43 | PubDate date.Date 44 | Rating float64 // average rating or user rating (RSS feeds) 45 | Description string // HTML, not in search results 46 | 47 | URL string // Book's page on goodreads.com 48 | ImageURL string // URL of cover image 49 | } 50 | 51 | // HasSeries returns true if Book belongs to a series. 52 | func (b Book) HasSeries() bool { return b.Series.Title != "" } 53 | 54 | // DescriptionText return Book description as plaintext. 55 | func (b Book) DescriptionText() string { return HTML2Text(b.Description) } 56 | 57 | // DescriptionMarkdown return Book description as Markdown. 58 | func (b Book) DescriptionMarkdown() string { return HTML2Markdown(b.Description) } 59 | 60 | // String implements Stringer. 61 | func (b Book) String() string { 62 | return fmt.Sprintf(`Book{ID: %d, Title: %q, Series: %q, Author: %q}`, b.ID, b.TitleNoSeries, b.Series, b.Author) 63 | } 64 | 65 | // Data returns template data. 66 | func (b Book) Data() map[string]string { 67 | data := map[string]string{ 68 | "TITLE": b.Title, 69 | "TITLE_NO_SERIES": b.TitleNoSeries, 70 | "SERIES": b.Series.Title, 71 | "SERIES_ID": fmt.Sprintf("%d", b.Series.ID), 72 | "BOOK_ID": fmt.Sprintf("%d", b.ID), 73 | "ISBN": b.ISBN, 74 | "WORK_ID": fmt.Sprintf("%d", b.WorkID), 75 | "DESCRIPTION": b.DescriptionText(), 76 | "DESCRIPTION_HTML": b.Description, 77 | "DESCRIPTION_MARKDOWN": b.DescriptionMarkdown(), 78 | "AUTHOR": b.Author.Name, 79 | "AUTHOR_ID": fmt.Sprintf("%d", b.Author.ID), 80 | "AUTHOR_URL": b.Author.URL, 81 | "YEAR": b.PubDate.Format("2006"), 82 | "RATING": fmt.Sprintf("%f", b.Rating), 83 | "BOOK_URL": b.URL, 84 | "IMAGE_URL": b.ImageURL, 85 | } 86 | 87 | // remove empty/unset variabels 88 | out := map[string]string{} 89 | for k, v := range data { 90 | if v == "" || v == "0" { 91 | continue 92 | } 93 | out[k] = v 94 | } 95 | return out 96 | } 97 | 98 | // Author is the author of a book. 99 | type Author struct { 100 | ID int64 `xml:"id"` // not available in feeds 101 | Name string `xml:"name"` 102 | URL string // not available in feeds 103 | } 104 | 105 | // String returns author's name. 106 | func (a Author) String() string { return a.Name } 107 | 108 | // Search API for books. 109 | func (c *Client) Search(query string) (books []Book, err error) { 110 | var ( 111 | u = urlForQuery(url.QueryEscape(query)) 112 | data []byte 113 | ) 114 | if u == "" { 115 | err = errEmptyQuery 116 | return 117 | } 118 | if data, err = c.apiRequest(u); err != nil { 119 | return 120 | } 121 | 122 | return unmarshalSearchResults(data) 123 | } 124 | 125 | // BookDetails fetches the full details of a book. 126 | func (c *Client) BookDetails(id int64) (Book, error) { 127 | var ( 128 | u = fmt.Sprintf(bookURL, id, c.APIKey) 129 | data []byte 130 | err error 131 | ) 132 | 133 | if data, err = c.apiRequest(u); err != nil { 134 | return Book{}, errors.Wrap(err, "fetch book details") 135 | } 136 | 137 | return unmarshalBookDetails(data) 138 | } 139 | 140 | func unmarshalBookDetails(data []byte) (Book, error) { 141 | v := struct { 142 | Book struct { 143 | ID int64 `xml:"id"` 144 | WorkID int64 `xml:"work>id"` 145 | ISBN string `xml:"isbn"` 146 | ISBN13 string `xml:"isbn13"` 147 | 148 | Title string `xml:"title"` 149 | TitleNoSeries string `xml:"work>original_title"` 150 | SeriesName string `xml:"series_works>series_work>series>title"` 151 | SeriesPosition float64 `xml:"series_works>series_work>user_position"` 152 | SeriesID int64 `xml:"series_works>series_work>series>id"` 153 | Authors []Author `xml:"authors>author"` 154 | Year int `xml:"work>original_publication_year"` 155 | Month int `xml:"work>original_publication_month"` 156 | Day int `xml:"work>original_publication_day"` 157 | Rating float64 `xml:"average_rating"` 158 | Description string `xml:"description"` 159 | 160 | ImageURL string `xml:"image_url"` 161 | } `xml:"book"` 162 | }{} 163 | 164 | if err := xml.Unmarshal(data, &v); err != nil { 165 | log.Printf("[book] raw=%s", string(data)) 166 | return Book{}, err 167 | } 168 | 169 | title := v.Book.TitleNoSeries 170 | if title == "" { 171 | title, _ = parseTitle(v.Book.Title) 172 | } 173 | 174 | b := Book{ 175 | ID: v.Book.ID, 176 | WorkID: v.Book.WorkID, 177 | ISBN: v.Book.ISBN, 178 | ISBN13: v.Book.ISBN13, 179 | Title: v.Book.Title, 180 | TitleNoSeries: title, 181 | Series: Series{ 182 | Title: strings.TrimSpace(v.Book.SeriesName), 183 | ID: v.Book.SeriesID, 184 | Position: v.Book.SeriesPosition, 185 | }, 186 | Rating: v.Book.Rating, 187 | URL: fmt.Sprintf("https://www.goodreads.com/book/show/%d", v.Book.ID), 188 | Description: v.Book.Description, 189 | ImageURL: v.Book.ImageURL, 190 | } 191 | 192 | if len(v.Book.Authors) > 0 { 193 | b.Author = v.Book.Authors[0] 194 | b.Author.URL = fmt.Sprintf("https://www.goodreads.com/author/show/%d", b.Author.ID) 195 | } 196 | 197 | if v.Book.Month == 0 { 198 | v.Book.Month = 1 199 | } 200 | if v.Book.Day == 0 { 201 | v.Book.Day = 1 202 | } 203 | if v.Book.Year != 0 { 204 | b.PubDate = date.New(v.Book.Year, time.Month(v.Book.Month), v.Book.Day) 205 | } 206 | 207 | return b, nil 208 | } 209 | 210 | func urlForQuery(query string) string { 211 | if query == "" { 212 | return "" 213 | } 214 | return fmt.Sprintf(apiURL, query) 215 | } 216 | 217 | func unmarshalSearchResults(data []byte) (books []Book, err error) { 218 | v := struct { 219 | Works []struct { 220 | ID int64 `xml:"best_book>id"` 221 | WorkID int64 `xml:"id"` 222 | Title string `xml:"best_book>title"` 223 | Author Author `xml:"best_book>author"` 224 | Year int `xml:"original_publication_year"` 225 | Month int `xml:"original_publication_month"` 226 | Day int `xml:"original_publication_day"` 227 | 228 | Rating float64 `xml:"average_rating"` 229 | ImageURL string `xml:"best_book>image_url"` 230 | } `xml:"search>results>work"` 231 | }{} 232 | if err = xml.Unmarshal(data, &v); err != nil { 233 | return 234 | } 235 | 236 | for _, r := range v.Works { 237 | title, series := parseTitle(r.Title) 238 | b := Book{ 239 | ID: r.ID, 240 | WorkID: r.WorkID, 241 | Title: r.Title, 242 | TitleNoSeries: title, 243 | Series: series, 244 | Author: r.Author, 245 | Rating: r.Rating, 246 | URL: fmt.Sprintf("https://www.goodreads.com/book/show/%d", r.ID), 247 | ImageURL: r.ImageURL, 248 | } 249 | if r.Month == 0 { 250 | r.Month = 1 251 | } 252 | if r.Day == 0 { 253 | r.Day = 1 254 | } 255 | 256 | if r.Year != 0 { 257 | b.PubDate = date.New(r.Year, time.Month(r.Month), r.Day) 258 | } 259 | b.Author.URL = fmt.Sprintf("https://www.goodreads.com/author/show/%d", b.Author.ID) 260 | books = append(books, b) 261 | } 262 | 263 | return 264 | } 265 | 266 | // PageData contains pagination data. 267 | type PageData struct { 268 | Start int 269 | End int 270 | Total int 271 | } 272 | 273 | func (pd PageData) String() string { 274 | return fmt.Sprintf("pageData{Start: %d, End: %d, Total: %d}", pd.Start, pd.End, pd.Total) 275 | } 276 | 277 | // AuthorBooks returns books for specified author. 278 | func (c *Client) AuthorBooks(id int64, page int) (books []Book, meta PageData, err error) { 279 | if page == 0 { 280 | page = 1 281 | } 282 | var ( 283 | u = urlForAuthor(id, page) 284 | data []byte 285 | ) 286 | if u == "" { 287 | err = errEmptyQuery 288 | return 289 | } 290 | 291 | if data, err = c.apiRequest(u); err != nil { 292 | return 293 | } 294 | 295 | return unmarshalAuthorBooks(data) 296 | } 297 | 298 | func urlForAuthor(id int64, page int) string { 299 | if page == 0 { 300 | page = 1 301 | } 302 | return fmt.Sprintf(authorURL, id, page) 303 | } 304 | 305 | func unmarshalAuthorBooks(data []byte) (books []Book, meta PageData, err error) { 306 | v := struct { 307 | List struct { 308 | Start int `xml:"start,attr"` 309 | End int `xml:"end,attr"` 310 | Total int `xml:"total,attr"` 311 | 312 | Books []struct { 313 | ID int64 `xml:"id"` 314 | ISBN string `xml:"isbn"` 315 | ISBN13 string `xml:"isbn13"` 316 | Title string `xml:"title"` 317 | TitleNoSeries string `xml:"title_without_series"` 318 | Description string `xml:"description"` 319 | Year int `xml:"publication_year"` 320 | Month int `xml:"publication_month"` 321 | Day int `xml:"publication_day"` 322 | 323 | Authors []Author `xml:"authors>author"` 324 | 325 | Rating float64 `xml:"average_rating"` 326 | ImageURL string `xml:"image_url"` 327 | } `xml:"book"` 328 | } `xml:"author>books"` 329 | }{} 330 | if err = xml.Unmarshal(data, &v); err != nil { 331 | return 332 | } 333 | 334 | meta.Start = v.List.Start 335 | meta.End = v.List.End 336 | meta.Total = v.List.Total 337 | 338 | for _, r := range v.List.Books { 339 | _, series := parseTitle(r.Title) 340 | b := Book{ 341 | ID: r.ID, 342 | ISBN: r.ISBN, 343 | ISBN13: r.ISBN13, 344 | Title: r.Title, 345 | Series: series, 346 | Description: r.Description, 347 | TitleNoSeries: r.TitleNoSeries, 348 | Rating: r.Rating, 349 | URL: fmt.Sprintf("https://www.goodreads.com/book/show/%d", r.ID), 350 | ImageURL: r.ImageURL, 351 | } 352 | 353 | if len(r.Authors) > 0 { 354 | b.Author = r.Authors[0] 355 | b.Author.URL = fmt.Sprintf("https://www.goodreads.com/author/show/%d", b.Author.ID) 356 | } 357 | 358 | if r.Month == 0 { 359 | r.Month = 1 360 | } 361 | if r.Day == 0 { 362 | r.Day = 1 363 | } 364 | 365 | if r.Year != 0 { 366 | b.PubDate = date.New(r.Year, time.Month(r.Month), r.Day) 367 | } 368 | books = append(books, b) 369 | } 370 | 371 | return 372 | } 373 | 374 | // regexes to match book titles with embedded series info 375 | var ( 376 | seriesRegexes = []*regexp.Regexp{ 377 | regexp.MustCompile(`^(.+)\s\((.+?),?\s+#([0-9.]+)\)$`), 378 | regexp.MustCompile(`^(.+)\s\((.+?) Series Book ([0-9.]+)\)$`), 379 | } 380 | ) 381 | 382 | // extract title & series from book title with embedded series info. 383 | func parseTitle(s string) (title string, series Series) { 384 | for _, rx := range seriesRegexes { 385 | values := rx.FindAllStringSubmatch(s, -1) 386 | if len(values) == 1 { 387 | title = strings.TrimSpace(values[0][1]) 388 | series.Title = strings.TrimSpace(values[0][2]) 389 | series.Position, _ = strconv.ParseFloat(values[0][3], 64) 390 | return 391 | } 392 | } 393 | 394 | title = s 395 | return 396 | } 397 | -------------------------------------------------------------------------------- /pkg/gr/feed.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package gr 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "net/url" 10 | "path" 11 | "strings" 12 | 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // Base URL of RSS feeds. 17 | const rssURL = "https://www.goodreads.com/review/list_rss/" 18 | 19 | // Feed is a Goodreads RSS feed. It's only used to retrieve cover images 20 | // (a lot of covers are missing from API responses), so the Books contained 21 | // by a Feed only have ID and ImageURL set. 22 | type Feed struct { 23 | Name string 24 | Books []Book 25 | } 26 | 27 | // Extract user ID and user feed key from a feed URL. Currently unused. 28 | func parseFeedURL(URL string) (userID, feedKey string, err error) { 29 | var u *url.URL 30 | if u, err = url.Parse(URL); err != nil { 31 | return 32 | } 33 | 34 | userID = path.Base(u.Path) 35 | feedKey = u.Query().Get("key") 36 | return 37 | } 38 | 39 | // FetchFeed retrieves and parses a Goodreads RSS feed. 40 | func (c *Client) FetchFeed(userID int64, shelf string) (Feed, error) { 41 | var ( 42 | data []byte 43 | err error 44 | ) 45 | 46 | u, _ := url.Parse(fmt.Sprintf("%s%d", rssURL, userID)) 47 | v := u.Query() 48 | v.Set("shelf", shelf) 49 | u.RawQuery = v.Encode() 50 | 51 | if data, err = c.httpGet(u.String()); err != nil { 52 | return Feed{}, errors.Wrap(err, "retrive feed") 53 | } 54 | 55 | return unmarshalFeed(data) 56 | } 57 | 58 | // Parse RSS feed data. 59 | func unmarshalFeed(data []byte) (Feed, error) { 60 | var ( 61 | feed Feed 62 | err error 63 | ) 64 | v := struct { 65 | Name string `xml:"channel>title"` 66 | Items []struct { 67 | ID int64 `xml:"book_id"` 68 | ImageURL string `xml:"book_image_url"` 69 | ImageURLMedium string `xml:"book_medium_image_url"` 70 | ImageURLLarge string `xml:"book_large_image_url"` 71 | } `xml:"channel>item"` 72 | }{} 73 | 74 | if err = xml.Unmarshal(data, &v); err != nil { 75 | return Feed{}, errors.Wrap(err, "unmarshal feed") 76 | } 77 | 78 | feed.Name = parseFeedTitle(v.Name) 79 | 80 | for _, r := range v.Items { 81 | b := Book{ 82 | ID: r.ID, 83 | ImageURL: r.ImageURL, 84 | } 85 | 86 | if r.ImageURLLarge != "" { 87 | b.ImageURL = r.ImageURLLarge 88 | } else if r.ImageURLMedium != "" { 89 | b.ImageURL = r.ImageURLMedium 90 | } 91 | 92 | feed.Books = append(feed.Books, b) 93 | } 94 | 95 | return feed, nil 96 | } 97 | 98 | func parseFeedTitle(s string) string { 99 | i := strings.Index(s, "bookshelf: ") 100 | if i < 0 { 101 | return s 102 | } 103 | return s[i+11:] 104 | } 105 | -------------------------------------------------------------------------------- /pkg/gr/feed_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package gr 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestParseFeed(t *testing.T) { 14 | t.Parallel() 15 | tests := []struct { 16 | name string 17 | books []Book 18 | }{ 19 | {"to-read", expectedToRead}, 20 | {"fantasy", expectedFantasy}, 21 | } 22 | 23 | for _, td := range tests { 24 | td := td 25 | t.Run(td.name, func(t *testing.T) { 26 | t.Parallel() 27 | feed, err := unmarshalFeed(readFile(td.name+".rss", t)) 28 | require.Nil(t, err, "unmarshal feed %s.xml", td.name) 29 | assert.Equal(t, td.name, feed.Name) 30 | assert.Equal(t, td.books, feed.Books) 31 | }) 32 | } 33 | } 34 | 35 | func TestParseFeedURL(t *testing.T) { 36 | t.Parallel() 37 | 38 | tests := []struct { 39 | URL, userID, feedKey string 40 | }{ 41 | {"https://www.goodreads.com/review/list_rss/123456?key=7yg3Z3aVn-TWgH8Q_GGZDx&shelf=%23ALL%23", "123456", "7yg3Z3aVn-TWgH8Q_GGZDx"}, 42 | {"https://www.goodreads.com/review/list_rss/7220456?key=eHfwY8fE_unud_BMzPT-uj2&shelf=to-read", "7220456", "eHfwY8fE_unud_BMzPT-uj2"}, 43 | } 44 | 45 | for _, td := range tests { 46 | td := td 47 | t.Run(td.URL, func(t *testing.T) { 48 | t.Parallel() 49 | uid, key, err := parseFeedURL(td.URL) 50 | assert.Nil(t, err, "parse feed URL %q", td.URL) 51 | assert.Equal(t, td.userID, uid, "unexpected user_id") 52 | assert.Equal(t, td.feedKey, key, "unexpected feed_key") 53 | }) 54 | } 55 | } 56 | 57 | var ( 58 | expectedToRead = []Book{ 59 | { 60 | ID: 109502, 61 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1410138674l/109502.jpg", 62 | }, 63 | { 64 | ID: 22477307, 65 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1418771663l/22477307.jpg", 66 | }, 67 | { 68 | ID: 25135194, 69 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1432827094l/25135194._SY475_.jpg", 70 | }, 71 | { 72 | ID: 50740363, 73 | ImageURL: "https://s.gr-assets.com/assets/nophoto/book/111x148-bcc042a9c91a29c1d680899eff700a03.png", 74 | }, 75 | { 76 | ID: 1268479, 77 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1240256182l/1268479.jpg", 78 | }, 79 | { 80 | ID: 42592353, 81 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1562549322l/42592353._SY475_.jpg", 82 | }, 83 | } 84 | 85 | expectedFantasy = []Book{ 86 | { 87 | ID: 32337902, 88 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1481987017l/32337902.jpg", 89 | }, 90 | { 91 | ID: 16096968, 92 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1487946539l/16096968.jpg", 93 | }, 94 | { 95 | ID: 26030742, 96 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1456696097l/26030742._SY475_.jpg", 97 | }, 98 | { 99 | ID: 37769892, 100 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1514589702l/37769892._SY475_.jpg", 101 | }, 102 | } 103 | ) 104 | -------------------------------------------------------------------------------- /pkg/gr/goodreads.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | // Package gr is a partial implementation of the Goodreads API. 6 | package gr 7 | 8 | import ( 9 | "net/http" 10 | "time" 11 | 12 | "github.com/mrjones/oauth" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | // Workflow version. set via LD_FLAGS. 17 | var version = "" 18 | 19 | // TokenStore loads and saves the access tokens. Load() should return empty 20 | // strings (not an error) if no credentials are stored. 21 | type TokenStore interface { 22 | Save(token, secret string) error 23 | Load() (token, secret string, err error) 24 | } 25 | 26 | // Logger is the logging interface used by Client. 27 | type Logger interface { 28 | Printf(string, ...interface{}) 29 | Print(...interface{}) 30 | } 31 | 32 | type nullLogger struct{} 33 | 34 | func (l nullLogger) Printf(format string, args ...interface{}) {} 35 | func (l nullLogger) Print(args ...interface{}) {} 36 | 37 | var _ Logger = nullLogger{} 38 | 39 | // Client implements a subset of the Goodreads API. 40 | type Client struct { 41 | APIKey string // Goodreads API key 42 | APISecret string // Goodreads API secret 43 | Store TokenStore // Persistent store for access tokens 44 | Log Logger // Library logger 45 | 46 | token *oauth.AccessToken 47 | apiClient *http.Client 48 | lastRequest time.Time 49 | } 50 | 51 | // New creates a new Client. It calls store.Load() and passes through any error it returns. 52 | func New(apiKey, apiSecret string, store TokenStore) (*Client, error) { 53 | c := &Client{ 54 | APIKey: apiKey, 55 | APISecret: apiSecret, 56 | Store: store, 57 | Log: nullLogger{}, 58 | } 59 | token, secret, err := store.Load() 60 | if err != nil { 61 | return nil, errors.Wrap(err, "load OAuth credentials from store") 62 | } 63 | 64 | if token != "" && secret != "" { 65 | c.token = &oauth.AccessToken{Token: token, Secret: secret} 66 | } 67 | 68 | return c, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/gr/http.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package gr 6 | 7 | import ( 8 | "fmt" 9 | "io/ioutil" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "strings" 14 | "time" 15 | 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | var ( 20 | userAgent string 21 | httpClient = &http.Client{ 22 | Transport: &http.Transport{ 23 | Dial: (&net.Dialer{ 24 | Timeout: 60 * time.Second, 25 | KeepAlive: 60 * time.Second, 26 | }).Dial, 27 | TLSHandshakeTimeout: 30 * time.Second, 28 | ResponseHeaderTimeout: 30 * time.Second, 29 | ExpectContinueTimeout: 10 * time.Second, 30 | }, 31 | } 32 | ) 33 | 34 | func init() { 35 | userAgent = "Alfred Booksearch Workflow " + version + " (+https://github.com/deanishe/alfred-booksearch)" 36 | } 37 | 38 | // retrieve URL with standard HTTP client. 39 | func (c *Client) httpGet(URL string) ([]byte, error) { 40 | return c.httpRequest(URL, httpClient) 41 | } 42 | 43 | // retrieve URL with authorised API client. 44 | func (c *Client) apiRequest(URL string, method ...string) ([]byte, error) { 45 | var ( 46 | client *http.Client 47 | data []byte 48 | err error 49 | ) 50 | if client, err = c.AuthedClient(); err != nil { 51 | return nil, errors.Wrap(err, "get API client") 52 | } 53 | 54 | d := time.Since(c.lastRequest) 55 | if d < time.Second { 56 | d = time.Second - d 57 | c.Log.Printf("[api] pausing %v until next request ...", d) 58 | time.Sleep(d) 59 | } 60 | 61 | if data, err = c.httpRequest(URL, client, method...); err != nil { 62 | return nil, err 63 | } 64 | c.lastRequest = time.Now() 65 | 66 | return data, nil 67 | } 68 | 69 | // retrieve URL with given client. 70 | func (c *Client) httpRequest(URL string, client *http.Client, method ...string) ([]byte, error) { 71 | var ( 72 | meth = "GET" 73 | req *http.Request 74 | r *http.Response 75 | data []byte 76 | err error 77 | ) 78 | if len(method) > 0 { 79 | meth = method[0] 80 | } 81 | c.Log.Printf("[http] retrieving %q ...", cleanURL(URL)) 82 | 83 | if req, err = http.NewRequest(strings.ToUpper(meth), URL, nil); err != nil { 84 | return nil, errors.Wrap(err, "build HTTP request") 85 | } 86 | req.Header.Set("User-Agent", userAgent) 87 | 88 | if r, err = client.Do(req); err != nil { 89 | return nil, errors.Wrap(err, "retrieve URL") 90 | } 91 | defer r.Body.Close() 92 | c.Log.Printf("[%d] %s", r.StatusCode, cleanURL(URL)) 93 | 94 | if r.StatusCode > 299 { 95 | return nil, errors.Wrap(fmt.Errorf("%s: %s", URL, r.Status), "retrieve URL") 96 | } 97 | 98 | if data, err = ioutil.ReadAll(r.Body); err != nil { 99 | return nil, errors.Wrap(err, "read HTTP response") 100 | } 101 | 102 | return data, nil 103 | } 104 | 105 | func cleanURL(URL string) string { 106 | if u, err := url.Parse(URL); err == nil { 107 | v := u.Query() 108 | if v.Get("key") != "" { 109 | v.Set("key", "xxx") 110 | u.RawQuery = v.Encode() 111 | return u.String() 112 | } 113 | } 114 | return URL 115 | } 116 | -------------------------------------------------------------------------------- /pkg/gr/series.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-08-08 4 | 5 | package gr 6 | 7 | import ( 8 | "encoding/xml" 9 | "fmt" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/fxtlabs/date" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | const seriesURL = "https://www.goodreads.com/series/%d?format=xml&key=%s" 19 | 20 | // Series is a Goodreads series. 21 | type Series struct { 22 | Title string // series title 23 | Position float64 // position of current book in series 24 | ID int64 // only set in book details 25 | Books []Book // only set by series endpoint 26 | } 27 | 28 | // String returns series name and book position. 29 | func (s Series) String() string { 30 | if s.Title == "" { 31 | return "" 32 | } 33 | return fmt.Sprintf("%s #%.1f", s.Title, s.Position) 34 | } 35 | 36 | // Series fetches the full details of a book. 37 | func (c *Client) Series(id int64) (Series, error) { 38 | var ( 39 | u = fmt.Sprintf(seriesURL, id, c.APIKey) 40 | data []byte 41 | err error 42 | ) 43 | 44 | if data, err = c.apiRequest(u); err != nil { 45 | return Series{}, errors.Wrap(err, "fetch series") 46 | } 47 | 48 | return unmarshalSeries(data) 49 | } 50 | 51 | func unmarshalSeries(data []byte) (Series, error) { 52 | v := struct { 53 | Series struct { 54 | ID int64 `xml:"id"` 55 | Title string `xml:"title"` 56 | Description string `xml:"description"` 57 | Works []struct { 58 | WorkID int64 `xml:"work>id"` 59 | BookID int64 `xml:"work>best_book>id"` 60 | Title string `xml:"work>best_book>title"` 61 | TitleNoSeries string `xml:"work>original_title"` 62 | AuthorID int64 `xml:"work>best_book>author>id"` 63 | AuthorName string `xml:"work>best_book>author>name"` 64 | ImageURL string `xml:"work>best_book>image_url"` 65 | Year int `xml:"work>original_publication_year"` 66 | Month int `xml:"work>original_publication_month"` 67 | Day int `xml:"work>original_publication_day"` 68 | Rating float64 `xml:"work>average_rating"` 69 | Position string `xml:"user_position"` 70 | } `xml:"series_works>series_work"` 71 | } `xml:"series"` 72 | }{} 73 | 74 | if err := xml.Unmarshal(data, &v); err != nil { 75 | return Series{}, err 76 | } 77 | series := Series{ 78 | ID: v.Series.ID, 79 | Title: strings.TrimSpace(v.Series.Title), 80 | } 81 | 82 | for _, w := range v.Series.Works { 83 | var pos float64 84 | if f, err := strconv.ParseFloat(w.Position, 64); err == nil { 85 | pos = f 86 | } 87 | b := Book{ 88 | ID: w.BookID, 89 | WorkID: w.WorkID, 90 | Title: strings.TrimSpace(w.Title), 91 | TitleNoSeries: strings.TrimSpace(w.TitleNoSeries), 92 | Series: Series{ID: series.ID, Title: series.Title, Position: pos}, 93 | Author: Author{ID: w.AuthorID, Name: w.AuthorName, URL: fmt.Sprintf("https://www.goodreads.com/author/show/%d", w.AuthorID)}, 94 | Rating: w.Rating, 95 | URL: fmt.Sprintf("https://www.goodreads.com/book/show/%d", w.BookID), 96 | ImageURL: w.ImageURL, 97 | } 98 | 99 | if b.TitleNoSeries == "" { 100 | b.TitleNoSeries, _ = parseTitle(b.Title) 101 | } 102 | 103 | if w.Month == 0 { 104 | w.Month = 1 105 | } 106 | if w.Day == 0 { 107 | w.Day = 1 108 | } 109 | if w.Year != 0 { 110 | b.PubDate = date.New(w.Year, time.Month(w.Month), w.Day) 111 | } 112 | 113 | series.Books = append(series.Books, b) 114 | } 115 | 116 | return series, nil 117 | } 118 | -------------------------------------------------------------------------------- /pkg/gr/series_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-08-08 4 | 5 | package gr 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/fxtlabs/date" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // TestParseSeries parses series XML 16 | func TestParseSeries(t *testing.T) { 17 | t.Parallel() 18 | 19 | series, err := unmarshalSeries(readFile("alex_verus.xml", t)) 20 | if err != nil { 21 | t.Fatalf("unmarshal: %v", err) 22 | } 23 | 24 | assert.Equal(t, expectedVerus, series) 25 | } 26 | 27 | var expectedVerus = Series{ 28 | Title: "Alex Verus", 29 | ID: 71196, 30 | Books: []Book{ 31 | { 32 | ID: 11737387, 33 | WorkID: 16686573, 34 | Title: "Fated (Alex Verus, #1)", 35 | TitleNoSeries: "Fated", 36 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 1}, 37 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 38 | PubDate: date.New(2012, time.February, 1), 39 | URL: "https://www.goodreads.com/book/show/11737387", 40 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1330906653l/11737387._SX98_.jpg", 41 | }, 42 | { 43 | ID: 13274082, 44 | WorkID: 18478073, 45 | Title: "Cursed (Alex Verus, #2)", 46 | TitleNoSeries: "Cursed", 47 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 2}, 48 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 49 | PubDate: date.New(2012, time.May, 29), 50 | URL: "https://www.goodreads.com/book/show/13274082", 51 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1330971845l/13274082._SX98_.jpg", 52 | }, 53 | { 54 | ID: 13542616, 55 | WorkID: 19062926, 56 | Title: "Taken (Alex Verus, #3)", 57 | TitleNoSeries: "Taken", 58 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 3}, 59 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 60 | PubDate: date.New(2012, time.August, 28), 61 | URL: "https://www.goodreads.com/book/show/13542616", 62 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1346617379l/13542616._SX98_.jpg", 63 | }, 64 | { 65 | ID: 16072988, 66 | WorkID: 21867224, 67 | Title: "Chosen (Alex Verus, #4)", 68 | TitleNoSeries: "Chosen", 69 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 4}, 70 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 71 | PubDate: date.New(2013, time.August, 27), 72 | URL: "https://www.goodreads.com/book/show/16072988", 73 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1365983616l/16072988._SX98_.jpg", 74 | }, 75 | { 76 | ID: 18599601, 77 | WorkID: 26365716, 78 | Title: "Hidden (Alex Verus, #5)", 79 | TitleNoSeries: "Hidden", 80 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 5}, 81 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 82 | PubDate: date.New(2014, time.September, 2), 83 | URL: "https://www.goodreads.com/book/show/18599601", 84 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1386933935l/18599601._SX98_.jpg", 85 | }, 86 | { 87 | ID: 23236738, 88 | WorkID: 42780952, 89 | Title: "Veiled (Alex Verus, #6)", 90 | TitleNoSeries: "Veiled", 91 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 6}, 92 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 93 | PubDate: date.New(2015, time.August, 4), 94 | URL: "https://www.goodreads.com/book/show/23236738", 95 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1421862439l/23236738._SX98_.jpg", 96 | }, 97 | { 98 | ID: 23236743, 99 | WorkID: 42780954, 100 | Title: "Burned (Alex Verus, #7)", 101 | TitleNoSeries: "Burned", 102 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 7}, 103 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 104 | PubDate: date.New(2016, time.April, 5), 105 | URL: "https://www.goodreads.com/book/show/23236743", 106 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1453058973l/23236743._SX98_.jpg", 107 | }, 108 | { 109 | ID: 29865319, 110 | WorkID: 76176665, 111 | Title: "Bound (Alex Verus, #8)", 112 | TitleNoSeries: "Bound", 113 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 8}, 114 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 115 | PubDate: date.New(2017, time.April, 4), 116 | URL: "https://www.goodreads.com/book/show/29865319", 117 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1474725377l/29865319._SX98_.jpg", 118 | }, 119 | { 120 | ID: 36068567, 121 | WorkID: 57651776, 122 | Title: "Marked (Alex Verus, #9)", 123 | TitleNoSeries: "Marked", 124 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 9}, 125 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 126 | PubDate: date.New(2018, time.July, 3), 127 | URL: "https://www.goodreads.com/book/show/36068567", 128 | ImageURL: "https://s.gr-assets.com/assets/nophoto/book/111x148-bcc042a9c91a29c1d680899eff700a03.png", 129 | }, 130 | { 131 | ID: 43670629, 132 | WorkID: 63342276, 133 | Title: "Fallen (Alex Verus, #10)", 134 | TitleNoSeries: "Fallen", 135 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 10}, 136 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 137 | PubDate: date.New(2019, time.September, 24), 138 | URL: "https://www.goodreads.com/book/show/43670629", 139 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1554395647l/43670629._SX98_.jpg", 140 | }, 141 | { 142 | ID: 50740363, 143 | WorkID: 75767304, 144 | Title: "Forged (Alex Verus, #11)", 145 | TitleNoSeries: "Forged", 146 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 11}, 147 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 148 | PubDate: date.New(2020, time.November, 24), 149 | URL: "https://www.goodreads.com/book/show/50740363", 150 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1591956617l/50740363._SX98_.jpg", 151 | }, 152 | { 153 | ID: 20980464, 154 | WorkID: 40357662, 155 | Title: "The Alex Verus Novels, Books 1-4", 156 | TitleNoSeries: "The Alex Verus Novels, Books 1-4", 157 | Series: Series{ID: 71196, Title: "Alex Verus", Position: 0}, 158 | Author: Author{ID: 849723, Name: "Benedict Jacka", URL: "https://www.goodreads.com/author/show/849723"}, 159 | PubDate: date.New(2014, time.March, 4), 160 | URL: "https://www.goodreads.com/book/show/20980464", 161 | ImageURL: "https://s.gr-assets.com/assets/nophoto/book/111x148-bcc042a9c91a29c1d680899eff700a03.png", 162 | }, 163 | }, 164 | } 165 | -------------------------------------------------------------------------------- /pkg/gr/shelf.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package gr 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "github.com/fxtlabs/date" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | const ( 18 | shelfURL = "https://www.goodreads.com/review/list.xml?v=2&id=%d&shelf=%s&page=%d&per_page=50&sort=position" 19 | shelvesURL = "https://www.goodreads.com/shelf/list.xml?user_id=%d&page=%d" 20 | shelfAddURL = "https://www.goodreads.com/shelf/add_to_shelf.xml" 21 | shelvesAddURL = "https://www.goodreads.com/shelf/add_books_to_shelves.xml" 22 | ) 23 | 24 | // Shelf is a user's bookshelf/list. 25 | type Shelf struct { 26 | ID int64 27 | Name string 28 | URL string 29 | Size int // number of books on shelf 30 | Books []Book // not populated in shelf list 31 | Selected bool 32 | } 33 | 34 | // String implements Stringer. 35 | func (s Shelf) String() string { 36 | return fmt.Sprintf(`Shelf{ID: %d, Name: %q, Size: %d}`, s.ID, s.Name, s.Size) 37 | } 38 | 39 | // Title is the formatted Shelf name. 40 | func (s Shelf) Title() string { 41 | switch s.Name { 42 | case "read": 43 | return "Read" 44 | case "currently-reading": 45 | return "Currently Reading" 46 | case "to-read": 47 | return "Want to Read" 48 | default: 49 | return s.Name 50 | } 51 | } 52 | 53 | // ShelvesByName sorts a slice of Shelf structs by name. 54 | type ShelvesByName []Shelf 55 | 56 | // Implement sort.Interface 57 | func (s ShelvesByName) Len() int { return len(s) } 58 | func (s ShelvesByName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 59 | func (s ShelvesByName) Less(i, j int) bool { return s[i].Name < s[j].Name } 60 | 61 | // UserShelf returns the books on the specified shelf. 62 | func (c *Client) UserShelf(userID int64, name string, page int) ([]Book, PageData, error) { 63 | if page == 0 { 64 | page = 1 65 | } 66 | 67 | var ( 68 | u = fmt.Sprintf(shelfURL, userID, name, page) 69 | data []byte 70 | err error 71 | ) 72 | 73 | if data, err = c.apiRequest(u); err != nil { 74 | return nil, PageData{}, errors.Wrap(err, "fetch shelf") 75 | } 76 | 77 | return unmarshalShelf(data) 78 | } 79 | 80 | func unmarshalShelf(data []byte) ([]Book, PageData, error) { 81 | var ( 82 | books []Book 83 | meta PageData 84 | err error 85 | ) 86 | v := struct { 87 | List struct { 88 | Start int `xml:"start,attr"` 89 | End int `xml:"end,attr"` 90 | Total int `xml:"total,attr"` 91 | 92 | Books []struct { 93 | ID int64 `xml:"id"` 94 | ISBN string `xml:"isbn"` 95 | ISBN13 string `xml:"isbn13"` 96 | Title string `xml:"title"` 97 | TitleNoSeries string `xml:"title_without_series"` 98 | Description string `xml:"description"` 99 | Year int `xml:"publication_year"` 100 | Month int `xml:"publication_month"` 101 | Day int `xml:"publication_day"` 102 | 103 | Authors []Author `xml:"authors>author"` 104 | 105 | Rating float64 `xml:"average_rating"` 106 | ImageURL string `xml:"image_url"` 107 | } `xml:"review>book"` 108 | XMLName xml.Name `xml:"reviews"` 109 | } 110 | }{} 111 | if err = xml.Unmarshal(data, &v); err != nil { 112 | return nil, PageData{}, errors.Wrap(err, "unmarshal shelf") 113 | } 114 | 115 | meta.Start = v.List.Start 116 | meta.End = v.List.End 117 | meta.Total = v.List.Total 118 | 119 | for _, r := range v.List.Books { 120 | _, series := parseTitle(r.Title) 121 | b := Book{ 122 | ID: r.ID, 123 | ISBN: r.ISBN, 124 | ISBN13: r.ISBN13, 125 | Title: r.Title, 126 | TitleNoSeries: r.TitleNoSeries, 127 | Series: series, 128 | Description: r.Description, 129 | Rating: r.Rating, 130 | URL: fmt.Sprintf("https://www.goodreads.com/book/show/%d", r.ID), 131 | ImageURL: r.ImageURL, 132 | } 133 | 134 | if len(r.Authors) > 0 { 135 | b.Author = r.Authors[0] 136 | b.Author.URL = fmt.Sprintf("https://www.goodreads.com/author/show/%d", b.Author.ID) 137 | } 138 | 139 | if r.Month == 0 { 140 | r.Month = 1 141 | } 142 | if r.Day == 0 { 143 | r.Day = 1 144 | } 145 | 146 | if r.Year != 0 { 147 | b.PubDate = date.New(r.Year, time.Month(r.Month), r.Day) 148 | } 149 | books = append(books, b) 150 | } 151 | 152 | return books, meta, nil 153 | } 154 | 155 | // UserShelves retrieve user's shelves. Only basic shelf info (ID, name, book count) is returned. 156 | func (c *Client) UserShelves(userID int64, page int) (shelves []Shelf, meta PageData, err error) { 157 | if page == 0 { 158 | page = 1 159 | } 160 | 161 | var ( 162 | u = fmt.Sprintf(shelvesURL, userID, page) 163 | data []byte 164 | ) 165 | 166 | if data, err = c.apiRequest(u); err != nil { 167 | return 168 | } 169 | 170 | if shelves, meta, err = unmarshalShelves(data); err == nil { 171 | for i, s := range shelves { 172 | URL := fmt.Sprintf("https://www.goodreads.com/review/list/%d", userID) 173 | u, _ := url.Parse(URL) 174 | v := u.Query() 175 | v.Set("shelf", s.Name) 176 | u.RawQuery = v.Encode() 177 | s.URL = u.String() 178 | shelves[i] = s 179 | } 180 | } 181 | return 182 | } 183 | 184 | func unmarshalShelves(data []byte) ([]Shelf, PageData, error) { 185 | var ( 186 | shelves []Shelf 187 | meta PageData 188 | err error 189 | ) 190 | v := struct { 191 | List struct { 192 | Start int `xml:"start,attr"` 193 | End int `xml:"end,attr"` 194 | Total int `xml:"total,attr"` 195 | Shelves []struct { 196 | ID int64 `xml:"id"` 197 | Name string `xml:"name"` 198 | BookCount int `xml:"book_count"` 199 | } `xml:"user_shelf"` 200 | XMLName xml.Name `xml:"shelves"` 201 | } 202 | }{} 203 | if err = xml.Unmarshal(data, &v); err != nil { 204 | return nil, PageData{}, errors.Wrap(err, "parse shelves data") 205 | } 206 | 207 | meta.Start = v.List.Start 208 | meta.End = v.List.End 209 | meta.Total = v.List.Total 210 | 211 | for _, r := range v.List.Shelves { 212 | s := Shelf{ 213 | ID: r.ID, 214 | Name: r.Name, 215 | Size: r.BookCount, 216 | } 217 | shelves = append(shelves, s) 218 | } 219 | 220 | return shelves, meta, nil 221 | } 222 | 223 | // AddToShelves adds a book to the specified shelves. 224 | func (c *Client) AddToShelves(bookID int64, shelves []string) error { 225 | var ( 226 | u, _ = url.Parse(shelvesAddURL) 227 | v = u.Query() 228 | ) 229 | v.Set("shelves", strings.Join(shelves, ",")) 230 | v.Set("bookids", fmt.Sprintf("%d", bookID)) 231 | u.RawQuery = v.Encode() 232 | 233 | if _, err := c.apiRequest(u.String(), "POST"); err != nil { 234 | return err 235 | } 236 | 237 | return nil 238 | } 239 | 240 | // AddToShelf adds a book to the specified shelf. 241 | func (c *Client) AddToShelf(bookID int64, shelf string) error { 242 | if err := c.addRemoveShelf(bookID, shelf, false); err != nil { 243 | return errors.Wrap(err, "add to shelf") 244 | } 245 | return nil 246 | } 247 | 248 | // RemoveFromShelf removes a book from the specified shelf. 249 | func (c *Client) RemoveFromShelf(bookID int64, shelf string) error { 250 | if err := c.addRemoveShelf(bookID, shelf, true); err != nil { 251 | return errors.Wrap(err, "remove from shelf") 252 | } 253 | return nil 254 | } 255 | 256 | func (c *Client) addRemoveShelf(bookID int64, shelf string, remove bool) error { 257 | var ( 258 | u, _ = url.Parse(shelfAddURL) 259 | v = u.Query() 260 | ) 261 | v.Set("name", shelf) 262 | v.Set("book_id", fmt.Sprintf("%d", bookID)) 263 | if remove { 264 | v.Set("a", "remove") 265 | } 266 | u.RawQuery = v.Encode() 267 | 268 | if _, err := c.apiRequest(u.String(), "POST"); err != nil { 269 | return err 270 | } 271 | 272 | return nil 273 | } 274 | -------------------------------------------------------------------------------- /pkg/gr/shelf_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson <deanishe@deanishe.net> 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | package gr 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/fxtlabs/date" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | // Books from shelf 15 | func TestParseShelf(t *testing.T) { 16 | t.Parallel() 17 | 18 | books, meta, err := unmarshalShelf(readFile("currently-reading.xml", t)) 19 | if err != nil { 20 | t.Fatalf("unmarshal: %v", err) 21 | } 22 | 23 | assert.Equal(t, expectedCurrentlyReading, books, "unexpected Books") 24 | assert.Equal(t, PageData{Start: 1, End: 5, Total: 5}, meta, "unexpected meta") 25 | } 26 | 27 | var ( 28 | expectedCurrentlyReading = []Book{ 29 | { 30 | ID: 31379281, 31 | Title: "Deep Cover Jack (Hunt For Reacher #4)", 32 | TitleNoSeries: "Deep Cover Jack", 33 | Series: Series{Title: "Hunt For Reacher", Position: 4}, 34 | Author: Author{Name: "Diane Capri", ID: 5070259, URL: "https://www.goodreads.com/author/show/5070259"}, 35 | Rating: 4.08, 36 | URL: "https://www.goodreads.com/book/show/31379281", 37 | ImageURL: "https://s.gr-assets.com/assets/nophoto/book/111x148-bcc042a9c91a29c1d680899eff700a03.png", 38 | Description: "In the thrilling follow-up to the ITW Thriller Award Finalist (“Jack and Joe”), FBI Special Agents Kim Otto and Carlos Gaspar will wait no longer. They head to Houston to find Susan Duffy, one of Jack Reacher’s known associates, determined to get answers. But Duffy’s left town, headed for trouble. Otto and Gaspar are right behind her, and powerful enemies with their backs against the wall will have everything to lose.", 39 | }, 40 | { 41 | ID: 10383597, 42 | ISBN: "0771041411", 43 | ISBN13: "9780771041419", 44 | Title: "Arguably: Selected Essays", 45 | TitleNoSeries: "Arguably: Selected Essays", 46 | PubDate: date.New(2011, time.January, 1), 47 | Author: Author{Name: "Christopher Hitchens", ID: 3956, URL: "https://www.goodreads.com/author/show/3956"}, 48 | Rating: 4.2, 49 | URL: "https://www.goodreads.com/book/show/10383597", 50 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1426386037l/10383597._SX98_.jpg", 51 | Description: `The first new book of essays by Christopher Hitchens since 2004, <i>Arguably</i> offers an indispensable key to understanding the passionate and skeptical spirit of one of our most dazzling writers, widely admired for the clarity of his style, a result of his disciplined and candid thinking. <br /><br />Topics range from ruminations on why Charles Dickens was among the best of writers and the worst of men to the haunting science fiction of J.G. Ballard; from the enduring legacies of Thomas Jefferson and George Orwell to the persistent agonies of anti-Semitism and jihad. Hitchens even looks at the recent financial crisis and argues for the enduring relevance of Karl Marx. <br /><br />The book forms a bridge between the two parallel enterprises of culture and politics. It reveals how politics justifies itself by culture, and how the latter prompts the former. In this fashion, <i>Arguably</i> burnishes Christopher Hitchens' credentials as (to quote Christopher Buckley) our "greatest living essayist in the English language."`, 52 | }, 53 | { 54 | ID: 61886, 55 | ISBN: "0007133618", 56 | ISBN13: "9780007133611", 57 | Title: "The Curse of Chalion (World of the Five Gods, #1)", 58 | TitleNoSeries: "The Curse of Chalion", 59 | PubDate: date.New(2003, time.February, 3), 60 | Series: Series{Title: "World of the Five Gods", Position: 1}, 61 | Author: Author{Name: "Lois McMaster Bujold", ID: 16094, URL: "https://www.goodreads.com/author/show/16094"}, 62 | Rating: 4.15, 63 | URL: "https://www.goodreads.com/book/show/61886", 64 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1322571773l/61886._SX98_.jpg", 65 | Description: `A man broken in body and spirit, Cazaril, has returned to the noble household he once served as page, and is named, to his great surprise, as the secretary-tutor to the beautiful, strong-willed sister of the impetuous boy who is next in line to rule. <br /><br />It is an assignment Cazaril dreads, for it will ultimately lead him to the place he fears most, the royal court of Cardegoss, where the powerful enemies, who once placed him in chains, now occupy lofty positions. In addition to the traitorous intrigues of villains, Cazaril and the Royesse Iselle, are faced with a sinister curse that hangs like a sword over the entire blighted House of Chalion and all who stand in their circle. Only by employing the darkest, most forbidden of magics, can Cazaril hope to protect his royal charge—an act that will mark the loyal, damaged servant as a tool of the miraculous, and trap him, flesh and soul, in a maze of demonic paradox, damnation, and death.`, 66 | }, 67 | { 68 | ID: 14497, 69 | ISBN: "0060557818", 70 | ISBN13: "9780060557812", 71 | Title: "Neverwhere (London Below, #1)", 72 | TitleNoSeries: "Neverwhere", 73 | PubDate: date.New(2003, time.September, 2), 74 | Series: Series{Title: "London Below", Position: 1}, 75 | Author: Author{Name: "Neil Gaiman", ID: 1221698, URL: "https://www.goodreads.com/author/show/1221698"}, 76 | Rating: 4.17, 77 | URL: "https://www.goodreads.com/book/show/14497", 78 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1348747943l/14497._SX98_.jpg", 79 | Description: `Under the streets of London there's a place most people could never even dream of. A city of monsters and saints, murderers and angels, knights in armour and pale girls in black velvet. This is the city of the people who have fallen between the cracks.<br /><br />Richard Mayhew, a young businessman, is going to find out more than enough about this other London. A single act of kindness catapults him out of his workday existence and into a world that is at once eerily familiar and utterly bizarre. And a strange destiny awaits him down here, beneath his native city: Neverwhere.`, 80 | }, 81 | { 82 | ID: 18656030, 83 | Title: "Cibola Burn (The Expanse, #4)", 84 | TitleNoSeries: "Cibola Burn", 85 | PubDate: date.New(2014, time.June, 17), 86 | Series: Series{Title: "The Expanse", Position: 4}, 87 | Author: Author{Name: "James S.A. Corey", ID: 4192148, URL: "https://www.goodreads.com/author/show/4192148"}, 88 | Rating: 4.17, 89 | URL: "https://www.goodreads.com/book/show/18656030", 90 | ImageURL: "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1405023040l/18656030._SX98_.jpg", 91 | Description: `<b>The fourth novel in James S.A. Corey’s New York Times bestselling Expanse series</b><br /><br />The gates have opened the way to thousands of habitable planets, and the land rush has begun. Settlers stream out from humanity's home planets in a vast, poorly controlled flood, landing on a new world. Among them, the Rocinante, haunted by the vast, posthuman network of the protomolecule as they investigate what destroyed the great intergalactic society that built the gates and the protomolecule.<br /><br />But Holden and his crew must also contend with the growing tensions between the settlers and the company which owns the official claim to the planet. Both sides will stop at nothing to defend what's theirs, but soon a terrible disease strikes and only Holden - with help from the ghostly Detective Miller - can find the cure.`, 92 | }, 93 | } 94 | ) 95 | -------------------------------------------------------------------------------- /pkg/gr/testdata/alex_verus.xml: -------------------------------------------------------------------------------- 1 | <GoodreadsResponse> 2 | <Request> 3 | <authentication>true</authentication> 4 | <key><![CDATA[W50Kq7OIhVFLTy9daYLlDw]]></key> 5 | <method><![CDATA[series_show]]></method> 6 | </Request> 7 | <series> 8 | <id>71196</id> 9 | <title><![CDATA[ 10 | Alex Verus 11 | ]]> 12 | 14 | 16 | 12 17 | 11 18 | true 19 | 20 | 21 | 324430 22 | 1 23 | 24 | 16686573 25 | kca://work/amzn1.gr.work.v1._hyVDGsLB2AxbkYiD2czjQ 26 | 27 | 11737387 28 | Fated (Alex Verus, #1) 29 | 30 | 849723 31 | Benedict Jacka 32 | 33 | 34 | 35 | 30 36 | 1 37 | 2 38 | 2012 39 | Fated 40 | 21575 41 | 83962 42 | 44275 43 | 1771 44 | 45 | 46 | 47 | 48 | 324431 49 | 2 50 | 51 | 18478073 52 | kca://work/amzn1.gr.work.v1.JrgopgGwKU7ugam7h3wsqQ 53 | 54 | 13274082 55 | Cursed (Alex Verus, #2) 56 | 57 | 849723 58 | Benedict Jacka 59 | 60 | 61 | 62 | 21 63 | 29 64 | 5 65 | 2012 66 | Cursed 67 | 14945 68 | 60820 69 | 20812 70 | 675 71 | 72 | 73 | 74 | 75 | 380525 76 | 3 77 | 78 | 19062926 79 | kca://work/amzn1.gr.work.v1.e71H-GHSvuYglRr-YKDTWw 80 | 81 | 13542616 82 | Taken (Alex Verus, #3) 83 | 84 | 849723 85 | Benedict Jacka 86 | 87 | 88 | 89 | 21 90 | 28 91 | 8 92 | 2012 93 | Taken 94 | 13893 95 | 57230 96 | 19242 97 | 529 98 | 99 | 100 | 101 | 102 | 433395 103 | 4 104 | 105 | 21867224 106 | kca://work/amzn1.gr.work.v1.C5TAVNR1Fg36_vBnOvNiPQ 107 | 108 | 16072988 109 | Chosen (Alex Verus, #4) 110 | 111 | 849723 112 | Benedict Jacka 113 | 114 | 115 | 116 | 21 117 | 27 118 | 8 119 | 2013 120 | Chosen 121 | 11244 122 | 47354 123 | 15884 124 | 508 125 | 126 | 127 | 128 | 129 | 548698 130 | 5 131 | 132 | 26365716 133 | kca://work/amzn1.gr.work.v1.OsFg4a32R3FEBeFY6KpcSg 134 | 135 | 18599601 136 | Hidden (Alex Verus, #5) 137 | 138 | 849723 139 | Benedict Jacka 140 | 141 | 142 | 143 | 16 144 | 2 145 | 9 146 | 2014 147 | Hidden 148 | 9306 149 | 38935 150 | 13700 151 | 403 152 | 153 | 154 | 155 | 156 | 686360 157 | 6 158 | 159 | 42780952 160 | kca://work/amzn1.gr.work.v1.wkXklNmZ9MwVxw3rxZEoLw 161 | 162 | 23236738 163 | Veiled (Alex Verus, #6) 164 | 165 | 849723 166 | Benedict Jacka 167 | 168 | 169 | 170 | 14 171 | 4 172 | 8 173 | 2015 174 | Veiled 175 | 7895 176 | 32890 177 | 11953 178 | 362 179 | 180 | 181 | 182 | 183 | 686361 184 | 7 185 | 186 | 42780954 187 | kca://work/amzn1.gr.work.v1.BppwFAzK10ZllvUOsxUdUg 188 | 189 | 23236743 190 | Burned (Alex Verus, #7) 191 | 192 | 849723 193 | Benedict Jacka 194 | 195 | 196 | 197 | 11 198 | 5 199 | 4 200 | 2016 201 | Burned 202 | 6799 203 | 28974 204 | 10843 205 | 432 206 | 207 | 208 | 209 | 210 | 1565862 211 | 8 212 | 213 | 76176665 214 | kca://work/amzn1.gr.work.v3.Iy4TgdllNL7pDxYD 215 | 216 | 29865319 217 | Bound (Alex Verus, #8) 218 | 219 | 849723 220 | Benedict Jacka 221 | 222 | 223 | 224 | 11 225 | 4 226 | 4 227 | 2017 228 | Bound 229 | 5675 230 | 24423 231 | 9647 232 | 360 233 | 234 | 235 | 236 | 237 | 1106193 238 | 9 239 | 240 | 57651776 241 | kca://work/amzn1.gr.work.v1.cCUgQUgEwC41kg2uGqVklg 242 | 243 | 36068567 244 | Marked (Alex Verus, #9) 245 | 246 | 849723 247 | Benedict Jacka 248 | 249 | 250 | 251 | 10 252 | 3 253 | 7 254 | 2018 255 | Marked 256 | 4407 257 | 18666 258 | 7765 259 | 283 260 | 261 | 262 | 263 | 264 | 1254101 265 | 10 266 | 267 | 63342276 268 | kca://work/amzn1.gr.work.v1.ReHNAl6gZpZrk3ThPBOMrw 269 | 270 | 43670629 271 | Fallen (Alex Verus, #10) 272 | 273 | 849723 274 | Benedict Jacka 275 | 276 | 277 | 278 | 10 279 | 24 280 | 9 281 | 2019 282 | Fallen 283 | 3022 284 | 13352 285 | 5951 286 | 286 287 | 288 | 289 | 290 | 291 | 1555863 292 | 11 293 | 294 | 75767304 295 | kca://work/amzn1.gr.work.v3.sSXw5R1DPH6ZjDk2 296 | 297 | 50740363 298 | Forged (Alex Verus, #11) 299 | 300 | 849723 301 | Benedict Jacka 302 | 303 | 304 | 305 | 5 306 | 24 307 | 11 308 | 2020 309 | Forged 310 | 27 311 | 116 312 | 842 313 | 2 314 | 315 | 316 | 317 | 318 | 686362 319 | 1-4 320 | 321 | 40357662 322 | kca://work/amzn1.gr.work.v1.kAquhAWlqUEbTqrEkUrGMA 323 | 324 | 20980464 325 | The Alex Verus Novels, Books 1-4 326 | 327 | 849723 328 | Benedict Jacka 329 | 330 | 331 | 332 | 2 333 | 4 334 | 3 335 | 2014 336 | 337 | 247 338 | 1103 339 | 542 340 | 5 341 | 342 | 343 | 344 | 345 | 346 | -------------------------------------------------------------------------------- /pkg/gr/text.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package gr 6 | 7 | import ( 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/microcosm-cc/bluemonday" 13 | ) 14 | 15 | var ( 16 | rxEntityDec = regexp.MustCompile(`&#\d+;`) 17 | rxWhitespace = regexp.MustCompile(`\s+`) 18 | mdPolicy *bluemonday.Policy 19 | textPolicy *bluemonday.Policy 20 | ) 21 | 22 | func init() { 23 | mdPolicy = bluemonday.NewPolicy() 24 | mdPolicy.AllowElements("br", "i", "em", "strong", "b", "p") 25 | textPolicy = bluemonday.NewPolicy() 26 | textPolicy.AllowElements("br") 27 | } 28 | 29 | // HTML2Markdown converts HTML to Markdown. 30 | func HTML2Markdown(s string) string { 31 | tags := []struct { 32 | find, repl string 33 | }{ 34 | {"

", ""}, 35 | {"

", "\n\n"}, 36 | {"

", "\n\n"}, 37 | {"

", "\n\n"}, 38 | {"
", " \n"}, 39 | {"
", " \n"}, 40 | {"", "*"}, 41 | {"", "*"}, 42 | {"", "*"}, 43 | {"", "*"}, 44 | {"", "**"}, 45 | {"", "**"}, 46 | {"", "**"}, 47 | {"", "**"}, 48 | } 49 | s = mdPolicy.Sanitize(s) 50 | s = decodeEntities(tidyText(s)) 51 | for _, t := range tags { 52 | s = strings.Replace(s, t.find, t.repl, -1) 53 | } 54 | return s 55 | } 56 | 57 | var rxBR = regexp.MustCompile(`
`) 58 | 59 | // HTML2Text converts HTML to plaintext. 60 | func HTML2Text(s string) string { 61 | s = textPolicy.Sanitize(s) 62 | s = decodeEntities(tidyText(s)) 63 | return rxBR.ReplaceAllString(s, "\n") 64 | } 65 | 66 | // convert HTML &#NN; entities to text. 67 | func decodeEntities(s string) string { 68 | for { 69 | m := rxEntityDec.FindStringIndex(s) 70 | if m == nil { 71 | break 72 | } 73 | i, _ := strconv.ParseInt(s[m[0]+2:m[1]-1], 10, 64) 74 | s = s[0:m[0]] + string(i) + s[m[1]:] 75 | } 76 | return strings.TrimSpace(s) 77 | } 78 | 79 | // replace ASCII dashes and ellipses with Unicode versions; collapse whitespace. 80 | func tidyText(s string) string { 81 | for _, pat := range []string{"....", ". . . .", ". . .", "..."} { 82 | s = strings.Replace(s, pat, "…", -1) 83 | } 84 | s = strings.Replace(s, "--", "—", -1) 85 | s = strings.Replace(s, "\n", "", -1) 86 | s = rxWhitespace.ReplaceAllString(s, " ") 87 | return s 88 | } 89 | -------------------------------------------------------------------------------- /pkg/gr/text_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-18 4 | 5 | package gr 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestHTML2Markdown(t *testing.T) { 14 | t.Parallel() 15 | tests := []struct { 16 | name, in, x string 17 | }{ 18 | {"empty string", "", ""}, 19 | {"four dots", "....", "…"}, 20 | {"four dots with spaces", ". . . .", "…"}, 21 | {"three dots with spaces", ". . .", "…"}, 22 | {"three dots", "...", "…"}, 23 | {"dresden", `HARRY DRESDEN — WIZARD

Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment.

Harry Dresden is the best at what he does. Well, technically, he's the only at what he does. So when the Chicago P.D. has a case that transcends mortal creativity or capability, they come to him for answers. For the "everyday" world is actually full of strange and magical things—and most don't play well with humans. That's where Harry comes in. Takes a wizard to catch a—well, whatever. There's just one problem. Business, to put it mildly, stinks.

So when the police bring him in to consult on a grisly double murder committed with black magic, Harry's seeing dollar signs. But where there's black magic, there's a black mage behind it. And now that mage knows Harry's name. And that's when things start to get interesting.

Magic - it can get a guy killed.`, 24 | `**HARRY DRESDEN — WIZARD** 25 | 26 | *Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment.* 27 | 28 | Harry Dresden is the best at what he does. Well, technically, he's the *only* at what he does. So when the Chicago P.D. has a case that transcends mortal creativity or capability, they come to him for answers. For the "everyday" world is actually full of strange and magical things—and most don't play well with humans. That's where Harry comes in. Takes a wizard to catch a—well, whatever. There's just one problem. Business, to put it mildly, stinks. 29 | 30 | So when the police bring him in to consult on a grisly double murder committed with black magic, Harry's seeing dollar signs. But where there's black magic, there's a black mage behind it. And now that mage knows Harry's name. And that's when things start to get interesting. 31 | 32 | Magic - it can get a guy killed.`}, 33 | {"dresden with link", `An alternative cover edition with a different page count exists here.

Harry Dresden - Wizard
Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment.

Harry Dresden has faced some pretty terrifying foes during his career. Giant scorpions. Oversexed vampires. Psychotic werewolves. It comes with the territory when you're the only professional wizard in the Chicago-area phone book.

But in all Harry's years of supernatural sleuthing, he's never faced anything like this: The spirit world has gone postal. All over Chicago, ghosts are causing trouble - and not just of the door-slamming, boo-shouting variety. These ghosts are tormented, violent, and deadly. Someone - or something - is purposely stirring them up to wreak unearthly havoc. But why? And why do so many of the victims have ties to Harry? If Harry doesn't figure it out soon, he could wind up a ghost himself....`, 34 | `*An alternative cover edition with a different page count exists here.* 35 | 36 | Harry Dresden - Wizard 37 | Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment. 38 | 39 | Harry Dresden has faced some pretty terrifying foes during his career. Giant scorpions. Oversexed vampires. Psychotic werewolves. It comes with the territory when you're the only professional wizard in the Chicago-area phone book. 40 | 41 | But in all Harry's years of supernatural sleuthing, he's never faced anything like this: The spirit world has gone postal. All over Chicago, ghosts are causing trouble - and not just of the door-slamming, boo-shouting variety. These ghosts are tormented, violent, and deadly. Someone - or *something* - is purposely stirring them up to wreak unearthly havoc. But why? And why do so many of the victims have ties to Harry? If Harry doesn't figure it out soon, he could wind up a ghost himself…`}, 42 | {"dresden many links", `Here, together for the first time, are the shorter from Jim Butcher's DRESDEN FILES series — a compendium of cases that Harry and his cadre of allies managed to close in record time. The tales range from the deadly serious to the absurdly hilarious. Also included is a new, never-before-published novella that takes place after the cliff-hanger ending of the new April 2010 hardcover, Changes.

Contains:
+ "Restoration of Faith"
+ "Vignette"
+ "Something Borrowed" -- from 43 | My Big Fat Supernatural Wedding 44 |
+ "It's My Birthday Too" -- from 45 | Many Bloody Returns 46 |
+ "Heorot" -- from 47 | My Big Fat Supernatural Honeymoon 48 |
+ "Day Off" -- from 49 | Blood Lite 50 |
+ "Backup" -- novelette from Thomas' point of view, originally published by Subterranean Press
+ "The Warrior" -- novelette from 51 | Mean Streets 52 |
+ "Last Call" -- from 53 | Strange Brew 54 |
+ "Love Hurts" -- from 55 | Songs of Love and Death 56 |
+ Aftermath -- all-new novella from Murphy's point of view, set forty-five minutes after the end of 57 | Changes 58 | `, 59 | `Here, together for the first time, are the shorter from Jim Butcher's DRESDEN FILES series — a compendium of cases that Harry and his cadre of allies managed to close in record time. The tales range from the deadly serious to the absurdly hilarious. Also included is a new, never-before-published novella that takes place after the cliff-hanger ending of the new April 2010 hardcover, *Changes*. 60 | 61 | Contains: 62 | + "Restoration of Faith" 63 | + "Vignette" 64 | + "Something Borrowed" — from * My Big Fat Supernatural Wedding* 65 | + "It's My Birthday Too" — from * Many Bloody Returns* 66 | + "Heorot" — from * My Big Fat Supernatural Honeymoon* 67 | + "Day Off" — from * Blood Lite* 68 | + "Backup" — novelette from Thomas' point of view, originally published by Subterranean Press 69 | + "The Warrior" — novelette from * Mean Streets* 70 | + "Last Call" — from * Strange Brew* 71 | + "Love Hurts" — from * Songs of Love and Death* 72 | + *Aftermath* — all-new novella from Murphy's point of view, set forty-five minutes after the end of * Changes*`}, 73 | } 74 | 75 | for _, td := range tests { 76 | td := td 77 | t.Run(td.name, func(t *testing.T) { 78 | assert.Equal(t, td.x, HTML2Markdown(td.in), "unexpected markdown") 79 | }) 80 | } 81 | } 82 | 83 | func TestHTML2Text(t *testing.T) { 84 | t.Parallel() 85 | tests := []struct { 86 | name, in, x string 87 | }{ 88 | {"empty string", "", ""}, 89 | {"four dots", "....", "…"}, 90 | {"four dots with spaces", ". . . .", "…"}, 91 | {"three dots with spaces", ". . .", "…"}, 92 | {"three dots", "...", "…"}, 93 | {"dresden", `HARRY DRESDEN — WIZARD

Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment.

Harry Dresden is the best at what he does. Well, technically, he's the only at what he does. So when the Chicago P.D. has a case that transcends mortal creativity or capability, they come to him for answers. For the "everyday" world is actually full of strange and magical things—and most don't play well with humans. That's where Harry comes in. Takes a wizard to catch a—well, whatever. There's just one problem. Business, to put it mildly, stinks.

So when the police bring him in to consult on a grisly double murder committed with black magic, Harry's seeing dollar signs. But where there's black magic, there's a black mage behind it. And now that mage knows Harry's name. And that's when things start to get interesting.

Magic - it can get a guy killed.`, 94 | `HARRY DRESDEN — WIZARD 95 | 96 | Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment. 97 | 98 | Harry Dresden is the best at what he does. Well, technically, he's the only at what he does. So when the Chicago P.D. has a case that transcends mortal creativity or capability, they come to him for answers. For the "everyday" world is actually full of strange and magical things—and most don't play well with humans. That's where Harry comes in. Takes a wizard to catch a—well, whatever. There's just one problem. Business, to put it mildly, stinks. 99 | 100 | So when the police bring him in to consult on a grisly double murder committed with black magic, Harry's seeing dollar signs. But where there's black magic, there's a black mage behind it. And now that mage knows Harry's name. And that's when things start to get interesting. 101 | 102 | Magic - it can get a guy killed.`}, 103 | {"dresden with link", `An alternative cover edition with a different page count exists here.

Harry Dresden - Wizard
Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment.

Harry Dresden has faced some pretty terrifying foes during his career. Giant scorpions. Oversexed vampires. Psychotic werewolves. It comes with the territory when you're the only professional wizard in the Chicago-area phone book.

But in all Harry's years of supernatural sleuthing, he's never faced anything like this: The spirit world has gone postal. All over Chicago, ghosts are causing trouble - and not just of the door-slamming, boo-shouting variety. These ghosts are tormented, violent, and deadly. Someone - or something - is purposely stirring them up to wreak unearthly havoc. But why? And why do so many of the victims have ties to Harry? If Harry doesn't figure it out soon, he could wind up a ghost himself....`, 104 | `An alternative cover edition with a different page count exists here. 105 | 106 | Harry Dresden - Wizard 107 | Lost Items Found. Paranormal Investigations. Consulting. Advice. Reasonable Rates. No Love Potions, Endless Purses, or Other Entertainment. 108 | 109 | Harry Dresden has faced some pretty terrifying foes during his career. Giant scorpions. Oversexed vampires. Psychotic werewolves. It comes with the territory when you're the only professional wizard in the Chicago-area phone book. 110 | 111 | But in all Harry's years of supernatural sleuthing, he's never faced anything like this: The spirit world has gone postal. All over Chicago, ghosts are causing trouble - and not just of the door-slamming, boo-shouting variety. These ghosts are tormented, violent, and deadly. Someone - or something - is purposely stirring them up to wreak unearthly havoc. But why? And why do so many of the victims have ties to Harry? If Harry doesn't figure it out soon, he could wind up a ghost himself…`}, 112 | {"dresden many links", `Here, together for the first time, are the shorter from Jim Butcher's DRESDEN FILES series — a compendium of cases that Harry and his cadre of allies managed to close in record time. The tales range from the deadly serious to the absurdly hilarious. Also included is a new, never-before-published novella that takes place after the cliff-hanger ending of the new April 2010 hardcover, Changes.

Contains:
+ "Restoration of Faith"
+ "Vignette"
+ "Something Borrowed" -- from 113 | My Big Fat Supernatural Wedding 114 |
+ "It's My Birthday Too" -- from 115 | Many Bloody Returns 116 |
+ "Heorot" -- from 117 | My Big Fat Supernatural Honeymoon 118 |
+ "Day Off" -- from 119 | Blood Lite 120 |
+ "Backup" -- novelette from Thomas' point of view, originally published by Subterranean Press
+ "The Warrior" -- novelette from 121 | Mean Streets 122 |
+ "Last Call" -- from 123 | Strange Brew 124 |
+ "Love Hurts" -- from 125 | Songs of Love and Death 126 |
+ Aftermath -- all-new novella from Murphy's point of view, set forty-five minutes after the end of 127 | Changes 128 | `, 129 | `Here, together for the first time, are the shorter from Jim Butcher's DRESDEN FILES series — a compendium of cases that Harry and his cadre of allies managed to close in record time. The tales range from the deadly serious to the absurdly hilarious. Also included is a new, never-before-published novella that takes place after the cliff-hanger ending of the new April 2010 hardcover, Changes. 130 | 131 | Contains: 132 | + "Restoration of Faith" 133 | + "Vignette" 134 | + "Something Borrowed" — from My Big Fat Supernatural Wedding 135 | + "It's My Birthday Too" — from Many Bloody Returns 136 | + "Heorot" — from My Big Fat Supernatural Honeymoon 137 | + "Day Off" — from Blood Lite 138 | + "Backup" — novelette from Thomas' point of view, originally published by Subterranean Press 139 | + "The Warrior" — novelette from Mean Streets 140 | + "Last Call" — from Strange Brew 141 | + "Love Hurts" — from Songs of Love and Death 142 | + Aftermath — all-new novella from Murphy's point of view, set forty-five minutes after the end of Changes`}, 143 | } 144 | 145 | for _, td := range tests { 146 | td := td 147 | t.Run(td.name, func(t *testing.T) { 148 | assert.Equal(t, td.x, HTML2Text(td.in), "unexpected text") 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pkg/gr/user.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | // Created on 2020-07-17 4 | 5 | package gr 6 | 7 | import ( 8 | "encoding/xml" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const apiUser = "https://www.goodreads.com/api/auth_user" 14 | 15 | // User is a Goodreads user. 16 | type User struct { 17 | ID int64 `xml:"id,attr"` 18 | Name string `xml:"name"` 19 | } 20 | 21 | // UserInfo retrieves user info from API. 22 | func (c *Client) UserInfo() (User, error) { 23 | var ( 24 | data []byte 25 | err error 26 | ) 27 | if data, err = c.apiRequest(apiUser); err != nil { 28 | return User{}, errors.Wrap(err, "contact user endpoint") 29 | } 30 | 31 | v := struct { 32 | User User `xml:"user"` 33 | }{} 34 | err = xml.Unmarshal(data, &v) 35 | return v.User, err 36 | } 37 | -------------------------------------------------------------------------------- /scripts/Add to Currently Reading.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -add "currently-reading" 4 | -------------------------------------------------------------------------------- /scripts/Add to Shelves.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -hide=false -action select 4 | -------------------------------------------------------------------------------- /scripts/Add to Want to Read.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -add "to-read" 4 | -------------------------------------------------------------------------------- /scripts/Copy Goodreads Link.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | echo -n "https://www.goodreads.com/book/show/${BOOK_ID}" | /usr/bin/pbcopy 4 | 5 | ./alfred-booksearch -beep 6 | -------------------------------------------------------------------------------- /scripts/Mark as Read.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -add "read" 4 | -------------------------------------------------------------------------------- /scripts/Open Author Page.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | /usr/bin/open "https://www.goodreads.com/author/show/${AUTHOR_ID}" 4 | -------------------------------------------------------------------------------- /scripts/Open Book Page.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | /usr/bin/open "https://www.goodreads.com/book/show/${BOOK_ID}" 4 | -------------------------------------------------------------------------------- /scripts/View Author’s Books.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -hide=false -action author 4 | -------------------------------------------------------------------------------- /scripts/View Series.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | ./alfred-booksearch -hide=false -action series -query="" 4 | -------------------------------------------------------------------------------- /scripts/View Similar Books.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh -e 2 | 3 | eval "$( ./alfred-booksearch -export )" 4 | 5 | if [[ "$WORK_ID" == "0" ]]; then 6 | echo "WORK_ID is not set" >&2 7 | exit 1 8 | fi 9 | 10 | /usr/bin/open "https://www.goodreads.com/book/similar/${WORK_ID}" 11 | -------------------------------------------------------------------------------- /vars.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2020 Dean Jackson 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2020-05-26 8 | # 9 | 10 | """Remove/add custom user variables before/after committing.""" 11 | 12 | from __future__ import print_function, absolute_import 13 | 14 | import argparse 15 | import json 16 | import os 17 | import plistlib 18 | from subprocess import check_call 19 | import sys 20 | 21 | 22 | INFO_PLIST = os.path.join(os.path.dirname(__file__), 'info.plist') 23 | VAR_CACHE = os.path.join(os.path.dirname(__file__), 'vars.json') 24 | 25 | WHITELIST = ('ACTION_DEFAULT', 'ACTION_ALT') 26 | DELETE_PREFIXES = ('ACTION_',) 27 | CLEAR_PREFIXES = ('USER_',) 28 | 29 | 30 | def log(s, *args, **kwargs): 31 | """Log to STDOUT.""" 32 | if args: 33 | s = s % args 34 | elif kwargs: 35 | s = s % kwargs 36 | 37 | print(s, file=sys.stdout) 38 | 39 | 40 | def save_vars(): 41 | """Save variables from info.plist to vars.json.""" 42 | data = plistlib.readPlist(INFO_PLIST) 43 | var = {} 44 | for key, value in data['variables'].items(): 45 | if not value: # ignore empty variables 46 | continue 47 | 48 | if key in WHITELIST: 49 | continue 50 | 51 | for prefix in DELETE_PREFIXES: 52 | if key.startswith(prefix): 53 | var[key] = value 54 | del data['variables'][key] 55 | log('deleted %s', key) 56 | 57 | for prefix in CLEAR_PREFIXES: 58 | if key.startswith(prefix): 59 | var[key] = value 60 | data['variables'][key] = '' 61 | log('cleared %s', key) 62 | 63 | if var: 64 | with open(VAR_CACHE, 'wb') as fp: 65 | json.dump(var, fp, indent=2, sort_keys=True, separators=(',', ': ')) 66 | 67 | plistlib.writePlist(data, INFO_PLIST) 68 | 69 | 70 | def add_vars(): 71 | """Save variables from info.plist to vars.json.""" 72 | with open(VAR_CACHE) as fp: 73 | var = json.load(fp) 74 | data = plistlib.readPlist(INFO_PLIST) 75 | for key, value in var.items(): 76 | data['variables'][key] = value 77 | log('set %s', key) 78 | 79 | plistlib.writePlist(data, INFO_PLIST) 80 | 81 | 82 | def reload(): 83 | """Tell Alfred to reload workflow.""" 84 | s = """ 85 | tell application id "com.runningwithcrayons.Alfred" 86 | reload workflow "net.deanishe.alfred.goodreads" 87 | end tell 88 | """ 89 | check_call(['/usr/bin/osascript', '-e', s]) 90 | 91 | 92 | def parse_args(): 93 | """Handle CLI arguments.""" 94 | parser = argparse.ArgumentParser(description=__doc__) 95 | parser.add_argument('-s', '--save', action='store_true', 96 | help='save variables and remove them from info.plist') 97 | parser.add_argument('-a', '--add', action='store_true', 98 | help='re-add variables to info.plist') 99 | parser.add_argument('-r', '--reload', action='store_true', 100 | help='tell Alfred to reload workflow') 101 | return parser.parse_args() 102 | 103 | 104 | def main(): 105 | """Run script.""" 106 | args = parse_args() 107 | if args.save: 108 | save_vars() 109 | else: 110 | add_vars() 111 | 112 | if args.reload: 113 | reload() 114 | 115 | 116 | if __name__ == '__main__': 117 | main() 118 | --------------------------------------------------------------------------------