├── .gitignore ├── .travis.yml ├── Gemfile ├── README.md ├── Rakefile ├── alfred-3_workflow.gemspec ├── lib ├── alfred-3_workflow.rb └── result.rb └── test └── workflow_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.0 4 | - 2.1.0 5 | - 2.0.0 6 | - 1.9.3 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem 'rake', :group => :test 3 | gem 'minitest', '~> 5.9.0', :group => :test 4 | gemspec 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby Helper for Alfred 3 Workflows 2 | 3 | [![Latest Version](https://img.shields.io/github/tag/joetannenbaum/alfred-workflow-ruby.svg?style=flat&label=release)](https://github.com/joetannenbaum/alfred-workflow-ruby/tags) 4 | [![Build Status](https://travis-ci.org/joetannenbaum/alfred-workflow-ruby.svg?branch=master)](https://travis-ci.org/joetannenbaum/alfred-workflow-ruby) 5 | 6 | This package simplifies Ruby development for **Alfred 3** workflows. 7 | 8 | ## Installation 9 | 10 | ``` 11 | gem install alfred-3_workflow 12 | ``` 13 | 14 | ## Usage 15 | 16 | To understand the following properties, please reference the [official Alfred 3 documentation](https://www.alfredapp.com/help/workflows/inputs/script-filter/json/). 17 | 18 | The library is not doing any validation for required properties, so all of the following are optional. Please refer to the documentation above for required properties. All of the properties will default to the official defaults if excluded. 19 | 20 | ```ruby 21 | require 'alfred-3_workflow' 22 | 23 | workflow = Alfred3::Workflow.new 24 | 25 | workflow.result 26 | .uid('bob-belcher') 27 | .title('Bob') 28 | .subtitle('Head Burger Chef') 29 | .quicklookurl('http://www.bobsburgers.com') 30 | .type('default') 31 | .arg('bob') 32 | .valid(true) 33 | .icon('bob.png') 34 | .mod('cmd', 'Search for Bob', 'search') 35 | .text('copy', 'Bob is the best!') 36 | .autocomplete('Bob Belcher') 37 | 38 | workflow.result 39 | .uid('linda-belcher') 40 | .title('Linda') 41 | .subtitle('Wife') 42 | .quicklookurl('http://www.bobsburgers.com') 43 | .type('default') 44 | .arg('linda') 45 | .valid(true) 46 | .icon('linda.png') 47 | .mod('cmd', 'Search for Linda', 'search') 48 | .text('largetype', 'Linda is the best!') 49 | .autocomplete('Linda Belcher') 50 | 51 | print workflow.output 52 | ``` 53 | 54 | Results in: 55 | 56 | ```json 57 | { 58 | "items": [ 59 | { 60 | "arg": "bob", 61 | "autocomplete": "Bob Belcher", 62 | "icon": { 63 | "path": "bob.png" 64 | }, 65 | "mods": { 66 | "cmd": { 67 | "subtitle": "Search for Bob", 68 | "arg": "search", 69 | "valid": true 70 | } 71 | }, 72 | "quicklookurl": "http://www.bobsburgers.com", 73 | "subtitle": "Head Burger Chef", 74 | "text": { 75 | "copy": "Bob is the best!" 76 | }, 77 | "title": "Bob", 78 | "type": "default", 79 | "uid": "bob-belcher", 80 | "valid": true 81 | }, 82 | { 83 | "arg": "linda", 84 | "autocomplete": "Linda Belcher", 85 | "icon": { 86 | "path": "linda.png" 87 | }, 88 | "mods": { 89 | "cmd": { 90 | "subtitle": "Search for Linda", 91 | "arg": "search", 92 | "valid": true 93 | } 94 | }, 95 | "quicklookurl": "http://www.bobsburgers.com", 96 | "subtitle": "Wife", 97 | "text": { 98 | "largetype": "LINDA IS THE BEST!" 99 | }, 100 | "title": "Linda", 101 | "type": "default", 102 | "uid": "linda-belcher", 103 | "valid": true 104 | } 105 | ] 106 | } 107 | ``` 108 | 109 | ## Helper Methods 110 | 111 | Just for clarity, some helper methods exist. 112 | 113 | ```ruby 114 | # This... 115 | workflow.result.mod('cmd', 'Search for Bob', 'search') 116 | # ...is the same as this. 117 | workflow.result.cmd('Search for Bob', 'search') 118 | # And these are all available as well: 119 | workflow.result.shift('Search for Bob', 'search') 120 | workflow.result.fn('Search for Bob', 'search') 121 | workflow.result.ctrl('Search for Bob', 'search') 122 | workflow.result.alt('Search for Bob', 'search') 123 | ``` 124 | 125 | ```ruby 126 | # This... 127 | workflow.result.text('largetype', 'Linda is the best!') 128 | # ...is the same as this. 129 | workflow.result.largetype('Linda is the best!') 130 | # Also works: 131 | workflow.result.copy('Linda is the best!') 132 | ``` 133 | 134 | ```ruby 135 | # This... 136 | workflow.result.icon('bob.png', 'fileicon') 137 | # ...is the same as this. 138 | workflow.result.fileicon_icon('bob.png') 139 | # Also works: 140 | workflow.result.filetype_icon('bob.png') 141 | ``` 142 | 143 | ## Sorting 144 | 145 | If you'd like to sort your results: 146 | 147 | ```ruby 148 | # Default is by title asc: 149 | workflow.sort_results 150 | # Title desc: 151 | workflow.sort_results('desc') 152 | # By property asc: 153 | workflow.sort_results('asc', 'subtitle') 154 | ``` 155 | 156 | ## Filtering 157 | 158 | You can filter your results as well if Alfred isn't doing it for you: 159 | 160 | **Please note** this is a very simple filtering, literally looking for the string within the string. For anything more complex filter before creating results. 161 | 162 | ```ruby 163 | # Default is searching in title: 164 | workflow.filter_results('bob') 165 | # By property: 166 | workflow.filter_results('bob', 'subtitle') 167 | ``` 168 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | 3 | Rake::TestTask.new do |t| 4 | t.test_files = FileList['test/**/*_test.rb'] 5 | end 6 | 7 | desc "Run tests" 8 | 9 | task default: :test 10 | -------------------------------------------------------------------------------- /alfred-3_workflow.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'alfred-3_workflow' 3 | s.version = '0.1.0' 4 | s.date = '2016-05-21' 5 | s.summary = 'Helper library for creating Alfred 3 Workflows' 6 | s.description = 'Easily generate Alfred 3 workflow results in a fluent manner.' 7 | s.authors = ['Joe Tannenbaum'] 8 | s.email = 'joe@joe.codes' 9 | s.files = ['lib/alfred-3_workflow.rb', 'lib/result.rb'] 10 | s.homepage = 'https://github.com/joetannenbaum/alfred-workflow-ruby' 11 | s.license = 'MIT' 12 | end 13 | -------------------------------------------------------------------------------- /lib/alfred-3_workflow.rb: -------------------------------------------------------------------------------- 1 | require 'result' 2 | require 'json' 3 | 4 | module Alfred3 5 | class Workflow 6 | 7 | def initialize 8 | @results = [] 9 | end 10 | 11 | public 12 | def result 13 | result = Result.new 14 | @results << result 15 | result 16 | end 17 | 18 | public 19 | def sort_results(direction = 'asc', property = 'title') 20 | @results.sort! { |r1, r2| 21 | r1_prop = r1.instance_variable_get("@#{property}") 22 | r2_prop = r2.instance_variable_get("@#{property}") 23 | multiplier = direction === 'asc' ? 1 : -1 24 | (r1_prop <=> r2_prop) * multiplier 25 | } 26 | 27 | self 28 | end 29 | 30 | public 31 | def filter_results(query, property = 'title') 32 | query = query.to_s.strip.downcase 33 | 34 | return self if query.length === 0 35 | 36 | @results.select! { |result| 37 | result.instance_variable_get("@#{property}").downcase.include? query 38 | } 39 | 40 | self 41 | end 42 | 43 | public 44 | def output 45 | { 46 | items: @results.map { |result| 47 | result.to_hash 48 | } 49 | }.to_json 50 | end 51 | 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/result.rb: -------------------------------------------------------------------------------- 1 | class Result 2 | 3 | def initialize 4 | @arg = nil 5 | @autocomplete = nil 6 | @icon = nil 7 | @mods = {} 8 | @quicklookurl = nil 9 | @subtitle = nil 10 | @text = {} 11 | @title = nil 12 | @type = nil 13 | @uid = nil 14 | @valid = true 15 | 16 | @simple_values = [ 17 | 'arg', 18 | 'autocomplete', 19 | 'quicklookurl', 20 | 'subtitle', 21 | 'title', 22 | 'uid', 23 | ] 24 | 25 | @valid_values = { 26 | type: ['default', 'file', 'file:skipcheck'], 27 | icon: ['fileicon', 'filetype'], 28 | text: ['copy', 'largetype'], 29 | mod: ['shift', 'fn', 'ctrl', 'alt', 'cmd'] 30 | } 31 | end 32 | 33 | public 34 | def valid(valid) 35 | @valid = !!valid 36 | self 37 | end 38 | 39 | public 40 | def type(type, verify_existence = true) 41 | return self unless @valid_values[:type].include?(type.to_s) 42 | 43 | if type === 'file' && !verify_existence 44 | @type = 'file:skipcheck' 45 | else 46 | @type = type 47 | end 48 | 49 | self 50 | end 51 | 52 | public 53 | def icon(path, type = nil) 54 | @icon = { 55 | path: path 56 | } 57 | 58 | @icon[:type] = type if @valid_values[:icon].include?(type.to_s) 59 | 60 | self 61 | end 62 | 63 | public 64 | def fileicon_icon(path) 65 | icon(path, 'fileicon') 66 | end 67 | 68 | public 69 | def filetype_icon(path) 70 | icon(path, 'filetype') 71 | end 72 | 73 | public 74 | def text(type, text) 75 | return self unless @valid_values[:text].include?(type.to_s) 76 | 77 | @text[type.to_sym] = text 78 | 79 | self 80 | end 81 | 82 | public 83 | def mod(mod, subtitle, arg, valid = true) 84 | return self unless @valid_values[:mod].include?(mod.to_s) 85 | 86 | @mods[mod.to_sym] = { 87 | subtitle: subtitle, 88 | arg: arg, 89 | valid: valid 90 | } 91 | 92 | self 93 | end 94 | 95 | public 96 | def to_hash 97 | keys = [ 98 | 'arg', 99 | 'autocomplete', 100 | 'icon', 101 | 'mods', 102 | 'quicklookurl', 103 | 'subtitle', 104 | 'text', 105 | 'title', 106 | 'type', 107 | 'uid', 108 | 'valid', 109 | ] 110 | 111 | result = {} 112 | 113 | keys.each { |key| 114 | result[key.to_sym] = self.instance_variable_get("@#{key}") 115 | } 116 | 117 | result.select { |hash_key, hash_value| 118 | (hash_value.class.to_s === 'Hash' && !hash_value.empty?) || (hash_value.class.to_s != 'Hash' && !hash_value.nil?) 119 | } 120 | end 121 | 122 | def method_missing(method, *arguments) 123 | if @simple_values.include?(method.to_s) 124 | self.instance_variable_set("@#{method}", arguments.first) 125 | return self 126 | end 127 | 128 | if @valid_values[:mod].include?(method.to_s) 129 | return mod(method, *arguments) 130 | end 131 | 132 | if @valid_values[:text].include?(method.to_s) 133 | return text(method, *arguments) 134 | end 135 | 136 | super 137 | end 138 | 139 | def respond_to?(method, include_private = false) 140 | if @simple_values.include?(method.to_s) 141 | return true 142 | end 143 | 144 | if @valid_values[:mod].include?(method.to_s) 145 | return true 146 | end 147 | 148 | if @valid_values[:text].include?(method.to_s) 149 | return true 150 | end 151 | 152 | super 153 | end 154 | 155 | end 156 | -------------------------------------------------------------------------------- /test/workflow_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'alfred-3_workflow' 3 | 4 | class TestWorkflow < MiniTest::Test 5 | def setup 6 | @workflow = Alfred3::Workflow.new 7 | end 8 | 9 | def test_that_it_can_add_a_result 10 | @workflow.result.uid('THE ID') 11 | .title('Item Title') 12 | .subtitle('Item Subtitle') 13 | .quicklookurl('https://www.google.com') 14 | .type('file') 15 | .arg('ARGUMENT') 16 | .valid(false) 17 | .icon('icon.png') 18 | .mod('cmd', 'Do Something Different', 'something-different') 19 | .mod('shift', 'Another Different', 'another-different', false) 20 | .copy('Please copy this') 21 | .largetype('This will be huge') 22 | .autocomplete('AutoComplete This') 23 | 24 | expected = { 25 | 'items' => [ 26 | { 27 | 'arg' => 'ARGUMENT', 28 | 'autocomplete' => 'AutoComplete This', 29 | 'icon' => { 30 | 'path' => 'icon.png' 31 | }, 32 | 'mods' => { 33 | 'cmd' => { 34 | 'subtitle' => 'Do Something Different', 35 | 'arg' => 'something-different', 36 | 'valid' => true 37 | }, 38 | 'shift' => { 39 | 'subtitle' => 'Another Different', 40 | 'arg' => 'another-different', 41 | 'valid' => false 42 | }, 43 | }, 44 | 'quicklookurl' => 'https://www.google.com', 45 | 'subtitle' => 'Item Subtitle', 46 | 'text' => { 47 | 'copy' => 'Please copy this', 48 | 'largetype' => 'This will be huge' 49 | }, 50 | 'title' => 'Item Title', 51 | 'type' => 'file', 52 | 'uid' => 'THE ID', 53 | 'valid' => false 54 | } 55 | ] 56 | } 57 | 58 | assert_equal expected.to_json, @workflow.output 59 | end 60 | 61 | def test_that_it_can_add_multiple_results 62 | @workflow.result 63 | .uid('THE ID') 64 | .title('Item Title') 65 | .subtitle('Item Subtitle') 66 | .quicklookurl('https://www.google.com') 67 | .type('file') 68 | .arg('ARGUMENT') 69 | .valid(false) 70 | .icon('icon.png') 71 | .mod('cmd', 'Do Something Different', 'something-different') 72 | .mod('shift', 'Another Different', 'another-different', false) 73 | .copy('Please copy this') 74 | .largetype('This will be huge') 75 | .autocomplete('AutoComplete This') 76 | 77 | @workflow.result 78 | .uid('THE ID 2') 79 | .title('Item Title 2') 80 | .subtitle('Item Subtitle 2') 81 | .quicklookurl('https://www.google.com/2') 82 | .type('file') 83 | .arg('ARGUMENT 2') 84 | .valid(true) 85 | .icon('icon2.png') 86 | .mod('cmd', 'Do Something Different 2', 'something-different 2') 87 | .mod('shift', 'Another Different 2', 'another-different 2', false) 88 | .copy('Please copy this 2') 89 | .largetype('This will be huge 2') 90 | .autocomplete('AutoComplete This 2') 91 | 92 | expected = { 93 | 'items' => [ 94 | { 95 | 'arg' => 'ARGUMENT', 96 | 'autocomplete' => 'AutoComplete This', 97 | 'icon' => { 98 | 'path' => 'icon.png' 99 | }, 100 | 'mods' => { 101 | 'cmd' => { 102 | 'subtitle' => 'Do Something Different', 103 | 'arg' => 'something-different', 104 | 'valid' => true 105 | }, 106 | 'shift' => { 107 | 'subtitle' => 'Another Different', 108 | 'arg' => 'another-different', 109 | 'valid' => false 110 | }, 111 | }, 112 | 'quicklookurl' => 'https://www.google.com', 113 | 'subtitle' => 'Item Subtitle', 114 | 'text' => { 115 | 'copy' => 'Please copy this', 116 | 'largetype' => 'This will be huge' 117 | }, 118 | 'title' => 'Item Title', 119 | 'type' => 'file', 120 | 'uid' => 'THE ID', 121 | 'valid' => false 122 | }, 123 | { 124 | 'arg' => 'ARGUMENT 2', 125 | 'autocomplete' => 'AutoComplete This 2', 126 | 'icon' => { 127 | 'path' => 'icon2.png' 128 | }, 129 | 'mods' => { 130 | 'cmd' => { 131 | 'subtitle' => 'Do Something Different 2', 132 | 'arg' => 'something-different 2', 133 | 'valid' => true 134 | }, 135 | 'shift' => { 136 | 'subtitle' => 'Another Different 2', 137 | 'arg' => 'another-different 2', 138 | 'valid' => false 139 | }, 140 | }, 141 | 'quicklookurl' => 'https://www.google.com/2', 142 | 'subtitle' => 'Item Subtitle 2', 143 | 'text' => { 144 | 'copy' => 'Please copy this 2', 145 | 'largetype' => 'This will be huge 2' 146 | }, 147 | 'title' => 'Item Title 2', 148 | 'type' => 'file', 149 | 'uid' => 'THE ID 2', 150 | 'valid' => true 151 | } 152 | ] 153 | } 154 | 155 | assert_equal expected.to_json, @workflow.output 156 | end 157 | 158 | def test_that_it_can_handle_a_file_skipcheck_via_arguments 159 | @workflow.result.type('file', false) 160 | 161 | expected = { 162 | 'items' => [ 163 | { 164 | 'type' => 'file:skipcheck', 165 | 'valid' => true, 166 | } 167 | ] 168 | } 169 | 170 | assert_equal expected.to_json, @workflow.output 171 | end 172 | 173 | def test_that_it_can_add_mods_via_shortcuts 174 | @workflow.result.cmd('Hit Command', 'command-it', false) 175 | .shift('Hit Shift', 'shift-it', true) 176 | 177 | expected = { 178 | 'items' => [ 179 | { 180 | 'mods' => { 181 | 'cmd' => { 182 | 'subtitle' => 'Hit Command', 183 | 'arg' => 'command-it', 184 | 'valid' => false, 185 | }, 186 | 'shift' => { 187 | 'subtitle' => 'Hit Shift', 188 | 'arg' => 'shift-it', 189 | 'valid' => true, 190 | }, 191 | }, 192 | 'valid' => true, 193 | } 194 | ] 195 | } 196 | 197 | assert_equal expected.to_json, @workflow.output 198 | end 199 | 200 | def test_that_it_can_handle_file_icon_via_shortcut 201 | @workflow.result.fileicon_icon('icon.png'); 202 | 203 | expected = { 204 | 'items' => [ 205 | { 206 | 'icon' => { 207 | 'path' => 'icon.png', 208 | 'type' => 'fileicon', 209 | }, 210 | 'valid' => true, 211 | } 212 | ] 213 | } 214 | 215 | assert_equal expected.to_json, @workflow.output 216 | end 217 | 218 | def test_that_it_can_sort_results_by_defaults 219 | @workflow.result 220 | .uid('THE ID 2') 221 | .title('Item Title 2') 222 | .subtitle('Item Subtitle 2') 223 | 224 | @workflow.result 225 | .uid('THE ID') 226 | .title('Item Title') 227 | .subtitle('Item Subtitle') 228 | 229 | expected = { 230 | 'items' => [ 231 | { 232 | 'subtitle' => 'Item Subtitle', 233 | 'title' => 'Item Title', 234 | 'uid' => 'THE ID', 235 | 'valid' => true, 236 | }, 237 | { 238 | 'subtitle' => 'Item Subtitle 2', 239 | 'title' => 'Item Title 2', 240 | 'uid' => 'THE ID 2', 241 | 'valid' => true, 242 | } 243 | ] 244 | } 245 | 246 | assert_equal expected.to_json, @workflow.sort_results.output 247 | end 248 | 249 | def test_that_it_can_sort_results_desc 250 | @workflow.result 251 | .uid('THE ID') 252 | .title('Item Title') 253 | .subtitle('Item Subtitle') 254 | 255 | @workflow.result 256 | .uid('THE ID 2') 257 | .title('Item Title 2') 258 | .subtitle('Item Subtitle 2') 259 | 260 | expected = { 261 | 'items' => [ 262 | { 263 | 'subtitle' => 'Item Subtitle 2', 264 | 'title' => 'Item Title 2', 265 | 'uid' => 'THE ID 2', 266 | 'valid' => true, 267 | }, 268 | { 269 | 'subtitle' => 'Item Subtitle', 270 | 'title' => 'Item Title', 271 | 'uid' => 'THE ID', 272 | 'valid' => true, 273 | } 274 | ] 275 | } 276 | 277 | assert_equal expected.to_json, @workflow.sort_results('desc').output 278 | end 279 | 280 | def test_that_it_can_sort_results_by_field 281 | @workflow.result 282 | .uid('456') 283 | .title('Item Title') 284 | .subtitle('Item Subtitle') 285 | 286 | @workflow.result 287 | .uid('123') 288 | .title('Item Title 2') 289 | .subtitle('Item Subtitle 2') 290 | 291 | expected = { 292 | 'items' => [ 293 | { 294 | 'subtitle' => 'Item Subtitle 2', 295 | 'title' => 'Item Title 2', 296 | 'uid' => '123', 297 | 'valid' => true, 298 | }, 299 | { 300 | 'subtitle' => 'Item Subtitle', 301 | 'title' => 'Item Title', 302 | 'uid' => '456', 303 | 'valid' => true, 304 | } 305 | ] 306 | } 307 | 308 | assert_equal expected.to_json, @workflow.sort_results('asc', 'uid').output 309 | end 310 | 311 | def test_that_it_can_filter_results 312 | @workflow.result 313 | .uid('456') 314 | .title('Item Title') 315 | .subtitle('Item Subtitle') 316 | 317 | @workflow.result 318 | .uid('123') 319 | .title('Item Title 2') 320 | .subtitle('Item Subtitle 2') 321 | 322 | expected = { 323 | 'items' => [ 324 | { 325 | 'subtitle' => 'Item Subtitle 2', 326 | 'title' => 'Item Title 2', 327 | 'uid' => '123', 328 | 'valid' => true, 329 | } 330 | ] 331 | } 332 | 333 | assert_equal expected.to_json, @workflow.filter_results('Title 2').output 334 | end 335 | 336 | def test_that_it_can_filter_results_by_property 337 | @workflow.result 338 | .uid('456') 339 | .title('Item Title') 340 | .subtitle('Item Subtitle') 341 | 342 | @workflow.result 343 | .uid('123') 344 | .title('Item Title 2') 345 | .subtitle('Item Subtitle 2') 346 | 347 | expected = { 348 | 'items' => [ 349 | { 350 | 'subtitle' => 'Item Subtitle', 351 | 'title' => 'Item Title', 352 | 'uid' => '456', 353 | 'valid' => true, 354 | } 355 | ] 356 | } 357 | 358 | assert_equal expected.to_json, @workflow.filter_results(45, 'uid').output 359 | end 360 | end 361 | --------------------------------------------------------------------------------