├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── alpaca.svg ├── app ├── cmd │ ├── fixtures │ │ └── pack_test │ │ │ ├── alpaca.yml │ │ │ ├── img │ │ │ └── alpaca.png │ │ │ └── scripts │ │ │ └── script.js │ ├── install.go │ ├── pack.go │ ├── pack_test.go │ ├── root.go │ └── version.go └── version │ └── version.go ├── config ├── applescript.go ├── clipboard.go ├── config.go ├── keyword.go ├── object.go ├── openurl.go ├── script.go └── scriptfilter.go ├── go.mod ├── go.sum ├── main.go ├── project └── build.go ├── script └── build └── workflow ├── uidata.go └── workflow.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: {branches: master} 3 | pull_request: {branches: master} 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/setup-go@v1.0.2 10 | with: {go-version: '1.12.7'} 11 | - uses: actions/checkout@v1.0.0 12 | - run: go test ./... 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: repository_dispatch 2 | 3 | jobs: 4 | release: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/setup-go@v1.0.2 8 | with: {go-version: '1.12.7'} 9 | - uses: actions/checkout@v1.0.0 10 | - run: go test ./... 11 | - run: script/build ${{github.event.action}} 12 | - uses: jclem/github-release@master 13 | with: 14 | tag-name: ${{github.event.action}} 15 | name: ${{github.event.action}} 16 | draft: true 17 | assets: 'build/*.tar.gz' 18 | github-token: ${{github.token}} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Jonathan Clem 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alpaca 2 | 3 | [![](https://github.com/jclem/alpaca/workflows/.github/workflows/ci.yml/badge.svg)](https://github.com/jclem/alpaca/actions) 4 | 5 | Alpaca is a command line utility for building Alfred workflow bundles. 6 | 7 | An alpaca project is an `alpaca.yml` file that defines the workflow, alongside any supporting files, such as scripts or images. 8 | 9 | **Note:** Alpaca is still in the proof-of-concept phase. Huge portions of Alfred functionality are unimplemented. 10 | 11 |
12 | Contents 13 | 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [`alpaca pack`](#alpaca-pack-dir) 17 | - [Schema](#schema) 18 | - [Example](#example) 19 | - [Root Schema](#root-schema) 20 | - [Object Schema](#object-schema) 21 | - [`applescript`](#applescript) 22 | - [`clipboard`](#clipboard) 23 | - [`keyword`](#keyword) 24 | - [`open-url`](#open-url) 25 | - [`script`](#script) 26 | - [`script-filter`](#script-filter) 27 | - [Script Schema](#script-schema) 28 | - [Executable Script](#executable-script) 29 | - [Inline Script](#inline-script) 30 | 31 |
32 | 33 | ## Installation 34 | 35 | [Download the latest release](https://github.com/jclem/alpaca/releases/latest), or: 36 | 37 | ```shell 38 | $ go get github.com/jclem/alpaca 39 | ``` 40 | 41 | ## Usage 42 | 43 | ### `alpaca pack ` 44 | 45 | Pack an Alpaca project into an Alfred workflow. The workflow will be output into the current directory. 46 | 47 | ```shell 48 | $ alpaca pack . 49 | ``` 50 | 51 | ## Schema 52 | 53 | ### Example 54 | 55 | This workflow uses a trigger "say" to say words passed as arguments. For example, the user might type "say hello world" into Alfred. 56 | 57 | ```yaml 58 | name: Say 59 | 60 | objects: 61 | trigger: 62 | type: keyword 63 | config: 64 | keyword: say 65 | argument: required 66 | then: say 67 | 68 | say: 69 | type: script 70 | config: 71 | script: 72 | content: say {query} 73 | type: bash 74 | ``` 75 | 76 | ### Root Schema 77 | 78 | - `name` The name of the project 79 | - `version` The version of the project 80 | - `author` The author of the project (i.e. `Jonathan Clem `) 81 | - `bundle-id` The Alfred workflow bundle ID 82 | - `description` A short description of the workflow 83 | - `readme` A longer description of the workflow, seen when users import it 84 | - `url` A homepage URL for the workflow 85 | - `icon` A project-relative path to an icon to use for the worflow 86 | - `variables` A map of variable names and their default values 87 | - [`object`](#object-schema) An map of objects in the Alfred workflow. Each key is an object name. 88 | 89 | ### Object Schema 90 | 91 | - `icon` A project-relative path to an icon for the object 92 | - `type` The type of object this is. Currently partial support exists for: 93 | - [`applescript`](#applescript) 94 | - [`clipboard`](#clipboard) 95 | - [`keyword`](#keyword) 96 | - [`open-url`](#open-url) 97 | - [`script`](#script) 98 | - [`script-filter`](#script-filter) 99 | - `config` A type-specific configuration object, see each type schema for details 100 | - `then` A string, list of strings, or a list of objects representing other objects to connect to, each objects having this schema: 101 | - `object` The name of the object to connect to 102 | 103 | #### `applescript` 104 | 105 | - `cache` (`bool`, default `true`) Whether to cache the compiled AppleScript 106 | - `content` (`string`) The content of the AppleScript 107 | 108 | #### `clipboard` 109 | 110 | - `text` (`string`, default `"{query}"`) The text to copy to the clipboard—use `"{query}"` for the exact query 111 | 112 | #### `keyword` 113 | 114 | - `keyword` (`string`) The keyword that triggers this object 115 | - `with-space` (`bool`, default `true`) Whether a space is required with this object 116 | - `title` (`string`) The title of the object 117 | - `subtitle` (`string`) The subtitle of the object 118 | - `argument` (`string`) A string determining whether an argument is required. One of: 119 | - `required` The argument is required 120 | - `optional` The argument is optional 121 | - `none` No argument is accepted 122 | 123 | #### `open-url` 124 | 125 | - `url` (`string`) The URL to open. Use `"{query}"` for the exact query 126 | 127 | #### `script` 128 | 129 | - [`script`](#script-schema) A script configuration object 130 | 131 | #### `script-filter` 132 | 133 | - `argument` (`string`) A string determining whether an argument is required. One of: 134 | - `required` The argument is required 135 | - `optional` The argument is optional 136 | - `none` No argument is accepted 137 | - `argument-trim` (`string`) Whether to trim the argument's whitespace. One of: 138 | - `auto` Argument automatically trimmed 139 | - `off` Argument not trimmed 140 | - `escaping` (`[]string`) A list of strings to escape in the query text, if the script object's `arg-type` is `query`. Any of: 141 | - `spaces` 142 | - `backquotes` 143 | - `double-quote` 144 | - `brackets` 145 | - `semicolons` 146 | - `dollars` 147 | - `backslashes` 148 | - `ignore-empty-argument` (`bool`) Whether an empty argument (when `arg-type` is `argv` in script config) is omitted from `argv` (when `false`, it will be an empty string) 149 | - `keyword` (`string`) The keyword that triggers this object 150 | - `running-subtitle` (`string`) A subtitle to display while this filter runs 151 | - `subtitle` (`string`) A subtitle for this object 152 | - `title` (`string`) A title for this object 153 | - `with-space` (`bool`, default `true`) Whether a space is required with this object 154 | - `alfred-filters-results` An object describing how Alfred filters results (it does not if this is omitted): 155 | - `mode` (`string`) The mode Alfred uses to filter results. One of: 156 | - `exact-boundary` 157 | - `exact-start` 158 | - `word-match` 159 | - `run-behavior` An object describing behavior of the script run 160 | - `immediate` (`bool`) Whether to run immediately always for the first character typed 161 | - `queue-mode` (`string`) A mode for how script runs are queued. One of: 162 | - `wait` Wait for previous run to complete 163 | - `terminate` Terminate previous run immediately and start a new one 164 | - `queue-delay` (`string`) A delay mode for queueing script runs. One of: 165 | - `immediate` No delay 166 | - `automatic` Automatic after last character typed 167 | - `100ms` 100ms after last character typed 168 | - `200ms` 200ms after last character typed 169 | - `300ms` 300ms after last character typed 170 | - `400ms` 400ms after last character typed 171 | - `500ms` 500ms after last character typed 172 | - `600ms` 600ms after last character typed 173 | - `700ms` 700ms after last character typed 174 | - `800ms` 800ms after last character typed 175 | - `900ms` 900ms after last character typed 176 | - `1000ms` 1000ms after last character typed 177 | - [`script`](#script-schema) A script configuration object 178 | 179 | ### Script Schema 180 | 181 | There are a few types of script schemas possible, in addition to these options: 182 | 183 | #### Executable Script 184 | 185 | This version executes the script at the given path (it must be executable). 186 | 187 | - `path` (`string`) The path to the script 188 | 189 | #### Inline Script 190 | 191 | This version executes an inline script. 192 | 193 | - `arg-type` (`string`) The way the argument is passed to the script. One of: 194 | - `query` Interpolated as (`query`) 195 | - `argv` Passed into process arguments 196 | - `content` (`string`) The content of the script 197 | - `type` (`string`) The type of script, one of: 198 | - `bash` 199 | - `php` 200 | - `ruby` 201 | - `python` 202 | - `perl` 203 | - `zsh` 204 | - `osascript-as` 205 | - `osascript-js` 206 | -------------------------------------------------------------------------------- /alpaca.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/cmd/fixtures/pack_test/alpaca.yml: -------------------------------------------------------------------------------- 1 | name: pack_test 2 | version: 0.1.0 3 | author: Jonathan Clem 4 | bundle-id: com.jclem.alfred.alpaca-test.say-hello 5 | description: Says words 6 | url: https://github.com/jclem/alpaca/blob/master/app/tests/fixtures/pack_test 7 | icon: img/alpaca.png 8 | 9 | readme: | 10 | This is information about the workflow. 11 | 12 | variables: 13 | FOO: foo 14 | 15 | objects: 16 | applescript: 17 | type: applescript 18 | config: 19 | content: the-applescript 20 | 21 | clipboard: 22 | type: clipboard 23 | then: [object: applescript] 24 | 25 | keyword: 26 | type: keyword 27 | config: 28 | keyword: the-keyword 29 | title: Keyword 30 | subtitle: keyword 31 | argument: none 32 | then: applescript 33 | 34 | openurl: 35 | type: open-url 36 | config: 37 | url: https://example.com 38 | 39 | script: 40 | type: script 41 | icon: img/alpaca.png 42 | config: 43 | script: 44 | content: |- 45 | echo "hi" 46 | type: bash 47 | 48 | script-filter: 49 | type: script-filter 50 | icon: img/alpaca.png 51 | config: 52 | argument: optional 53 | argument-trim: off 54 | escaping: 55 | - spaces 56 | - dollars 57 | ignore-empty-argument: true 58 | keyword: filter 59 | running-subtitle: Please wait... 60 | subtitle: Runs a script filter 61 | title: Run script-filter 62 | script: 63 | arg-type: argv 64 | path: scripts/script.js 65 | alfred-filters-results: 66 | mode: word-match 67 | run-behavior: 68 | immediate: true 69 | queue-mode: wait 70 | queue-delay: automatic 71 | then: [clipboard] 72 | -------------------------------------------------------------------------------- /app/cmd/fixtures/pack_test/img/alpaca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jclem/alpaca/a6afdd0fa7755ddfee6aff349013a7ed0d6b15e6/app/cmd/fixtures/pack_test/img/alpaca.png -------------------------------------------------------------------------------- /app/cmd/fixtures/pack_test/scripts/script.js: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/node 2 | 3 | console.log('Hello, world.') 4 | -------------------------------------------------------------------------------- /app/cmd/install.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | func init() { 6 | rootCmd.AddCommand(&installCmd) 7 | } 8 | 9 | var installCmd = cobra.Command{} 10 | -------------------------------------------------------------------------------- /app/cmd/pack.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/jclem/alpaca/project" 9 | "github.com/pkg/errors" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var out string 14 | 15 | func init() { 16 | packCmd.Flags().StringVarP(&out, "out", "o", "", "Directory to output the packaged workflow to") 17 | rootCmd.AddCommand(&packCmd) 18 | } 19 | 20 | var packCmd = cobra.Command{ 21 | Use: "pack ", 22 | Short: "Package the given Alpaca project into an Alfred workflow", 23 | Args: cobra.ExactArgs(1), 24 | Run: func(cmd *cobra.Command, args []string) { 25 | dir := args[0] 26 | 27 | projectPath, err := filepath.Abs(dir) 28 | if err != nil { 29 | log.Fatalf("Could not resolve path %s", dir) 30 | } 31 | 32 | outDir := out 33 | if outDir == "" { 34 | outDir, err = os.Getwd() 35 | if err != nil { 36 | err := errors.Wrap(err, "Error getting working directory") 37 | log.Fatal(err) 38 | } 39 | } 40 | 41 | if err := project.Build(projectPath, outDir); err != nil { 42 | log.Fatal(err) 43 | } 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /app/cmd/pack_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/groob/plist" 12 | "github.com/jclem/alpaca/workflow" 13 | "github.com/mholt/archiver" 14 | "github.com/spf13/cobra" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func TestPack(t *testing.T) { 19 | dir, err := filepath.Abs("./fixtures/pack_test") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | out := packWorkflow(dir) 25 | 26 | wfFile := filepath.Join(out, "pack_test.alfredworkflow") 27 | zipOut := unzip(wfFile) 28 | plistPath := filepath.Join(zipOut, "info.plist") 29 | var i workflow.Info 30 | if err := plist.Unmarshal(readFile(plistPath), &i); err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | // Test basic workflow metadata 35 | assert.Equal(t, "pack_test", i.Name) 36 | assert.Equal(t, "0.1.0", i.Version) 37 | assert.Equal(t, "Jonathan Clem ", i.CreatedBy) 38 | assert.Equal(t, "com.jclem.alfred.alpaca-test.say-hello", i.BundleID) 39 | assert.Equal(t, "Says words", i.Description) 40 | assert.Equal(t, "https://github.com/jclem/alpaca/blob/master/app/tests/fixtures/pack_test", i.WebAddress) 41 | assert.Equal(t, "This is information about the workflow.\n", i.Readme) 42 | assert.Equal(t, readFile(filepath.Join(dir, "img/alpaca.png")), readFile(filepath.Join(zipOut, "icon.png"))) 43 | assert.Equal(t, map[string]string{"FOO": "foo"}, i.Variables) 44 | assert.Equal(t, []string{"FOO"}, i.VariablesDontExport) 45 | 46 | // Test objects 47 | // Sort objects by type 48 | sortedObjs := make([]map[string]interface{}, len(i.Objects)) 49 | copy(sortedObjs, i.Objects) 50 | sort.Slice(sortedObjs, func(i, j int) bool { 51 | iType := sortedObjs[i]["type"].(string) 52 | jType := sortedObjs[j]["type"].(string) 53 | return iType < jType 54 | }) 55 | assert.Equal(t, 6, len(sortedObjs)) 56 | 57 | applescript := sortedObjs[0] 58 | config := applescript["config"].(map[string]interface{}) 59 | assert.True(t, config["cachescript"].(bool)) 60 | assert.Equal(t, "the-applescript", config["applescript"].(string)) 61 | assert.Equal(t, "alfred.workflow.action.applescript", applescript["type"]) 62 | 63 | openurl := sortedObjs[1] 64 | config = openurl["config"].(map[string]interface{}) 65 | assert.Equal(t, "alfred.workflow.action.openurl", openurl["type"]) 66 | assert.Equal(t, "https://example.com", config["url"]) 67 | 68 | script := sortedObjs[2] 69 | config = script["config"].(map[string]interface{}) 70 | assert.Equal(t, "alfred.workflow.action.script", script["type"]) 71 | assert.Equal(t, readFile(filepath.Join(dir, "img/alpaca.png")), readFile(filepath.Join(zipOut, script["uid"].(string)+".png"))) 72 | assert.Equal(t, `echo "hi"`, config["script"]) 73 | assert.Equal(t, uint64(0), config["type"]) 74 | assert.Equal(t, uint64(1), config["scriptargtype"]) 75 | 76 | keyword := sortedObjs[3] 77 | config = keyword["config"].(map[string]interface{}) 78 | assert.Equal(t, "alfred.workflow.input.keyword", keyword["type"]) 79 | assert.Equal(t, "the-keyword", config["keyword"]) 80 | assert.True(t, config["withspace"].(bool)) 81 | assert.Equal(t, "Keyword", config["text"]) 82 | assert.Equal(t, "keyword", config["subtext"]) 83 | assert.Equal(t, uint64(2), config["argumenttype"]) 84 | 85 | scriptfilter := sortedObjs[4] 86 | config = scriptfilter["config"].(map[string]interface{}) 87 | assert.Equal(t, "alfred.workflow.input.scriptfilter", scriptfilter["type"]) 88 | assert.Equal(t, readFile(filepath.Join(dir, "img/alpaca.png")), readFile(filepath.Join(zipOut, scriptfilter["uid"].(string)+".png"))) 89 | assert.Equal(t, uint64(1), config["argumenttype"]) 90 | assert.Equal(t, uint64(1), config["argumenttrimmode"]) 91 | assert.Equal(t, uint64(33), config["escaping"]) 92 | assert.True(t, config["argumenttreatemptyqueryasnil"].(bool)) 93 | assert.Equal(t, "filter", config["keyword"]) 94 | assert.Equal(t, "Please wait...", config["runningsubtext"]) 95 | assert.Equal(t, "Runs a script filter", config["subtext"]) 96 | assert.Equal(t, "Run script-filter", config["title"]) 97 | assert.True(t, config["withspace"].(bool)) 98 | assert.Equal(t, uint64(1), config["scriptargtype"]) 99 | assert.Equal(t, "scripts/script.js", config["scriptfile"]) 100 | assert.True(t, config["alfredfiltersresults"].(bool)) 101 | assert.Equal(t, uint64(2), config["alfredfiltersresultsmatchmode"]) 102 | assert.True(t, config["queuedelayimmediatelyinitially"].(bool)) 103 | assert.Equal(t, uint64(1), config["queuemode"]) 104 | assert.Equal(t, uint64(1), config["queuedelaymode"]) 105 | 106 | clipboard := sortedObjs[5] 107 | config = clipboard["config"].(map[string]interface{}) 108 | assert.Equal(t, "alfred.workflow.output.clipboard", clipboard["type"]) 109 | assert.Equal(t, "{query}", config["clipboardtext"]) 110 | 111 | // Test connections 112 | assert.Equal(t, applescript["uid"], i.Connections[clipboard["uid"].(string)][0].To) 113 | assert.Equal(t, applescript["uid"], i.Connections[keyword["uid"].(string)][0].To) 114 | assert.Equal(t, clipboard["uid"], i.Connections[scriptfilter["uid"].(string)][0].To) 115 | 116 | // Test UI data 117 | assert.Equal(t, int64(20), i.UIData[openurl["uid"].(string)].XPos) 118 | assert.Equal(t, int64(20), i.UIData[openurl["uid"].(string)].YPos) 119 | 120 | assert.Equal(t, int64(20), i.UIData[script["uid"].(string)].XPos) 121 | assert.Equal(t, int64(145), i.UIData[script["uid"].(string)].YPos) 122 | 123 | assert.Equal(t, int64(20), i.UIData[keyword["uid"].(string)].XPos) 124 | assert.Equal(t, int64(270), i.UIData[keyword["uid"].(string)].YPos) 125 | 126 | assert.Equal(t, int64(20), i.UIData[scriptfilter["uid"].(string)].XPos) 127 | assert.Equal(t, int64(395), i.UIData[scriptfilter["uid"].(string)].YPos) 128 | 129 | assert.Equal(t, int64(265), i.UIData[clipboard["uid"].(string)].XPos) 130 | assert.Equal(t, int64(20), i.UIData[clipboard["uid"].(string)].YPos) 131 | 132 | assert.Equal(t, int64(510), i.UIData[applescript["uid"].(string)].XPos) 133 | assert.Equal(t, int64(20), i.UIData[applescript["uid"].(string)].YPos) 134 | } 135 | 136 | func packWorkflow(dir string) string { 137 | out = mktemp() 138 | packCmd.Run(&cobra.Command{}, []string{dir}) 139 | return out 140 | } 141 | 142 | func mktemp() string { 143 | temp, err := ioutil.TempDir("", "") 144 | if err != nil { 145 | panic(err) 146 | } 147 | return temp 148 | } 149 | 150 | func unzip(path string) string { 151 | out := mktemp() 152 | zip := archiver.NewZip() 153 | if err := zip.Unarchive(path, out); err != nil { 154 | panic(err) 155 | } 156 | return out 157 | } 158 | 159 | func readFile(path string) []byte { 160 | bytes, err := ioutil.ReadFile(path) 161 | if err != nil { 162 | panic(err) 163 | } 164 | return bytes 165 | } 166 | 167 | func printJson(x interface{}) { 168 | j, _ := json.Marshal(x) 169 | fmt.Println(string(j)) 170 | } 171 | -------------------------------------------------------------------------------- /app/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var rootCmd = cobra.Command{ 10 | Use: "alpaca", 11 | Short: "Alpaca is a packaging utility for Alfred workflows", 12 | Long: `A package utility for Alfred workflows built by @jclem in Go 13 | 14 | Documentation at https://github.com/jclem/alpaca`, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | cmd.Help() 17 | }, 18 | } 19 | 20 | // Execute executes the root command. 21 | func Execute() { 22 | if err := rootCmd.Execute(); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jclem/alpaca/app/version" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func init() { 11 | rootCmd.AddCommand(versionCmd) 12 | } 13 | 14 | var versionCmd = &cobra.Command{ 15 | Use: "version", 16 | Short: "Print the version number of Alpaca", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fmt.Println(version.Version) 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /app/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version denotes the current version of Alpaca 4 | var Version = "1.2.0" 5 | -------------------------------------------------------------------------------- /config/applescript.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/fatih/structs" 5 | yaml "gopkg.in/yaml.v3" 6 | ) 7 | 8 | // AppleScript is an Alfred action that runs NSAppleScript 9 | type AppleScript struct { 10 | Cache bool `yaml:"cache" structs:"cachescript"` 11 | Content string `yaml:"content" structs:"-"` 12 | Script ScriptConfig `yaml:"script" structs:"-"` 13 | } 14 | 15 | func (a *AppleScript) UnmarshalYAML(node *yaml.Node) error { 16 | type alias AppleScript 17 | as := alias{Cache: true} 18 | if err := node.Decode(&as); err != nil { 19 | return err 20 | } 21 | 22 | *a = AppleScript(as) 23 | 24 | return nil 25 | } 26 | 27 | func (a AppleScript) ToWorkflowConfig() map[string]interface{} { 28 | m := structs.Map(a) 29 | m["applescript"] = a.Script.Content 30 | m["applescript"] = a.Content 31 | return m 32 | } 33 | -------------------------------------------------------------------------------- /config/clipboard.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/fatih/structs" 5 | yaml "gopkg.in/yaml.v3" 6 | ) 7 | 8 | // Clipboard is an object that copies to the clipboard 9 | type Clipboard struct { 10 | Text string `yaml:"text" structs:"clipboardtext"` 11 | } 12 | 13 | func (c *Clipboard) UnmarshalYAML(node *yaml.Node) error { 14 | type alias Clipboard 15 | as := alias{Text: "{query}"} 16 | if err := node.Decode(&as); err != nil { 17 | return err 18 | } 19 | 20 | *c = Clipboard(as) 21 | 22 | return nil 23 | } 24 | 25 | func (c Clipboard) ToWorkflowConfig() map[string]interface{} { 26 | return structs.Map(c) 27 | } 28 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | // Config is a parsed alpaca.json file. 11 | type Config struct { 12 | Author string 13 | BundleID string `yaml:"bundle-id"` 14 | Description string `yaml:"description"` 15 | Icon string `yaml:"icon"` 16 | Name string `yaml:"name"` 17 | Objects ObjectMap `yaml:"objects"` 18 | Readme string `yaml:"readme"` 19 | URL string `yaml:"url"` 20 | Variables map[string]string `yaml:"variables"` 21 | Version string `yaml:"version"` 22 | } 23 | 24 | // ObjectMap is a mapping of object names to objects 25 | type ObjectMap map[string]Object 26 | 27 | // ThenList is a list of Then structs 28 | type ThenList []Then 29 | 30 | func (l *ThenList) UnmarshalYAML(node *yaml.Node) error { 31 | var s string 32 | if err := node.Decode(&s); err == nil { 33 | *l = ThenList{Then{Object: s}} 34 | return nil 35 | } 36 | 37 | type alias ThenList 38 | var as alias 39 | if err := node.Decode(&as); err != nil { 40 | return err 41 | } 42 | 43 | *l = ThenList(as) 44 | 45 | return nil 46 | } 47 | 48 | // Then is an object following another object. 49 | type Then struct { 50 | Object string `yaml:"object"` 51 | } 52 | 53 | func (t *Then) UnmarshalYAML(node *yaml.Node) error { 54 | var s string 55 | if err := node.Decode(&s); err == nil { 56 | t.Object = s 57 | return nil 58 | } 59 | 60 | type alias Then 61 | var as alias 62 | if err := node.Decode(&as); err != nil { 63 | return err 64 | } 65 | 66 | *t = Then(as) 67 | 68 | return nil 69 | } 70 | 71 | // UnmarshalYAML unmarshals an object. 72 | func (o *ObjectMap) UnmarshalYAML(node *yaml.Node) error { 73 | var m map[string]Object 74 | if err := node.Decode(&m); err != nil { 75 | return err 76 | } 77 | 78 | *o = make(ObjectMap) 79 | 80 | for name, obj := range m { 81 | obj.Name = name 82 | (*o)[obj.Name] = obj 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // Read parses an alpaca.json file. 89 | func Read(path string) (*Config, error) { 90 | file, err := os.Open(path) 91 | if err != nil { 92 | return nil, err 93 | } 94 | defer file.Close() 95 | 96 | bytes, err := ioutil.ReadAll(file) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | var config Config 102 | if err := yaml.Unmarshal(bytes, &config); err != nil { 103 | return nil, err 104 | } 105 | 106 | return &config, nil 107 | } 108 | -------------------------------------------------------------------------------- /config/keyword.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/fatih/structs" 5 | yaml "gopkg.in/yaml.v3" 6 | ) 7 | 8 | type keywordArgumentType string 9 | 10 | var ( 11 | keywordArgumentRequired keywordArgumentType = "required" 12 | keywordArgumentOptional keywordArgumentType = "optional" 13 | keywordArgumentNone keywordArgumentType = "none" 14 | ) 15 | 16 | var argumentType = map[keywordArgumentType]int64{ 17 | "required": 0, 18 | "optional": 1, 19 | "none": 2, 20 | } 21 | 22 | // Keyword is an object triggered by a keyword 23 | type Keyword struct { 24 | Keyword string `yaml:"keyword" structs:"keyword"` 25 | WithSpace bool `yaml:"with-space" structs:"withspace"` 26 | Title string `yaml:"title" structs:"text"` 27 | Subtitle string `yaml:"subtitle" structs:"subtext"` 28 | Argument keywordArgumentType `yaml:"argument" structs:"argumenttype"` 29 | } 30 | 31 | func (k *Keyword) UnmarshalYAML(node *yaml.Node) error { 32 | type alias Keyword 33 | as := alias{WithSpace: true} 34 | if err := node.Decode(&as); err != nil { 35 | return err 36 | } 37 | 38 | *k = Keyword(as) 39 | 40 | return nil 41 | } 42 | 43 | func (k Keyword) ToWorkflowConfig() map[string]interface{} { 44 | m := structs.Map(k) 45 | m["argumenttype"] = argumentType[k.Argument] 46 | return m 47 | } 48 | -------------------------------------------------------------------------------- /config/object.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/structs" 8 | "github.com/google/uuid" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // ObjectType denotes the general behavior of an Alfred object 13 | type ObjectType string 14 | 15 | var ( 16 | AppleScriptType ObjectType = "applescript" 17 | ClipboardType ObjectType = "clipboard" 18 | KeywordType ObjectType = "keyword" 19 | OpenURLType ObjectType = "open-url" 20 | ScriptType ObjectType = "script" 21 | ScriptFilterType ObjectType = "script-filter" 22 | UnknownType ObjectType = "unknown" 23 | ) 24 | 25 | // Object is an object in an Alfred workflow 26 | type Object struct { 27 | Name string `yaml:"-" structs:"-"` 28 | Icon string `yaml:"icon" structs:"-"` 29 | Type ObjectType `yaml:"type" structs:"-"` 30 | UID string `yaml:"-" structs:"uid"` 31 | Then ThenList `yaml:"then" structs:"-"` 32 | Version int64 `yaml:"version" structs:"version"` 33 | Config ObjectConfig `yaml:"config" structs:"-"` 34 | } 35 | 36 | func (o *Object) UnmarshalYAML(node *yaml.Node) error { 37 | uuid, err := uuid.NewRandom() 38 | if err != nil { 39 | return err 40 | } 41 | o.UID = strings.ToUpper(uuid.String()) 42 | 43 | var proxy struct { 44 | Icon string 45 | Type ObjectType 46 | Then ThenList 47 | Version int64 48 | Config map[string]interface{} 49 | } 50 | 51 | if err := node.Decode(&proxy); err != nil { 52 | return err 53 | } 54 | 55 | o.Icon = proxy.Icon 56 | o.Type = proxy.Type 57 | o.Then = proxy.Then 58 | o.Version = proxy.Version 59 | 60 | rawConfig, err := yaml.Marshal(proxy.Config) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | switch o.Type { 66 | case AppleScriptType: 67 | var cfg AppleScript 68 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 69 | return err 70 | } 71 | o.Config = cfg 72 | case ClipboardType: 73 | var cfg Clipboard 74 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 75 | return err 76 | } 77 | o.Config = cfg 78 | case KeywordType: 79 | var cfg Keyword 80 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 81 | return err 82 | } 83 | o.Config = cfg 84 | case OpenURLType: 85 | var cfg OpenURL 86 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 87 | return err 88 | } 89 | o.Config = cfg 90 | case ScriptType: 91 | var cfg Script 92 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 93 | return err 94 | } 95 | o.Config = cfg 96 | case ScriptFilterType: 97 | var cfg ScriptFilter 98 | if err := yaml.Unmarshal(rawConfig, &cfg); err != nil { 99 | return err 100 | } 101 | o.Config = cfg 102 | default: 103 | fmt.Println(o.Type) 104 | } 105 | 106 | return nil 107 | } 108 | 109 | var objectType = map[ObjectType]string{ 110 | "applescript": "alfred.workflow.action.applescript", 111 | "clipboard": "alfred.workflow.output.clipboard", 112 | "keyword": "alfred.workflow.input.keyword", 113 | "open-url": "alfred.workflow.action.openurl", 114 | "script": "alfred.workflow.action.script", 115 | "script-filter": "alfred.workflow.input.scriptfilter", 116 | } 117 | 118 | func (o Object) ToWorkflowConfig() map[string]interface{} { 119 | m := structs.Map(o) 120 | m["type"] = objectType[o.Type] 121 | m["config"] = o.Config.ToWorkflowConfig() 122 | return m 123 | } 124 | 125 | // ObjectConfig is a general configuration for an object 126 | type ObjectConfig interface { 127 | ToWorkflowConfig() map[string]interface{} 128 | } 129 | 130 | var scriptType = map[string]int64{ 131 | "bash": 0, 132 | "php": 1, 133 | "ruby": 2, 134 | "python": 3, 135 | "perl": 4, 136 | "zsh": 5, 137 | "osascript-as": 6, 138 | "osascript-js": 7, 139 | "external": 8, 140 | } 141 | 142 | var scriptArgType = map[string]int64{ 143 | "query": 0, 144 | "argv": 1, 145 | } 146 | 147 | // ScriptConfig is a runnable script in a workflow. 148 | type ScriptConfig struct { 149 | ArgType string `yaml:"arg-type" structs"-"` 150 | Content string `yaml:"content" structs:"script"` 151 | Path string `yaml:"path" structs:"scriptfile"` 152 | Type string `yaml:"type" structs:"-"` 153 | } 154 | 155 | func (s ScriptConfig) ToWorkflowConfig() map[string]interface{} { 156 | m := structs.Map(s) 157 | 158 | if s.Type == "" { 159 | m["type"] = scriptType["external"] 160 | } else { 161 | m["type"] = scriptType[s.Type] 162 | } 163 | 164 | if s.ArgType == "" { 165 | m["scriptargtype"] = scriptArgType["argv"] 166 | } else { 167 | m["scriptargtype"] = scriptArgType[s.ArgType] 168 | } 169 | 170 | return m 171 | } 172 | -------------------------------------------------------------------------------- /config/openurl.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/fatih/structs" 4 | 5 | // OpenURL is an object that opens a URL in a browser 6 | type OpenURL struct { 7 | URL string `yaml:"url" structs:"url"` 8 | } 9 | 10 | func (o OpenURL) ToWorkflowConfig() map[string]interface{} { 11 | return structs.Map(o) 12 | } 13 | -------------------------------------------------------------------------------- /config/script.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Script is an Alfred action that runs a script 4 | type Script struct { 5 | Script ScriptConfig 6 | } 7 | 8 | func (s Script) ToWorkflowConfig() map[string]interface{} { 9 | return s.Script.ToWorkflowConfig() 10 | } 11 | -------------------------------------------------------------------------------- /config/scriptfilter.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/fatih/structs" 5 | yaml "gopkg.in/yaml.v3" 6 | ) 7 | 8 | var alfredMatchMode = map[string]int64{ 9 | "exact-boundary": 0, 10 | "exact-start": 1, 11 | "word-match": 2, 12 | } 13 | 14 | var argumentTrim = map[string]int64{ 15 | "auto": 0, 16 | "off": 1, 17 | } 18 | 19 | var escaping = map[string]int64{ 20 | "spaces": 1, 21 | "backquotes": 2, 22 | "double-quote": 4, 23 | "brackets": 8, 24 | "semicolons": 16, 25 | "dollars": 32, 26 | "backslashes": 64, 27 | } 28 | 29 | var queueMode = map[string]int64{ 30 | "wait": 1, 31 | "terminate": 2, 32 | } 33 | 34 | var queueDelayCustom = map[string]int64{ 35 | "100ms": 1, 36 | "200ms": 2, 37 | "300ms": 3, 38 | "400ms": 4, 39 | "500ms": 5, 40 | "600ms": 6, 41 | "700ms": 7, 42 | "800ms": 8, 43 | "900ms": 9, 44 | "1000ms": 10, 45 | } 46 | 47 | // ScriptFilter is an Alfred filter that runs a script 48 | type ScriptFilter struct { 49 | Argument keywordArgumentType `yaml:"argument" structs:"argumenttype"` 50 | ArgumentTrim string `yaml:"argument-trim" structs:"-"` 51 | Escaping []string `yaml:"escaping" structs:"-"` 52 | IgnoreEmptyArgument bool `yaml:"ignore-empty-argument" structs:"-"` 53 | Keyword string `yaml:"keyword" structs:"keyword"` 54 | RunningSubtitle string `yaml:"running-subtitle" structs:"runningsubtext"` 55 | Subtitle string `yaml:"subtitle" structs:"subtext"` 56 | Title string `yaml:"title" structs:"title"` 57 | WithSpace bool `yaml:"with-space" structs:"withspace"` 58 | Script ScriptConfig `yaml:"script" structs:"-"` 59 | AlfredFilters *struct { 60 | Mode string `yaml:"mode"` 61 | } `yaml:"alfred-filters-results" structs:"-"` 62 | RunBehavior *struct { 63 | Immediate bool `yaml:"immediate"` 64 | QueueMode string `yaml:"queue-mode"` 65 | QueueDelay string `yaml:"queue-delay"` 66 | } `yaml:"run-behavior" structs:"-"` 67 | } 68 | 69 | func (s *ScriptFilter) UnmarshalYAML(node *yaml.Node) error { 70 | type alias ScriptFilter 71 | as := alias{WithSpace: true} 72 | if err := node.Decode(&as); err != nil { 73 | return err 74 | } 75 | 76 | *s = ScriptFilter(as) 77 | 78 | return nil 79 | } 80 | 81 | func (s ScriptFilter) ToWorkflowConfig() map[string]interface{} { 82 | m := structs.Map(s) 83 | sMap := s.Script.ToWorkflowConfig() 84 | for k, v := range sMap { 85 | m[k] = v 86 | } 87 | 88 | m["argumenttype"] = argumentType[s.Argument] 89 | m["argumenttrimmode"] = argumentTrim[s.ArgumentTrim] 90 | m["argumenttreatemptyqueryasnil"] = s.IgnoreEmptyArgument 91 | 92 | // Filters 93 | if s.AlfredFilters != nil { 94 | m["alfredfiltersresults"] = true 95 | m["alfredfiltersresultsmatchmode"] = alfredMatchMode[s.AlfredFilters.Mode] 96 | } 97 | 98 | // Escaping 99 | var escapingSum int64 100 | for _, esc := range s.Escaping { 101 | escapingSum = escapingSum + escaping[esc] 102 | } 103 | m["escaping"] = escapingSum 104 | 105 | if s.RunBehavior != nil { 106 | // Run behavior 107 | m["queuedelayimmediatelyinitially"] = s.RunBehavior.Immediate 108 | m["queuemode"] = queueMode[s.RunBehavior.QueueMode] 109 | 110 | // Queue Delay 111 | if s.RunBehavior.QueueDelay == "immediate" { 112 | m["queuedelaymode"] = 0 113 | } else if s.RunBehavior.QueueDelay == "automatic" { 114 | m["queuedelaymode"] = 1 115 | } else if s.RunBehavior.QueueDelay != "" { 116 | m["queuedelaymode"] = 2 117 | m["queuedelaycustom"] = queueDelayCustom[s.RunBehavior.QueueDelay] 118 | } 119 | } 120 | 121 | return m 122 | } 123 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jclem/alpaca 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/dsnet/compress v0.0.1 // indirect 7 | github.com/fatih/structs v1.1.0 8 | github.com/frankban/quicktest v1.4.1 // indirect 9 | github.com/golang/snappy v0.0.1 // indirect 10 | github.com/google/uuid v1.1.1 11 | github.com/groob/plist v0.0.0-20190114192801-a99fbe489d03 12 | github.com/mholt/archiver v3.1.1+incompatible 13 | github.com/nwaples/rardecode v1.0.0 // indirect 14 | github.com/pierrec/lz4 v2.2.6+incompatible // indirect 15 | github.com/pkg/errors v0.8.1 16 | github.com/spf13/cobra v0.0.5 17 | github.com/stretchr/testify v1.2.2 18 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 19 | gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 4 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 5 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 6 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 7 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 11 | github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 12 | github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= 13 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 14 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 15 | github.com/frankban/quicktest v1.4.1 h1:Wv2VwvNn73pAdFIVUQRXYDFp31lXKbqblIXo/Q5GPSg= 16 | github.com/frankban/quicktest v1.4.1/go.mod h1:36zfPVQyHxymz4cH7wlDmVwDrJuljRB60qkgn7rorfQ= 17 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 18 | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= 19 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 20 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 21 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 22 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 23 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 24 | github.com/groob/plist v0.0.0-20190114192801-a99fbe489d03 h1:z4Na/Ihs7LelUWfkSkr3sixCMwF3Ln1a/3K4eXynhBg= 25 | github.com/groob/plist v0.0.0-20190114192801-a99fbe489d03/go.mod h1:qg2Nek0ND/hIr+nY8H1oVqEW2cLzVVNaAQ0QexOyjyc= 26 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 27 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 28 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 29 | github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 30 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 31 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 32 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 33 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 34 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 35 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 36 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 37 | github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= 38 | github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= 39 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 40 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 41 | github.com/nwaples/rardecode v1.0.0 h1:r7vGuS5akxOnR4JQSkko62RJ1ReCMXxQRPtxsiFMBOs= 42 | github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= 43 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 44 | github.com/pierrec/lz4 v2.2.6+incompatible h1:6aCX4/YZ9v8q69hTyiR7dNLnTA3fgtKHVVW5BCd5Znw= 45 | github.com/pierrec/lz4 v2.2.6+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 46 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 47 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 51 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 52 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 53 | github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= 54 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 55 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 56 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 57 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 58 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 59 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 60 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 61 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 62 | github.com/ulikunitz/xz v0.5.6 h1:jGHAfXawEGZQ3blwU5wnWKQJvAraT7Ftq9EXjnXYgt8= 63 | github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 64 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= 65 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 66 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 67 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 68 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 69 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 73 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 74 | gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22 h1:0efs3hwEZhFKsCoP8l6dDB1AZWMgnEl3yWXWRZTOaEA= 75 | gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/jclem/alpaca/app/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /project/build.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/groob/plist" 12 | "github.com/jclem/alpaca/config" 13 | "github.com/jclem/alpaca/workflow" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | // Build builds an Alpaca project into the given targetPath 18 | func Build(projectDir string, targetDir string) error { 19 | cfg, err := readConfig(projectDir) 20 | if err != nil { 21 | return errors.Wrap(err, "Unable to read project config") 22 | } 23 | 24 | targetPath := filepath.Join(targetDir, fmt.Sprintf("%s.alfredworkflow", cfg.Name)) 25 | 26 | workflowFile, err := os.Create(targetPath) 27 | if err != nil { 28 | return errors.Wrap(err, "Error creating workflow package file") 29 | } 30 | defer workflowFile.Close() 31 | 32 | archive := zip.NewWriter(workflowFile) 33 | defer archive.Close() 34 | 35 | writer, err := archive.Create("info.plist") 36 | if err != nil { 37 | return errors.Wrap(err, "Error creating plist file in archive") 38 | } 39 | 40 | info, err := workflow.NewFromConfig(projectDir, *cfg) 41 | if err != nil { 42 | return errors.Wrap(err, "Error creating worfklow from configuration") 43 | } 44 | plistBytes, err := plist.MarshalIndent(info, "\t") 45 | if err != nil { 46 | return errors.Wrap(err, "Error marshalling info plist") 47 | } 48 | 49 | writer.Write(plistBytes) 50 | 51 | if cfg.Icon != "" { 52 | src := filepath.Join(projectDir, cfg.Icon) 53 | ext := filepath.Ext(src) 54 | 55 | if ext != ".png" { 56 | return fmt.Errorf("Workflow icon must be a .png, got %q", ext) 57 | } 58 | 59 | dst := fmt.Sprintf("%s%s", "icon", ext) 60 | if err := copyFile(src, dst, archive); err != nil { 61 | return errors.Wrap(err, "Error copying file") 62 | } 63 | } 64 | 65 | for _, obj := range cfg.Objects { 66 | if obj.Icon == "" { 67 | continue 68 | } 69 | 70 | src := filepath.Join(projectDir, obj.Icon) 71 | ext := filepath.Ext(src) 72 | if ext != ".png" { 73 | return fmt.Errorf("Object icon must be a .png, got %q", ext) 74 | } 75 | 76 | dst := fmt.Sprintf("%s%s", obj.UID, ext) 77 | if err := copyFile(src, dst, archive); err != nil { 78 | return errors.Wrap(err, "Error copying file") 79 | } 80 | } 81 | 82 | if err := filepath.Walk(projectDir, func(filePath string, info os.FileInfo, err error) error { 83 | if info.IsDir() { 84 | return nil 85 | } 86 | 87 | if filePath == targetPath { 88 | return nil 89 | } 90 | 91 | if strings.HasSuffix(filePath, "info.plist") { 92 | return nil 93 | } 94 | 95 | if err != nil { 96 | return err 97 | } 98 | 99 | header, err := zip.FileInfoHeader(info) 100 | if err != nil { 101 | return err 102 | } 103 | name := strings.TrimPrefix(filePath, projectDir+"/") 104 | header.Name = name 105 | 106 | writer, err := archive.CreateHeader(header) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | file, err := os.Open(filePath) 112 | if err != nil { 113 | return err 114 | } 115 | defer file.Close() 116 | if _, err := io.Copy(writer, file); err != nil { 117 | return err 118 | } 119 | 120 | return nil 121 | }); err != nil { 122 | return errors.Wrap(err, "Unable to create archive") 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func copyFile(from string, to string, archive *zip.Writer) error { 129 | info, err := os.Stat(from) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | header, err := zip.FileInfoHeader(info) 135 | header.Name = to 136 | 137 | writer, err := archive.CreateHeader(header) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | file, err := os.Open(from) 143 | if err != nil { 144 | return err 145 | } 146 | defer file.Close() 147 | 148 | if _, err := io.Copy(writer, file); err != nil { 149 | return err 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func readConfig(dir string) (*config.Config, error) { 156 | // Try .yaml first 157 | filePath := filepath.Join(dir, "alpaca.yaml") 158 | cfg, err := config.Read(filePath) 159 | 160 | if err != nil { 161 | if _, ok := err.(*os.PathError); ok { 162 | // Then try .yml 163 | filePath = filepath.Join(dir, "alpaca.yml") 164 | cfg, err = config.Read(filePath) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | return cfg, nil 170 | } 171 | 172 | return nil, err 173 | } 174 | 175 | return cfg, nil 176 | } 177 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | # Recreate build directory 6 | if test -d build; then rm -r build/; fi 7 | 8 | mkdir build 9 | 10 | GOOS=linux GOARCH=amd64 go build 11 | tar czvf build/alpaca-$1-for-Linux-64-bit.tar.gz alpaca 12 | rm alpaca 13 | 14 | GOOS=darwin GOARCH=amd64 go build 15 | tar czvf build/alpaca-$1-for-macOS.tar.gz alpaca 16 | rm alpaca -------------------------------------------------------------------------------- /workflow/uidata.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import "sort" 4 | 5 | const ( 6 | xPadding = 20 7 | yPadding = 20 8 | xGap = 245 9 | yGap = 125 10 | ) 11 | 12 | // uidata represents all uidata of a workflow. 13 | type uidata = map[string]uidatum 14 | 15 | // uidatum represents the position of an object. 16 | type uidatum struct { 17 | XPos int64 `plist:"xpos,omitempty"` 18 | YPos int64 `plist:"ypos,omitempty"` 19 | } 20 | 21 | func (i *Info) buildUIData() { 22 | i.UIData = make(uidata) 23 | 24 | // A map of object depths to object UIDs at that depth 25 | depthMap := make(map[int64][]string) 26 | 27 | // Sort for testing stability 28 | sortedObjs := make([]map[string]interface{}, len(i.Objects)) 29 | copy(sortedObjs, i.Objects) 30 | sort.Slice(sortedObjs, func(i, j int) bool { 31 | iType := sortedObjs[i]["type"].(string) 32 | jType := sortedObjs[j]["type"].(string) 33 | return iType < jType 34 | }) 35 | 36 | for _, obj := range sortedObjs { 37 | uid := obj["uid"].(string) 38 | depth := findDepth(uid, i.Connections) 39 | depthMap[depth] = append(depthMap[depth], uid) 40 | } 41 | 42 | for depth, uids := range depthMap { 43 | for idx, uid := range uids { 44 | xpos := int64(xPadding + depth*xGap) 45 | ypos := int64(yPadding + idx*yGap) 46 | 47 | i.UIData[uid] = uidatum{ 48 | XPos: xpos, 49 | YPos: ypos, 50 | } 51 | } 52 | } 53 | } 54 | 55 | func findDepth(uid string, conns map[string][]Connection) int64 { 56 | pointers := make([]string, 0) 57 | 58 | for connUID, conns := range conns { 59 | for _, conn := range conns { 60 | if conn.To == uid { 61 | pointers = append(pointers, connUID) 62 | } 63 | } 64 | } 65 | 66 | depth := int64(0) 67 | 68 | for _, pointer := range pointers { 69 | pointerDepth := findDepth(pointer, conns) 70 | if pointerDepth >= depth { 71 | depth = pointerDepth + 1 72 | } 73 | } 74 | 75 | return depth 76 | } 77 | -------------------------------------------------------------------------------- /workflow/workflow.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jclem/alpaca/config" 7 | ) 8 | 9 | // Info represents an info.plist in a workflow. 10 | type Info struct { 11 | BundleID string `plist:"bundleid,omitempty"` 12 | Connections map[string][]Connection `plist:"connections,omitempty"` 13 | CreatedBy string `plist:"createdby,omitempty"` 14 | Description string `plist:"description,omitempty"` 15 | Name string `plist:"name,omitempty"` 16 | Objects []map[string]interface{} `plist:"objects,omitempty"` 17 | Readme string `plist:"readme,omitempty"` 18 | UIData uidata `plist:"uidata,omitempty"` 19 | WebAddress string `plist:"webaddress,omitempty"` 20 | Variables map[string]string `plist:"variables,omitempty"` 21 | VariablesDontExport []string `plist:"variablesdontexport,omitempty"` 22 | Version string `plist:"version,omitempty"` 23 | path string 24 | } 25 | 26 | // NewFromConfig creates a new Info struct from an Alpaca config struct. 27 | func NewFromConfig(path string, c config.Config) (*Info, error) { 28 | i := Info{ 29 | BundleID: c.BundleID, 30 | Connections: map[string][]Connection{}, 31 | CreatedBy: c.Author, 32 | Description: c.Description, 33 | Name: c.Name, 34 | Readme: c.Readme, 35 | WebAddress: c.URL, 36 | Version: c.Version, 37 | Variables: c.Variables, 38 | } 39 | 40 | for varName := range i.Variables { 41 | i.VariablesDontExport = append(i.VariablesDontExport, varName) 42 | } 43 | 44 | // Build workflow connections. 45 | for _, cfgObj := range c.Objects { 46 | for _, then := range cfgObj.Then { 47 | conns, ok := i.Connections[cfgObj.UID] 48 | if !ok { 49 | i.Connections[cfgObj.UID] = []Connection{} 50 | } 51 | 52 | // Find the UID for the object we're connecting to. 53 | var uid string 54 | for _, cfgObj := range c.Objects { 55 | if cfgObj.Name == then.Object { 56 | uid = cfgObj.UID 57 | break 58 | } 59 | } 60 | if uid == "" { 61 | return nil, fmt.Errorf("Could not find object %q", then.Object) 62 | } 63 | 64 | i.Connections[cfgObj.UID] = append(conns, Connection{ 65 | To: uid, 66 | }) 67 | } 68 | } 69 | 70 | // Build workflow objects. 71 | for _, cfgObj := range c.Objects { 72 | obj := cfgObj.ToWorkflowConfig() 73 | i.Objects = append(i.Objects, obj) 74 | } 75 | 76 | i.buildUIData() 77 | 78 | return &i, nil 79 | } 80 | 81 | // Connection is a line between two objects. 82 | type Connection struct { 83 | To string `plist:"destinationuid,omitempty"` 84 | Modifiers int64 `plist:"modifiers,omitempty"` 85 | ModifierSubtext string `plist:"modifiersubtext,omitempty"` 86 | VetoClose bool `plist:"vitoclose,omitempty"` // NOTE: Yes, "vitoclose" 87 | } 88 | 89 | // Object is an object in an Alfred workflow. 90 | type Object struct { 91 | Config Config `plist:"config,omitempty"` 92 | Type string `plist:"type,omitempty"` 93 | UID string `plist:"uid,omitempty"` 94 | Version int64 `plist:"version,omitempty"` 95 | } 96 | 97 | // Config is a generic object configuration object. 98 | type Config map[string]interface{} 99 | --------------------------------------------------------------------------------