├── .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/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 |
--------------------------------------------------------------------------------