Merge remote-tracking branch 'origin/master' into set_charset_for_mysql

Conflicts:
db/schema.rb

Akinori MUSHA 10 years ago
parent
commit
857b8ea969
63 changed files with 786 additions and 274 deletions
  1. 5 3
      .env.example
  2. 2 2
      .travis.yml
  3. 2 1
      Gemfile
  4. 3 0
      Gemfile.lock
  5. 6 1
      app/assets/javascripts/application.js.coffee.erb
  6. 15 0
      app/assets/stylesheets/application.css.scss.erb
  7. 42 1
      app/concerns/liquid_droppable.rb
  8. 3 3
      app/concerns/oauthable.rb
  9. 2 2
      app/concerns/twitter_concern.rb
  10. 3 0
      app/concerns/web_request_concern.rb
  11. 8 7
      app/controllers/agents_controller.rb
  12. 23 0
      app/controllers/application_controller.rb
  13. 2 0
      app/controllers/home_controller.rb
  14. 2 0
      app/controllers/scenarios_controller.rb
  15. 1 0
      app/controllers/services_controller.rb
  16. 2 2
      app/helpers/agent_helper.rb
  17. 7 0
      app/helpers/markdown_helper.rb
  18. 23 0
      app/helpers/scenario_helper.rb
  19. 5 0
      app/helpers/service_helper.rb
  20. 3 13
      app/models/agent.rb
  21. 27 26
      app/models/agents/basecamp_agent.rb
  22. 15 3
      app/models/agents/event_formatting_agent.rb
  23. 1 1
      app/models/agents/google_calendar_publish_agent.rb
  24. 147 81
      app/models/agents/website_agent.rb
  25. 12 17
      app/models/event.rb
  26. 6 1
      app/models/scenario.rb
  27. 5 1
      app/models/scenario_import.rb
  28. 52 33
      app/models/service.rb
  29. 2 2
      app/models/user.rb
  30. 1 1
      app/views/agents/_action_menu.html.erb
  31. 1 6
      app/views/agents/_form.html.erb
  32. 6 0
      app/views/agents/_oauth_dropdown.html.erb
  33. 4 4
      app/views/agents/show.html.erb
  34. 26 0
      app/views/application/_upgrade_warning.html.erb
  35. 4 1
      app/views/layouts/application.html.erb
  36. 5 5
      app/views/scenario_imports/_step_two.html.erb
  37. 13 1
      app/views/scenarios/_form.html.erb
  38. 2 1
      app/views/scenarios/index.html.erb
  39. 2 2
      app/views/scenarios/share.html.erb
  40. 3 2
      app/views/scenarios/show.html.erb
  41. 12 6
      app/views/services/index.html.erb
  42. 3 3
      app/views/system_mailer/send_message.html.erb
  43. 7 1
      db/migrate/20140525150140_migrate_agents_to_service_authentication.rb
  44. 5 0
      db/migrate/20140809211540_remove_service_index_on_user_id.rb
  45. 7 0
      db/migrate/20140811200922_add_uid_column_to_services.rb
  46. 6 0
      db/migrate/20140820003139_add_tag_color_to_scenarios.rb
  47. 18 14
      db/schema.rb
  48. 3 1
      lib/agents_exporter.rb
  49. 15 0
      spec/concerns/liquid_interpolatable_spec.rb
  50. 2 0
      spec/controllers/scenarios_controller_spec.rb
  51. 5 4
      spec/controllers/services_controller_spec.rb
  52. 6 0
      spec/fixtures/agents.yml
  53. 14 0
      spec/helpers/markdown_helper_spec.rb
  54. 30 0
      spec/helpers/scenario_helper_spec.rb
  55. 8 2
      spec/lib/agents_exporter_spec.rb
  56. 5 4
      spec/models/agents/basecamp_agent_spec.rb
  57. 69 1
      spec/models/agents/website_agent_spec.rb
  58. 3 3
      spec/models/concerns/oauthable.rb
  59. 9 0
      spec/models/event_spec.rb
  60. 11 3
      spec/models/scenario_import_spec.rb
  61. 24 0
      spec/models/scenario_spec.rb
  62. 30 8
      spec/models/service_spec.rb
  63. 1 1
      spec/spec_helper.rb

+ 5 - 3
.env.example

@@ -70,9 +70,11 @@ EMAIL_FROM_ADDRESS=from_address@gmail.com
70 70
 # Number of lines of log messages to keep per Agent
71 71
 AGENT_LOG_LENGTH=200
72 72
 
73
-#############################
74
-#    OAuth Configuration    #
75
-#############################
73
+########################################################################################################
74
+#    OAuth Configuration                                                                               #
75
+#  More information at the wiki: https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications #
76
+########################################################################################################
77
+
76 78
 TWITTER_OAUTH_KEY=
77 79
 TWITTER_OAUTH_SECRET=
78 80
 

+ 2 - 2
.travis.yml

@@ -2,13 +2,13 @@ language: ruby
2 2
 cache: bundler
3 3
 bundler_args: --without development production
4 4
 env:
5
-  - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d TWITTER_OAUTH_KEY=twitteroauthkey TWITTER_OAUTH_SECRET=twitteroauthsecret
5
+  - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d
6 6
 rvm:
7 7
   - 2.0.0
8 8
   - 2.1.1
9 9
   - 1.9.3
10 10
 before_install:
11
-  - travis_retry gem install bundler  
11
+  - travis_retry gem install bundler
12 12
 before_script:
13 13
   - mysql -e 'create database huginn_test;'
14 14
   - bundle exec rake db:migrate db:test:prepare

+ 2 - 1
Gemfile

@@ -56,6 +56,8 @@ gem 'uglifier', '>= 1.3.0'
56 56
 gem 'select2-rails', '~> 3.5.4'
57 57
 gem 'jquery-rails', '~> 3.1.0'
58 58
 gem 'ace-rails-ap', '~> 2.0.1'
59
+gem 'spectrum-rails'
60
+
59 61
 
60 62
 # geokit-rails doesn't work with geokit 1.8.X but it specifies ~> 1.5
61 63
 # in its own Gemfile.
@@ -128,4 +130,3 @@ else
128 130
   gem 'unicorn', platform: :ruby_18
129 131
   gem 'rails_12factor', platform: :ruby_18
130 132
 end
131
-

+ 3 - 0
Gemfile.lock

@@ -315,6 +315,8 @@ GEM
315 315
     simplecov-html (0.8.0)
316 316
     slack-notifier (0.5.0)
317 317
     slop (3.6.0)
318
+    spectrum-rails (1.3.4)
319
+      railties (>= 3.1)
318 320
     sprockets (2.11.0)
319 321
       hike (~> 1.2)
320 322
       multi_json (~> 1.0)
@@ -444,6 +446,7 @@ DEPENDENCIES
444 446
   select2-rails (~> 3.5.4)
445 447
   shoulda-matchers
446 448
   slack-notifier (~> 0.5.0)
449
+  spectrum-rails
447 450
   therubyracer (~> 0.12.1)
448 451
   twilio-ruby (~> 3.11.5)
449 452
   twitter (~> 5.8.0)

+ 6 - 1
app/assets/javascripts/application.js.coffee.erb

@@ -6,6 +6,7 @@
6 6
 #= require json2
7 7
 #= require jquery.json-editor
8 8
 #= require latlon_and_geo
9
+#= require spectrum
9 10
 #= require ./worker-checker
10 11
 #= require_self
11 12
 
@@ -60,6 +61,10 @@ showEventDescriptions = ->
60 61
     $(".event-descriptions").html("").hide()
61 62
 
62 63
 $(document).ready ->
64
+  $('.navbar .dropdown.dropdown-hover').hover \
65
+    -> $(this).addClass('open'),
66
+    -> $(this).removeClass('open')
67
+
63 68
   # JSON Editor
64 69
   window.jsonEditor = setupJsonEditor()[0]
65 70
 
@@ -164,7 +169,7 @@ $(document).ready ->
164 169
 
165 170
         $(".description").html(json.description_html) if json.description_html?
166 171
 
167
-        $('.oauthable-form').html($(json.form).find('.oauthable-form').html()) if json.form?
172
+        $('.oauthable-form').html(json.form) if json.form?
168 173
 
169 174
         if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g)
170 175
           window.jsonEditor.json = json.options

+ 15 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -12,6 +12,7 @@
12 12
  *= require select2-bootstrap
13 13
  *= require jquery.json-editor
14 14
  *= require rickshaw
15
+ *= require spectrum
15 16
  *= require_tree .
16 17
  *= require_self
17 18
  */
@@ -186,3 +187,17 @@ h2 .scenario, a span.label.scenario {
186 187
 .color-success {
187 188
   color: #5cb85c;
188 189
 }
190
+
191
+.form-group {
192
+  .sp-replacer {
193
+    @extend .form-control;
194
+  }
195
+
196
+  .sp-preview {
197
+    width: 100%;
198
+  }
199
+
200
+  .sp-dd {
201
+    display: none;
202
+  }
203
+}

+ 42 - 1
app/concerns/liquid_droppable.rb

@@ -1,9 +1,50 @@
1 1
 module LiquidDroppable
2 2
   extend ActiveSupport::Concern
3 3
 
4
+  # In subclasses of this base class, "locals" take precedence over
5
+  # methods.
4 6
   class Drop < Liquid::Drop
5
-    def initialize(object)
7
+    class << self
8
+      def inherited(subclass)
9
+        class << subclass
10
+          attr_reader :drop_methods
11
+
12
+          # Make all public methods private so that #before_method
13
+          # catches everything.
14
+          def drop_methods!
15
+            return if @drop_methods
16
+
17
+            @drop_methods = Set.new
18
+
19
+            (public_instance_methods - Drop.public_instance_methods).each { |name|
20
+              @drop_methods << name.to_s
21
+              private name
22
+            }
23
+          end
24
+        end
25
+      end
26
+    end
27
+
28
+    def initialize(object, locals = nil)
29
+      self.class.drop_methods!
30
+
6 31
       @object = object
32
+      @locals = locals || {}
33
+    end
34
+
35
+    def before_method(name)
36
+      if @locals.include?(name)
37
+        @locals[name]
38
+      elsif self.class.drop_methods.include?(name)
39
+        __send__(name)
40
+      end
41
+    end
42
+
43
+    def each
44
+      return to_enum(__method__) unless block_given?
45
+      self.class.drop_methods.each { |name|
46
+        yield [name, __send__(name)]
47
+      }
7 48
     end
8 49
   end
9 50
 

+ 3 - 3
app/concerns/oauthable.rb

@@ -11,11 +11,11 @@ module Oauthable
11 11
     true
12 12
   end
13 13
 
14
-  def valid_services(current_user)
14
+  def valid_services_for(user)
15 15
     if valid_oauth_providers == :all
16
-      current_user.available_services
16
+      user.available_services
17 17
     else
18
-      current_user.available_services.where(provider: valid_oauth_providers)
18
+      user.available_services.where(provider: valid_oauth_providers)
19 19
     end
20 20
   end
21 21
 

+ 2 - 2
app/concerns/twitter_concern.rb

@@ -25,11 +25,11 @@ module TwitterConcern
25 25
   end
26 26
 
27 27
   def twitter_oauth_token
28
-    self.service.token
28
+    service.token
29 29
   end
30 30
 
31 31
   def twitter_oauth_token_secret
32
-    self.service.secret
32
+    service.secret
33 33
   end
34 34
 
35 35
   def twitter

+ 3 - 0
app/concerns/web_request_concern.rb

@@ -1,3 +1,6 @@
1
+require 'faraday'
2
+require 'faraday_middleware'
3
+
1 4
 module WebRequestConcern
2 5
   extend ActiveSupport::Concern
3 6
 

+ 8 - 7
app/controllers/agents_controller.rb

@@ -31,14 +31,15 @@ class AgentsController < ApplicationController
31 31
   end
32 32
 
33 33
   def type_details
34
-    agent = Agent.build_for_type(params[:type], current_user, {})
34
+    @agent = Agent.build_for_type(params[:type], current_user, {})
35 35
     render :json => {
36
-        :can_be_scheduled => agent.can_be_scheduled?,
37
-        :default_schedule => agent.default_schedule,
38
-        :can_receive_events => agent.can_receive_events?,
39
-        :can_create_events => agent.can_create_events?,
40
-        :options => agent.default_options,
41
-        :description_html => agent.html_description
36
+        :can_be_scheduled => @agent.can_be_scheduled?,
37
+        :default_schedule => @agent.default_schedule,
38
+        :can_receive_events => @agent.can_receive_events?,
39
+        :can_create_events => @agent.can_create_events?,
40
+        :options => @agent.default_options,
41
+        :description_html => @agent.html_description,
42
+        :form => render_to_string(partial: 'oauth_dropdown')
42 43
     }
43 44
   end
44 45
 

+ 23 - 0
app/controllers/application_controller.rb

@@ -13,4 +13,27 @@ class ApplicationController < ActionController::Base
13 13
     devise_parameter_sanitizer.for(:sign_in) { |u| u.permit(:login, :username, :email, :password, :remember_me) }
14 14
     devise_parameter_sanitizer.for(:account_update) { |u| u.permit(:username, :email, :password, :password_confirmation, :current_password) }
15 15
   end
16
+
17
+  def upgrade_warning
18
+    return unless current_user
19
+    twitter_oauth_check
20
+    basecamp_auth_check
21
+  end
22
+
23
+  private
24
+  def twitter_oauth_check
25
+    if ENV['TWITTER_OAUTH_KEY'].blank? || ENV['TWITTER_OAUTH_SECRET'].blank?
26
+      if @twitter_agent = current_user.agents.where("type like 'Agents::Twitter%'").first
27
+        @twitter_oauth_key    = @twitter_agent.options['consumer_key'].presence || @twitter_agent.credential('twitter_consumer_key')
28
+        @twitter_oauth_secret = @twitter_agent.options['consumer_secret'].presence || @twitter_agent.credential('twitter_consumer_secret')
29
+      end
30
+    end
31
+  end
32
+
33
+  def basecamp_auth_check
34
+    if ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'].blank? || ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'].blank?
35
+      @basecamp_agent = current_user.agents.where(type: 'Agents::BasecampAgent').first
36
+    end
37
+  end
38
+
16 39
 end

+ 2 - 0
app/controllers/home_controller.rb

@@ -1,6 +1,8 @@
1 1
 class HomeController < ApplicationController
2 2
   skip_before_filter :authenticate_user!
3 3
 
4
+  before_filter :upgrade_warning, only: :index
5
+
4 6
   def index
5 7
   end
6 8
 

+ 2 - 0
app/controllers/scenarios_controller.rb

@@ -45,6 +45,8 @@ class ScenariosController < ApplicationController
45 45
     @exporter = AgentsExporter.new(:name => @scenario.name,
46 46
                                    :description => @scenario.description,
47 47
                                    :guid => @scenario.guid,
48
+                                   :tag_fg_color => @scenario.tag_fg_color,
49
+                                   :tag_bg_color => @scenario.tag_bg_color,
48 50
                                    :source_url => @scenario.public? && export_scenario_url(@scenario),
49 51
                                    :agents => @scenario.agents)
50 52
     response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'

+ 1 - 0
app/controllers/services_controller.rb

@@ -1,4 +1,5 @@
1 1
 class ServicesController < ApplicationController
2
+  before_filter :upgrade_warning, only: :index
2 3
 
3 4
   def index
4 5
     @services = current_user.services.page(params[:page])

+ 2 - 2
app/helpers/agent_helper.rb

@@ -8,11 +8,11 @@ module AgentHelper
8 8
 
9 9
   def scenario_links(agent)
10 10
     agent.scenarios.map { |scenario|
11
-      link_to(scenario.name, scenario, class: "label label-info")
11
+      link_to(scenario.name, scenario, class: "label", style: style_colors(scenario))
12 12
     }.join(" ").html_safe
13 13
   end
14 14
 
15 15
   def agent_show_class(agent)
16 16
     agent.short_type.underscore.dasherize
17 17
   end
18
-end
18
+end

+ 7 - 0
app/helpers/markdown_helper.rb

@@ -0,0 +1,7 @@
1
+module MarkdownHelper
2
+
3
+  def markdown(text)
4
+    Kramdown::Document.new(text, :auto_ids => false).to_html.html_safe
5
+  end
6
+
7
+end

+ 23 - 0
app/helpers/scenario_helper.rb

@@ -0,0 +1,23 @@
1
+module ScenarioHelper
2
+
3
+  def style_colors(scenario)
4
+    colors = {
5
+      color: scenario.tag_fg_color || default_scenario_fg_color,
6
+      background_color: scenario.tag_bg_color || default_scenario_bg_color
7
+    }.map { |key, value| "#{key.to_s.dasherize}:#{value}" }.join(';')
8
+  end
9
+
10
+  def scenario_label(scenario, text = nil)
11
+    text ||= scenario.name
12
+    content_tag :span, text, class: 'label scenario', style: style_colors(scenario)
13
+  end
14
+
15
+  def default_scenario_bg_color
16
+    '#5BC0DE'
17
+  end
18
+
19
+  def default_scenario_fg_color
20
+    '#FFFFFF'
21
+  end
22
+
23
+end

+ 5 - 0
app/helpers/service_helper.rb

@@ -0,0 +1,5 @@
1
+module ServiceHelper
2
+  def has_oauth_configuration_for(provider)
3
+    ENV["#{provider.upcase}_OAUTH_KEY"].present? && ENV["#{provider.upcase}_OAUTH_SECRET"].present?
4
+  end
5
+end

+ 3 - 13
app/models/agent.rb

@@ -44,7 +44,7 @@ class Agent < ActiveRecord::Base
44 44
   after_save :possibly_update_event_expirations
45 45
 
46 46
   belongs_to :user, :inverse_of => :agents
47
-  belongs_to :service
47
+  belongs_to :service, :inverse_of => :agents
48 48
   has_many :events, -> { order("events.id desc") }, :dependent => :delete_all, :inverse_of => :agent
49 49
   has_one  :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc"
50 50
   has_many :logs,  -> { order("agent_logs.id desc") }, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog"
@@ -392,7 +392,7 @@ class AgentDrop
392 392
     @object.short_type
393 393
   end
394 394
 
395
-  METHODS = [
395
+  [
396 396
     :name,
397 397
     :type,
398 398
     :options,
@@ -403,19 +403,9 @@ class AgentDrop
403 403
     :disabled,
404 404
     :keep_events_for,
405 405
     :propagate_immediately,
406
-  ]
407
-
408
-  METHODS.each { |attr|
406
+  ].each { |attr|
409 407
     define_method(attr) {
410 408
       @object.__send__(attr)
411 409
     } unless method_defined?(attr)
412 410
   }
413
-
414
-  def each(&block)
415
-    return to_enum(__method__) unless block
416
-
417
-    METHODS.each { |attr|
418
-      yield [attr, __sent__(attr)]
419
-    }
420
-  end
421 411
 end

+ 27 - 26
app/models/agents/basecamp_agent.rb

@@ -21,25 +21,25 @@ module Agents
21 21
     event_description <<-MD
22 22
       Events are the raw JSON provided by the Basecamp API. Should look something like:
23 23
 
24
-        {
25
-          "creator": {
26
-            "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3",
27
-            "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3",
28
-            "name": "Dominik Sander",
29
-            "id": 123456
30
-          },
31
-          "attachments": [],
32
-          "raw_excerpt": "test test",
33
-          "excerpt": "test test",
34
-          "id": 6454342343,
35
-          "created_at": "2014-04-17T10:25:31.000+02:00",
36
-          "updated_at": "2014-04-17T10:25:31.000+02:00",
37
-          "summary": "commented on whaat",
38
-          "action": "commented on",
39
-          "target": "whaat",
40
-          "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json",
41
-          "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545"
42
-        }
24
+          {
25
+            "creator": {
26
+              "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3",
27
+              "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3",
28
+              "name": "Dominik Sander",
29
+              "id": 123456
30
+            },
31
+            "attachments": [],
32
+            "raw_excerpt": "test test",
33
+            "excerpt": "test test",
34
+            "id": 6454342343,
35
+            "created_at": "2014-04-17T10:25:31.000+02:00",
36
+            "updated_at": "2014-04-17T10:25:31.000+02:00",
37
+            "summary": "commented on whaat",
38
+            "action": "commented on",
39
+            "target": "whaat",
40
+            "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json",
41
+            "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545"
42
+          }
43 43
     MD
44 44
 
45 45
     default_schedule "every_10m"
@@ -59,28 +59,29 @@ module Agents
59 59
     end
60 60
 
61 61
     def check
62
-      self.service.prepare_request
62
+      service.prepare_request
63 63
       reponse = HTTParty.get request_url, request_options.merge(query_parameters)
64
-      memory[:last_run] = Time.now.utc.iso8601
65
-      if last_check_at != nil
66
-        JSON.parse(reponse.body).each do |event|
64
+      events = JSON.parse(reponse.body)
65
+      if !memory[:last_event].nil?
66
+        events.each do |event|
67 67
           create_event :payload => event
68 68
         end
69 69
       end
70
+      memory[:last_event] = events.first['created_at'] if events.length > 0
70 71
       save!
71 72
     end
72 73
 
73 74
   private
74 75
     def request_url
75
-      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
76
+      "https://basecamp.com/#{URI.encode(service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
76 77
     end
77 78
 
78 79
     def request_options
79
-      {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{self.service.token}\""}}
80
+      {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{service.token}\""}}
80 81
     end
81 82
 
82 83
     def query_parameters
83
-      memory[:last_run].present? ? { :query => {:since => memory[:last_run]} } : {}
84
+      memory[:last_event].present? ? { :query => {:since => memory[:last_event]} } : {}
84 85
     end
85 86
   end
86 87
 end

+ 15 - 3
app/models/agents/event_formatting_agent.rb

@@ -51,7 +51,7 @@ module Agents
51 51
               {
52 52
                 "path": "{{date.pretty}}",
53 53
                 "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
54
-                "to": "pretty_date",
54
+                "to": "pretty_date"
55 55
               }
56 56
             ]
57 57
           }
@@ -61,7 +61,7 @@ module Agents
61 61
           "pretty_date": {
62 62
             "time": "10:00 PM EST",
63 63
             "0": "10:00 PM EST on January 11, 2013"
64
-            "1": "10:00 PM EST",
64
+            "1": "10:00 PM EST"
65 65
           }
66 66
 
67 67
       So you can use it in `instructions` like this:
@@ -80,7 +80,19 @@ module Agents
80 80
           }
81 81
     MD
82 82
 
83
-    event_description "User defined"
83
+    event_description do
84
+      "Events will have the following fields%s:\n\n    %s" % [
85
+        case options['mode'].to_s
86
+        when 'merged'
87
+          ', merged with the original contents'
88
+        when /\{/
89
+          ', conditionally merged with the original contents'
90
+        end,
91
+        Utils.pretty_print(Hash[options['instructions'].keys.map { |key|
92
+          [key, "..."]
93
+        }])
94
+      ]
95
+    end
84 96
 
85 97
     after_save :clear_matchers
86 98
 

+ 1 - 1
app/models/agents/google_calendar_publish_agent.rb

@@ -62,7 +62,7 @@ module Agents
62 62
            ....
63 63
         },
64 64
         'agent_id' => 1234,
65
-        'event_id' => 3432,
65
+        'event_id' => 3432
66 66
       }
67 67
     MD
68 68
 

+ 147 - 81
app/models/agents/website_agent.rb

@@ -1,6 +1,4 @@
1 1
 require 'nokogiri'
2
-require 'faraday'
3
-require 'faraday_middleware'
4 2
 require 'date'
5 3
 
6 4
 module Agents
@@ -19,7 +17,7 @@ module Agents
19 17
 
20 18
       `url` can be a single url, or an array of urls (for example, for multiple pages with the exact same structure but different content to scrape)
21 19
 
22
-      The `type` value can be `xml`, `html`, or `json`.
20
+      The `type` value can be `xml`, `html`, `json`, or `text`.
23 21
 
24 22
       To tell the Agent how to parse the content, specify `extract` as a hash with keys naming the extractions and values of hashes.
25 23
 
@@ -40,6 +38,28 @@ module Agents
40 38
             "description": { "path": "results.data[*].description" }
41 39
           }
42 40
 
41
+      When parsing text, each sub-hash should contain a `regexp` and `index`.  Output text is matched against the regular expression repeatedly from the beginning through to the end, collecting a captured group specified by `index` in each match.  Each index should be either an integer or a string name which corresponds to `(?<_name_>...)`.  For example, to parse lines of `_word_: _definition_`, the following should work:
42
+
43
+          "extract": {
44
+            "word": { "regexp": "^(.+?): (.+)$", index: 1 },
45
+            "definition": { "regexp": "^(.+?): (.+)$", index: 2 }
46
+          }
47
+
48
+      Or if you prefer names to numbers for index:
49
+
50
+          "extract": {
51
+            "word": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'word' },
52
+            "definition": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'definition' }
53
+          }
54
+
55
+      To extract the whole content as one event:
56
+
57
+          "extract": {
58
+            "content": { "regexp": "\A(?m:.)*\z", index: 0 }
59
+          }
60
+
61
+      Beware that `.` does not match the newline character (LF) unless the `m` flag is in effect, and `^`/`$` basically match every line beginning/end.  See [this document](http://ruby-doc.org/core-#{RUBY_VERSION}/doc/regexp_rdoc.html) to learn the regular expression variant used in this service.
62
+
43 63
       Note that for all of the formats, whatever you extract MUST have the same number of matches for each extractor.  E.g., if you're extracting rows, all extractors must match all rows.  For generating CSS selectors, something like [SelectorGadget](http://selectorgadget.com) may be helpful.
44 64
 
45 65
       Can be configured to use HTTP basic auth by including the `basic_auth` parameter with `"username:password"`, or `["username", "password"]`.
@@ -58,7 +78,11 @@ module Agents
58 78
     MD
59 79
 
60 80
     event_description do
61
-      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print interpolated['extract']}"
81
+      "Events will have the following fields:\n\n    %s" % [
82
+        Utils.pretty_print(Hash[options['extract'].keys.map { |key|
83
+          [key, "..."]
84
+        }])
85
+      ]
62 86
     end
63 87
 
64 88
     def working?
@@ -137,77 +161,60 @@ module Agents
137 161
               log "Storing new result for '#{name}': #{doc.inspect}"
138 162
               create_event :payload => doc
139 163
             end
140
-          else
141
-            output = {}
142
-            interpolated['extract'].each do |name, extraction_details|
143
-              if extraction_type == "json"
144
-                result = Utils.values_at(doc, extraction_details['path'])
145
-                log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
146
-              else
147
-                case
148
-                when css = extraction_details['css']
149
-                  nodes = doc.css(css)
150
-                when xpath = extraction_details['xpath']
151
-                  doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds
152
-                  nodes = doc.xpath(xpath)
153
-                else
154
-                  error '"css" or "xpath" is required for HTML or XML extraction'
155
-                  return
156
-                end
157
-                case nodes
158
-                when Nokogiri::XML::NodeSet
159
-                  result = nodes.map { |node|
160
-                    case value = node.xpath(extraction_details['value'])
161
-                    when Float
162
-                      # Node#xpath() returns any numeric value as float;
163
-                      # convert it to integer as appropriate.
164
-                      value = value.to_i if value.to_i == value
165
-                    end
166
-                    value.to_s
167
-                  }
168
-                else
169
-                  error "The result of HTML/XML extraction was not a NodeSet"
170
-                  return
171
-                end
172
-                log "Extracting #{extraction_type} at #{xpath || css}: #{result}"
173
-              end
174
-              output[name] = result
164
+            next
165
+          end
166
+
167
+          output =
168
+            case extraction_type
169
+            when 'json'
170
+              extract_json(doc)
171
+            when 'text'
172
+              extract_text(doc)
173
+            else
174
+              extract_xml(doc)
175 175
             end
176 176
 
177
-            num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
177
+          num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
178 178
 
179
-            if num_unique_lengths.length != 1
180
-              error "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
181
-              return
182
-            end
179
+          if num_unique_lengths.length != 1
180
+            raise "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
181
+          end
183 182
 
184
-            old_events = previous_payloads num_unique_lengths.first
185
-            num_unique_lengths.first.times do |index|
186
-              result = {}
187
-              interpolated['extract'].keys.each do |name|
188
-                result[name] = output[name][index]
189
-                if name.to_s == 'url'
190
-                  result[name] = (response.env[:url] + result[name]).to_s
191
-                end
183
+          old_events = previous_payloads num_unique_lengths.first
184
+          num_unique_lengths.first.times do |index|
185
+            result = {}
186
+            interpolated['extract'].keys.each do |name|
187
+              result[name] = output[name][index]
188
+              if name.to_s == 'url'
189
+                result[name] = (response.env[:url] + result[name]).to_s
192 190
               end
191
+            end
193 192
 
194
-              if store_payload!(old_events, result)
195
-                log "Storing new parsed result for '#{name}': #{result.inspect}"
196
-                create_event :payload => result
197
-              end
193
+            if store_payload!(old_events, result)
194
+              log "Storing new parsed result for '#{name}': #{result.inspect}"
195
+              create_event :payload => result
198 196
             end
199 197
           end
200 198
         else
201
-          error "Failed: #{response.inspect}"
199
+          raise "Failed: #{response.inspect}"
202 200
         end
203 201
       end
202
+    rescue => e
203
+      error e.message
204 204
     end
205 205
 
206 206
     def receive(incoming_events)
207 207
       incoming_events.each do |event|
208
+        Thread.current[:current_event] = event
208 209
         url_to_scrape = event.payload['url']
209 210
         check_url(url_to_scrape) if url_to_scrape =~ /^https?:\/\//i
210 211
       end
212
+    ensure
213
+      Thread.current[:current_event] = nil
214
+    end
215
+
216
+    def interpolated(event = Thread.current[:current_event])
217
+      super
211 218
     end
212 219
 
213 220
     private
@@ -216,22 +223,22 @@ module Agents
216 223
     # If mode is set to 'on_change', this method may return false and update an existing
217 224
     # event to expire further in the future.
218 225
     def store_payload!(old_events, result)
219
-      if !interpolated['mode'].present?
220
-        return true
221
-      elsif interpolated['mode'].to_s == "all"
222
-        return true
223
-      elsif interpolated['mode'].to_s == "on_change"
226
+      case interpolated['mode'].presence
227
+      when 'on_change'
224 228
         result_json = result.to_json
225 229
         old_events.each do |old_event|
226 230
           if old_event.payload.to_json == result_json
227 231
             old_event.expires_at = new_event_expiration_date
228 232
             old_event.save!
229 233
             return false
230
-         end
234
+          end
231 235
         end
232
-        return true
236
+        true
237
+      when 'all', ''
238
+        true
239
+      else
240
+        raise "Illegal options[mode]: #{interpolated['mode']}"
233 241
       end
234
-      raise "Illegal options[mode]: " + interpolated['mode'].to_s
235 242
     end
236 243
 
237 244
     def previous_payloads(num_events)
@@ -244,7 +251,7 @@ module Agents
244 251
           look_back = UNIQUENESS_LOOK_BACK
245 252
         end
246 253
       end
247
-      events.order("id desc").limit(look_back) if interpolated['mode'].present? && interpolated['mode'].to_s == "on_change"
254
+      events.order("id desc").limit(look_back) if interpolated['mode'] == "on_change"
248 255
     end
249 256
 
250 257
     def extract_full_json?
@@ -253,35 +260,94 @@ module Agents
253 260
 
254 261
     def extraction_type
255 262
       (interpolated['type'] || begin
256
-        if interpolated['url'] =~ /\.(rss|xml)$/i
263
+        case interpolated['url']
264
+        when /\.(rss|xml)$/i
257 265
           "xml"
258
-        elsif interpolated['url'] =~ /\.json$/i
266
+        when /\.json$/i
259 267
           "json"
268
+        when /\.(txt|text)$/i
269
+          "text"
260 270
         else
261 271
           "html"
262 272
         end
263 273
       end).to_s
264 274
     end
265 275
 
276
+    def extract_each(doc, &block)
277
+      interpolated['extract'].each_with_object({}) { |(name, extraction_details), output|
278
+        output[name] = block.call(extraction_details)
279
+      }
280
+    end
281
+
282
+    def extract_json(doc)
283
+      extract_each(doc) { |extraction_details|
284
+        result = Utils.values_at(doc, extraction_details['path'])
285
+        log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
286
+        result
287
+      }
288
+    end
289
+
290
+    def extract_text(doc)
291
+      extract_each(doc) { |extraction_details|
292
+        regexp = Regexp.new(extraction_details['regexp'])
293
+        result = []
294
+        doc.scan(regexp) {
295
+          result << Regexp.last_match[extraction_details['index']]
296
+        }
297
+        log "Extracting #{extraction_type} at #{regexp}: #{result}"
298
+        result
299
+      }
300
+    end
301
+
302
+    def extract_xml(doc)
303
+      extract_each(doc) { |extraction_details|
304
+        case
305
+        when css = extraction_details['css']
306
+          nodes = doc.css(css)
307
+        when xpath = extraction_details['xpath']
308
+          doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds
309
+          nodes = doc.xpath(xpath)
310
+        else
311
+          raise '"css" or "xpath" is required for HTML or XML extraction'
312
+        end
313
+        case nodes
314
+        when Nokogiri::XML::NodeSet
315
+          result = nodes.map { |node|
316
+            case value = node.xpath(extraction_details['value'])
317
+            when Float
318
+              # Node#xpath() returns any numeric value as float;
319
+              # convert it to integer as appropriate.
320
+              value = value.to_i if value.to_i == value
321
+            end
322
+            value.to_s
323
+          }
324
+        else
325
+          raise "The result of HTML/XML extraction was not a NodeSet"
326
+        end
327
+        log "Extracting #{extraction_type} at #{xpath || css}: #{result}"
328
+        result
329
+      }
330
+    end
331
+
266 332
     def parse(data)
267 333
       case extraction_type
268
-        when "xml"
269
-          Nokogiri::XML(data)
270
-        when "json"
271
-          JSON.parse(data)
272
-        when "html"
273
-          Nokogiri::HTML(data)
274
-        else
275
-          raise "Unknown extraction type #{extraction_type}"
334
+      when "xml"
335
+        Nokogiri::XML(data)
336
+      when "json"
337
+        JSON.parse(data)
338
+      when "html"
339
+        Nokogiri::HTML(data)
340
+      when "text"
341
+        data
342
+      else
343
+        raise "Unknown extraction type #{extraction_type}"
276 344
       end
277 345
     end
278 346
 
279 347
     def is_positive_integer?(value)
280
-      begin
281
-        Integer(value) >= 0
282
-      rescue
283
-        false
284
-      end
348
+      Integer(value) >= 0
349
+    rescue
350
+      false
285 351
     end
286 352
   end
287 353
 end

+ 12 - 17
app/models/event.rb

@@ -44,26 +44,21 @@ class Event < ActiveRecord::Base
44 44
 end
45 45
 
46 46
 class EventDrop
47
-  def initialize(event, payload = event.payload)
48
-    super(event)
49
-    @payload = payload
50
-  end
51
-
52
-  def before_method(key)
53
-    if @payload.key?(key)
54
-      @payload[key]
55
-    else
56
-      case key
57
-      when 'agent'
58
-        @object.agent
59
-      when 'created_at'
60
-        @object.created_at
61
-      end
62
-    end
47
+  def initialize(object, locals = nil)
48
+    locals ||= object.payload
49
+    super
63 50
   end
64 51
 
65 52
   def each(&block)
66 53
     return to_enum(__method__) unless block
67
-    @payload.each(&block)
54
+    @locals.each(&block)
55
+  end
56
+
57
+  def agent
58
+    @object.agent
59
+  end
60
+
61
+  def created_at
62
+    @object.created_at
68 63
   end
69 64
 end

+ 6 - 1
app/models/scenario.rb

@@ -1,7 +1,7 @@
1 1
 class Scenario < ActiveRecord::Base
2 2
   include HasGuid
3 3
 
4
-  attr_accessible :name, :agent_ids, :description, :public, :source_url
4
+  attr_accessible :name, :agent_ids, :description, :public, :source_url, :tag_fg_color, :tag_bg_color
5 5
 
6 6
   belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
7 7
   has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
@@ -9,6 +9,11 @@ class Scenario < ActiveRecord::Base
9 9
 
10 10
   validates_presence_of :name, :user
11 11
 
12
+  validates_format_of :tag_fg_color, :tag_bg_color,
13
+    # Regex adapted from: http://stackoverflow.com/a/1636354/3130625
14
+    :with => /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, :allow_nil => true,
15
+    :message => "must be a valid hex color."
16
+
12 17
   validate :agents_are_owned
13 18
 
14 19
   protected

+ 5 - 1
app/models/scenario_import.rb

@@ -60,10 +60,14 @@ class ScenarioImport
60 60
     description = parsed_data['description']
61 61
     name = parsed_data['name']
62 62
     links = parsed_data['links']
63
+    tag_fg_color = parsed_data['tag_fg_color']
64
+    tag_bg_color = parsed_data['tag_bg_color']
63 65
     source_url = parsed_data['source_url'].presence || nil
64 66
     @scenario = user.scenarios.where(:guid => guid).first_or_initialize
65 67
     @scenario.update_attributes!(:name => name, :description => description,
66
-                                 :source_url => source_url, :public => false)
68
+                                 :source_url => source_url, :public => false,
69
+                                 :tag_fg_color => tag_fg_color,
70
+                                 :tag_bg_color => tag_bg_color)
67 71
 
68 72
     unless options[:skip_agents]
69 73
       created_agents = agent_diffs.map do |agent_diff|

+ 52 - 33
app/models/service.rb

@@ -1,17 +1,22 @@
1 1
 class Service < ActiveRecord::Base
2
-  attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options
2
+  PROVIDER_TO_ENV_MAP = {'37signals' => 'THIRTY_SEVEN_SIGNALS'}
3
+
4
+  attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options, :uid
3 5
 
4 6
   serialize :options, Hash
5 7
 
6
-  belongs_to :user
7
-  has_many :agents
8
+  belongs_to :user, :inverse_of => :services
9
+  has_many :agents, :inverse_of => :service
8 10
 
9 11
   validates_presence_of :user_id, :provider, :name, :token
10 12
 
11 13
   before_destroy :disable_agents
12 14
 
13
-  def disable_agents
14
-    self.agents.each do |agent|
15
+  scope :available_to_user, lambda { |user| where("services.user_id = ? or services.global = true", user.id) }
16
+  scope :by_name, lambda { |dir = 'desc'| order("services.name #{dir}") }
17
+
18
+  def disable_agents(conditions = {})
19
+    agents.where.not(conditions[:where_not] || {}).each do |agent|
15 20
       agent.service_id = nil
16 21
       agent.disabled = true
17 22
       agent.save!(validate: false)
@@ -19,52 +24,66 @@ class Service < ActiveRecord::Base
19 24
   end
20 25
 
21 26
   def toggle_availability!
27
+    disable_agents(where_not: {user_id: self.user_id}) if global
22 28
     self.global = !self.global
23 29
     self.save!
24 30
   end
25 31
 
26 32
   def prepare_request
27
-    if self.expires_at && Time.now > self.expires_at
28
-      self.refresh_token!
33
+    if expires_at && Time.now > expires_at
34
+      refresh_token!
29 35
     end
30 36
   end
31 37
 
32 38
   def refresh_token!
33 39
     response = HTTParty.post(endpoint, query: {
34 40
                   type:          'refresh',
35
-                  client_id:     ENV["#{self.provider.upcase}_OAUTH_KEY"],
36
-                  client_secret: ENV["#{self.provider.upcase}_OAUTH_SECRET"],
37
-                  refresh_token: self.refresh_token
41
+                  client_id:     oauth_key,
42
+                  client_secret: oauth_secret,
43
+                  refresh_token: refresh_token
38 44
     })
39 45
     data = JSON.parse(response.body)
40
-    self.update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || self.refresh_token)
46
+    update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || refresh_token)
41 47
   end
42 48
 
43
-  def self.initialize_or_update_via_omniauth(omniauth)
49
+  def endpoint
50
+    client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options']
51
+    URI.join(client_options['site'], client_options['token_url'])
52
+  end
53
+
54
+  def provider_to_env
55
+    PROVIDER_TO_ENV_MAP[provider].presence || provider.upcase
56
+  end
57
+
58
+  def oauth_key
59
+    ENV["#{provider_to_env}_OAUTH_KEY"]
60
+  end
61
+
62
+  def oauth_secret
63
+    ENV["#{provider_to_env}_OAUTH_SECRET"]
64
+  end
65
+
66
+  def self.provider_specific_options(omniauth)
44 67
     case omniauth['provider']
45
-    when 'twitter'
46
-      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service|
47
-        service.assign_attributes(token: omniauth['credentials']['token'], secret: omniauth['credentials']['secret'])
48
-      end
49
-    when 'github'
50
-      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service|
51
-        service.assign_attributes(token: omniauth['credentials']['token'])
52
-      end
53
-    when '37signals'
54
-      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['name']).tap do |service|
55
-        service.assign_attributes(token: omniauth['credentials']['token'],
56
-                                  refresh_token: omniauth['credentials']['refresh_token'],
57
-                                  expires_at: Time.at(omniauth['credentials']['expires_at']),
58
-                                  options: {user_id: omniauth['extra']['accounts'][0]['id']})
59
-      end
60
-    else
61
-      false
68
+      when 'twitter', 'github'
69
+        { name: omniauth['info']['nickname'] }
70
+      when '37signals'
71
+        { user_id: omniauth['extra']['accounts'][0]['id'], name: omniauth['info']['name'] }
72
+      else
73
+        { name: omniauth['info']['nickname'] }
62 74
     end
63 75
   end
64 76
 
65
-  private
66
-  def endpoint
67
-    client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options']
68
-    URI.join(client_options['site'], client_options['token_url'])
77
+  def self.initialize_or_update_via_omniauth(omniauth)
78
+    options = provider_specific_options(omniauth)
79
+
80
+    find_or_initialize_by(provider: omniauth['provider'], uid: omniauth['uid'].to_s).tap do |service|
81
+      service.assign_attributes token: omniauth['credentials']['token'],
82
+                                secret: omniauth['credentials']['secret'],
83
+                                name: options[:name],
84
+                                refresh_token: omniauth['credentials']['refresh_token'],
85
+                                expires_at: omniauth['credentials']['expires_at'] && Time.at(omniauth['credentials']['expires_at']),
86
+                                options: options
87
+    end
69 88
   end
70 89
 end

+ 2 - 2
app/models/user.rb

@@ -27,10 +27,10 @@ class User < ActiveRecord::Base
27 27
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
28 28
   has_many :logs, :through => :agents, :class_name => "AgentLog"
29 29
   has_many :scenarios, :inverse_of => :user, :dependent => :destroy
30
-  has_many :services, -> { order("services.name")}, :dependent => :destroy
30
+  has_many :services, -> { by_name('asc') }, :dependent => :destroy
31 31
 
32 32
   def available_services
33
-    Service.where("user_id = ? or global = true", self.id).order("services.name desc")
33
+    Service.available_to_user(self).by_name
34 34
   end
35 35
 
36 36
   # Allow users to login via either email or username.

+ 1 - 1
app/views/agents/_action_menu.html.erb

@@ -32,7 +32,7 @@
32 32
 
33 33
     <% agent.scenarios.each do |scenario| %>
34 34
       <li>
35
-        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
35
+        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from #{scenario_label(scenario)}".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
36 36
       </li>
37 37
     <% end %>
38 38
   <% end %>

+ 1 - 6
app/views/agents/_form.html.erb

@@ -31,12 +31,7 @@
31 31
           </div>
32 32
 
33 33
           <div class='oauthable-form'>
34
-            <% if @agent.try(:oauthable?) %>
35
-              <div class="form-group type-select">
36
-                <%= f.label :service %>
37
-                <%= f.select :service_id, options_for_select(@agent.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id),{}, class: 'form-control' %>
38
-              </div>
39
-            <% end %>
34
+            <%= render partial: 'oauth_dropdown' %>
40 35
           </div>
41 36
 
42 37
           <div class="form-group">

+ 6 - 0
app/views/agents/_oauth_dropdown.html.erb

@@ -0,0 +1,6 @@
1
+<% if @agent.try(:oauthable?) %>
2
+  <div class="form-group type-select">
3
+    <%= label_tag :service %>
4
+    <%= select_tag 'agent[service_id]', options_for_select(@agent.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id), class: 'form-control' %>
5
+  </div>
6
+<% end %>

+ 4 - 4
app/views/agents/show.html.erb

@@ -110,8 +110,8 @@
110 110
             <% if @agent.can_receive_events? %>
111 111
               <p>
112 112
                 <b>Event sources:</b>
113
-                <% if @agent.sources.length %>
114
-                  <%= @agent.sources.map { |source_agent| link_to(source_agent.name, agent_path(source_agent)) }.to_sentence.html_safe %>
113
+                <% if (agents = @agent.sources).length > 0 %>
114
+                  <%= agents.map { |agent| link_to(agent.name, agent_path(agent)) }.to_sentence.html_safe %>
115 115
                 <% else %>
116 116
                   None
117 117
                 <% end %>
@@ -126,8 +126,8 @@
126 126
             <% if @agent.can_create_events? %>
127 127
               <p>
128 128
                 <b>Event receivers:</b>
129
-                <% if @agent.receivers.length %>
130
-                  <%= @agent.receivers.map { |receiver_agent| link_to(receiver_agent.name, agent_path(receiver_agent)) }.to_sentence.html_safe %>
129
+                <% if (agents = @agent.receivers).length > 0 %>
130
+                  <%= agents.map { |agent| link_to(agent.name, agent_path(agent)) }.to_sentence.html_safe %>
131 131
                 <% else %>
132 132
                   None
133 133
                 <% end %>

+ 26 - 0
app/views/application/_upgrade_warning.html.erb

@@ -0,0 +1,26 @@
1
+<% if @twitter_agent || @basecamp_agent %>
2
+  <div class="alert alert-danger" role="alert">
3
+    <p>
4
+      <b>Warning!</b> You need to update your Huginn configuration, so your agents continue to work with the new OAuth services.
5
+    </p>
6
+    <br/>
7
+    <% if @twitter_agent %>
8
+      <p>
9
+        To complete the migration of your <b>Twitter</b> agents you need to update your .env file and add the following two lines:
10
+
11
+        <pre>
12
+TWITTER_OAUTH_KEY=<%= @twitter_oauth_key %>
13
+TWITTER_OAUTH_SECRET=<%= @twitter_oauth_secret %>
14
+        </pre>
15
+        To authenticate new accounts with your twitter OAuth application you need to log in the to <a href="https://apps.twitter.com/" target="_blank">twitter application management page</a> and set the callback URL of your application to "http<%= ENV['FORCE_SSL'] == 'true' ? 's' : '' %>://<%= ENV['DOMAIN'] %>/auth/twitter/callback".
16
+
17
+      </p>
18
+    <% end %>
19
+    <% if @basecamp_agent %>
20
+      <p>
21
+        Your <b>Basecamp</b> agents could not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it.<br/>
22
+        Have a look at the <%= link_to 'Wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: '_blank' %> if you need help.
23
+      </p>
24
+    <% end %>
25
+  </div>
26
+<% end -%>

+ 4 - 1
app/views/layouts/application.html.erb

@@ -24,7 +24,10 @@
24 24
           <%= render 'layouts/messages' %>
25 25
         </div>
26 26
       </div>
27
-      
27
+      <% if user_signed_in? %>
28
+        <%= render "upgrade_warning" %>
29
+      <% end %>
30
+
28 31
       <%= yield %>
29 32
       
30 33
     </div>

+ 5 - 5
app/views/scenario_imports/_step_two.html.erb

@@ -13,9 +13,8 @@
13 13
       <div class="alert alert-warning">
14 14
         <span class='glyphicon glyphicon-warning-sign'></span>
15 15
         This Scenario already exists in your system. The import will update your existing
16
-        <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title
17
-        and
18
-        description. Below you can customize how the individual agents get updated.
16
+        <%= scenario_label(@scenario_import.existing_scenario) %> Scenario's title,
17
+        description and tag colors. Below you can customize how the individual agents get updated.
19 18
       </div>
20 19
     <% end %>
21 20
 
@@ -30,7 +29,7 @@
30 29
     </div>
31 30
 
32 31
     <% if @scenario_import.parsed_data["description"].present? %>
33
-      <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote>
32
+      <blockquote><%= markdown(@scenario_import.parsed_data["description"]) %></blockquote>
34 33
     <% end %>
35 34
 
36 35
   </div>
@@ -120,12 +119,13 @@
120 119
           </div>
121 120
         <% end %>
122 121
       </div>
122
+
123 123
       <% if agent_diff.requires_service? %>
124 124
         <div class='row'>
125 125
           <div class='col-md-4'>
126 126
             <div class="form-group type-select">
127 127
               <%= label_tag "scenario_import[merges][#{index}][service_id]", 'Service' %>
128
-              <%= select_tag "scenario_import[merges][#{index}][service_id]", options_for_select(agent_diff.agent_instance.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent_diff.service_id.try(:current)), class: 'form-control' %>
128
+              <%= select_tag "scenario_import[merges][#{index}][service_id]", options_for_select(agent_diff.agent_instance.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent_diff.agent.try(:service_id)), class: 'form-control' %>
129 129
             </div>
130 130
           </div>
131 131
         </div>

+ 13 - 1
app/views/scenarios/_form.html.erb

@@ -15,6 +15,18 @@
15 15
         <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
16 16
       </div>
17 17
     </div>
18
+    <div class="col-md-2">
19
+      <div class="form-group">
20
+        <%= f.label :tag_bg_color, "Tag Background Color" %>
21
+        <%= f.color_field :tag_bg_color, :class => 'form-control', :value => @scenario.tag_bg_color || default_scenario_bg_color %>
22
+      </div>
23
+    </div>
24
+    <div class="col-md-2">
25
+      <div class="form-group">
26
+        <%= f.label :tag_fg_color, "Tag Foreground Color" %>
27
+        <%= f.color_field :tag_fg_color, :class => 'form-control', :value => @scenario.tag_fg_color || default_scenario_fg_color %>
28
+      </div>
29
+    </div>
18 30
   </div>
19 31
 
20 32
   <div class="row">
@@ -54,4 +66,4 @@
54 66
       </div>
55 67
     </div>
56 68
   </div>
57
-<% end %>
69
+<% end %>

+ 2 - 1
app/views/scenarios/index.html.erb

@@ -21,6 +21,7 @@
21 21
         <% @scenarios.each do |scenario| %>
22 22
           <tr>
23 23
             <td>
24
+              <%= scenario_label(scenario, content_tag(:i, '', class: 'glyphicon glyphicon-font')) %>
24 25
               <%= link_to(scenario.name, scenario) %>
25 26
             </td>
26 27
             <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
@@ -47,4 +48,4 @@
47 48
       </div>
48 49
     </div>
49 50
   </div>
50
-</div>
51
+</div>

+ 2 - 2
app/views/scenarios/share.html.erb

@@ -2,7 +2,7 @@
2 2
   <div class='row'>
3 3
     <div class='col-md-12'>
4 4
       <div class="page-header">
5
-        <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
5
+        <h2>Share <%= scenario_label(@scenario) %> with the world</h2>
6 6
       </div>
7 7
 
8 8
       <p>
@@ -30,4 +30,4 @@
30 30
       </div>
31 31
     </div>
32 32
   </div>
33
-</div>
33
+</div>

+ 3 - 2
app/views/scenarios/show.html.erb

@@ -2,11 +2,12 @@
2 2
   <div class='row'>
3 3
     <div class='col-md-12'>
4 4
       <div class="page-header">
5
-        <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2>
5
+        <h2><%= scenario_label(@scenario) %> <%= "Public" if @scenario.public? %> Scenario</h2>
6
+
6 7
       </div>
7 8
 
8 9
       <% if @scenario.description.present? %>
9
-        <blockquote><%= @scenario.description %></blockquote>
10
+        <blockquote><%= markdown(@scenario.description) %></blockquote>
10 11
       <% end %>
11 12
 
12 13
       <%= render 'agents/table', :returnTo => scenario_path(@scenario) %>

+ 12 - 6
app/views/services/index.html.erb

@@ -7,13 +7,19 @@
7 7
         </h2>
8 8
       </div>
9 9
       <p>
10
-        Before you can authenticate with a service, you need to set it up. Have a look at the
10
+        Before you can authenticate with a service, you need to set it up. Have a look at the Huginn
11 11
         <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %>
12 12
         for guidance.
13 13
       </p>
14
-      <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p>
15
-      <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p>
16
-      <p><%= link_to "Authenticate with Github", "/auth/github" %></p>
14
+      <% if has_oauth_configuration_for('twitter') %>
15
+        <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p>
16
+      <% end %>
17
+      <% if has_oauth_configuration_for('thirty_seven_signals') %>
18
+        <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p>
19
+      <% end -%>
20
+      <% if has_oauth_configuration_for('github') %>
21
+        <p><%= link_to "Authenticate with Github", "/auth/github" %></p>
22
+      <% end -%>
17 23
       <hr>
18 24
 
19 25
       <div class='table-responsive'>
@@ -33,9 +39,9 @@
33 39
             <td>
34 40
               <div class="btn-group btn-group-xs">
35 41
                 <% if service.global %>
36
-                  <%= link_to 'Make private', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to remove the access to this service for every user?'}, class: "btn btn-default" %>
42
+                  <%= link_to 'Make private', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to remove access to your data on this service for other users?'}, class: "btn btn-default" %>
37 43
                 <% else %>
38
-                   <%= link_to 'Make global', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to grant every user access to this service?'}, class: "btn btn-default" %>
44
+                   <%= link_to 'Make global', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to grant every user on this system access to your data on this service?'}, class: "btn btn-default" %>
39 45
                 <% end %>
40 46
                 <%= link_to 'Delete', service_path(service), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
41 47
               </div>

+ 3 - 3
app/views/system_mailer/send_message.html.erb

@@ -5,14 +5,14 @@
5 5
   </head>
6 6
   <body>
7 7
     <% if @headline %>
8
-      <h1><%= @headline %></h1>
8
+      <h1><%= sanitize @headline %></h1>
9 9
     <% end %>
10 10
     <% @groups.each do |group| %>
11 11
       <div style='margin-bottom: 10px;'>
12
-        <div><%= group[:title] %></div>
12
+        <div><%= sanitize group[:title] %></div>
13 13
         <% group[:entries].each do |entry| %>
14 14
           <div style='margin-left: 10px;'>
15
-            <%= entry %>
15
+            <%= sanitize entry %>
16 16
           </div>
17 17
         <% end %>
18 18
       </div>

+ 7 - 1
db/migrate/20140525150140_migrate_agents_to_service_authentication.rb

@@ -26,6 +26,7 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration
26 26
       agent.service_id = service.id
27 27
       agent.save!(validate: false)
28 28
     end
29
+    migrated = false
29 30
     if agents.length > 0
30 31
       puts <<-EOF.strip_heredoc
31 32
 
@@ -34,18 +35,23 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration
34 35
         TWITTER_OAUTH_KEY=#{twitter_consumer_key(agents.first)}
35 36
         TWITTER_OAUTH_SECRET=#{twitter_consumer_secret(agents.first)}
36 37
 
38
+        To authenticate new accounts with your twitter OAuth application you need to log in the to twitter application management page (https://apps.twitter.com/)
39
+        and set the callback URL of your application to "http#{ENV['FORCE_SSL'] == 'true' ? 's' : ''}://#{ENV['DOMAIN']}/auth/twitter/callback"
37 40
 
38 41
       EOF
42
+      migrated = true
39 43
     end
40 44
     if Agent.where(type: ['Agents::BasecampAgent']).count > 0
41 45
       puts <<-EOF.strip_heredoc
42 46
 
43
-        Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate huginn to use it. 
47
+        Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it.
44 48
         Have a look at the wiki (https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications) if you need help.
45 49
 
46 50
 
47 51
       EOF
52
+      migrated = true
48 53
     end
54
+    sleep 20 if migrated
49 55
   end
50 56
 
51 57
   def down

+ 5 - 0
db/migrate/20140809211540_remove_service_index_on_user_id.rb

@@ -0,0 +1,5 @@
1
+class RemoveServiceIndexOnUserId < ActiveRecord::Migration
2
+  def change
3
+    remove_index :services, :user_id
4
+  end
5
+end

+ 7 - 0
db/migrate/20140811200922_add_uid_column_to_services.rb

@@ -0,0 +1,7 @@
1
+class AddUidColumnToServices < ActiveRecord::Migration
2
+  def change
3
+    add_column :services, :uid, :string
4
+    add_index :services, :uid
5
+    add_index :services, :provider
6
+  end
7
+end

+ 6 - 0
db/migrate/20140820003139_add_tag_color_to_scenarios.rb

@@ -0,0 +1,6 @@
1
+class AddTagColorToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_column :scenarios, :tag_bg_color, :string
4
+    add_column :scenarios, :tag_fg_color, :string
5
+  end
6
+end

+ 18 - 14
db/schema.rb

@@ -11,7 +11,7 @@
11 11
 #
12 12
 # It's strongly recommended that you check this file into your version control system.
13 13
 
14
-ActiveRecord::Schema.define(version: 20140813110107) do
14
+ActiveRecord::Schema.define(version: 20140820003139) do
15 15
 
16 16
   # These are extensions that must be enabled in order to support this database
17 17
   enable_extension "plpgsql"
@@ -22,8 +22,8 @@ ActiveRecord::Schema.define(version: 20140813110107) do
22 22
     t.integer  "level",             default: 3, null: false
23 23
     t.integer  "inbound_event_id"
24 24
     t.integer  "outbound_event_id"
25
-    t.datetime "created_at",                    null: false
26
-    t.datetime "updated_at",                    null: false
25
+    t.datetime "created_at"
26
+    t.datetime "updated_at"
27 27
   end
28 28
 
29 29
   create_table "agents", force: true do |t|
@@ -45,8 +45,8 @@ ActiveRecord::Schema.define(version: 20140813110107) do
45 45
     t.datetime "last_error_log_at"
46 46
     t.boolean  "propagate_immediately", default: false, null: false
47 47
     t.boolean  "disabled",              default: false, null: false
48
-    t.integer  "service_id"
49 48
     t.string   "guid",                                                     null: false, charset: "ascii",   collation: "ascii_bin"
49
+    t.integer  "service_id"
50 50
   end
51 51
 
52 52
   add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
@@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 20140813110107) do
64 64
     t.datetime "failed_at"
65 65
     t.string   "locked_by"
66 66
     t.string   "queue"
67
-    t.datetime "created_at",             null: false
68
-    t.datetime "updated_at",             null: false
67
+    t.datetime "created_at"
68
+    t.datetime "updated_at"
69 69
   end
70 70
 
71 71
   add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
@@ -88,8 +88,8 @@ ActiveRecord::Schema.define(version: 20140813110107) do
88 88
   create_table "links", force: true do |t|
89 89
     t.integer  "source_id"
90 90
     t.integer  "receiver_id"
91
-    t.datetime "created_at",                       null: false
92
-    t.datetime "updated_at",                       null: false
91
+    t.datetime "created_at"
92
+    t.datetime "updated_at"
93 93
     t.integer  "event_id_at_creation", default: 0, null: false
94 94
   end
95 95
 
@@ -115,15 +115,17 @@ ActiveRecord::Schema.define(version: 20140813110107) do
115 115
     t.boolean  "public",      default: false, null: false
116 116
     t.string   "guid",                        null: false, charset: "ascii",   collation: "ascii_bin"
117 117
     t.string   "source_url"
118
+    t.string   "tag_bg_color"
119
+    t.string   "tag_fg_color"
118 120
   end
119 121
 
120 122
   add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
121 123
 
122 124
   create_table "services", force: true do |t|
123
-    t.integer  "user_id"
124
-    t.string   "provider"
125
-    t.string   "name"
126
-    t.text     "token"
125
+    t.integer  "user_id",                       null: false
126
+    t.string   "provider",                      null: false
127
+    t.string   "name",                          null: false
128
+    t.text     "token",                         null: false
127 129
     t.text     "secret"
128 130
     t.text     "refresh_token"
129 131
     t.datetime "expires_at"
@@ -131,10 +133,12 @@ ActiveRecord::Schema.define(version: 20140813110107) do
131 133
     t.text     "options"
132 134
     t.datetime "created_at"
133 135
     t.datetime "updated_at"
136
+    t.string   "uid"
134 137
   end
135 138
 
136
-  add_index "services", ["user_id", "global"], name: "index_accounts_on_user_id_and_global", using: :btree
137
-  add_index "services", ["user_id"], name: "index_accounts_on_user_id", using: :btree
139
+  add_index "services", ["provider"], name: "index_services_on_provider", using: :btree
140
+  add_index "services", ["uid"], name: "index_services_on_uid", using: :btree
141
+  add_index "services", ["user_id", "global"], name: "index_services_on_user_id_and_global", using: :btree
138 142
 
139 143
   create_table "user_credentials", force: true do |t|
140 144
     t.integer  "user_id",                           null: false

+ 3 - 1
lib/agents_exporter.rb

@@ -16,6 +16,8 @@ class AgentsExporter
16 16
       :description => options[:description].presence || 'No description provided',
17 17
       :source_url => options[:source_url],
18 18
       :guid => options[:guid],
19
+      :tag_fg_color => options[:tag_fg_color],
20
+      :tag_bg_color => options[:tag_bg_color],
19 21
       :exported_at => Time.now.utc.iso8601,
20 22
       :agents => agents.map { |agent| agent_as_json(agent) },
21 23
       :links => links
@@ -51,4 +53,4 @@ class AgentsExporter
51 53
       options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events?
52 54
     end
53 55
   end
54
-end
56
+end

+ 15 - 0
spec/concerns/liquid_interpolatable_spec.rb

@@ -0,0 +1,15 @@
1
+require 'spec_helper'
2
+
3
+describe LiquidInterpolatable::Filters do
4
+  before do
5
+    @filter = Class.new do
6
+      include LiquidInterpolatable::Filters
7
+    end.new
8
+  end
9
+
10
+  describe 'uri_escape' do
11
+    it 'should escape a string for use in URI' do
12
+      @filter.uri_escape('abc:/?=').should == 'abc%3A%2F%3F%3D'
13
+    end
14
+  end
15
+end

+ 2 - 0
spec/controllers/scenarios_controller_spec.rb

@@ -50,6 +50,8 @@ describe ScenariosController do
50 50
       assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
51 51
       assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
52 52
       assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
53
+      assigns(:exporter).options[:tag_fg_color].should == scenarios(:bob_weather).tag_fg_color
54
+      assigns(:exporter).options[:tag_bg_color].should == scenarios(:bob_weather).tag_bg_color
53 55
       assigns(:exporter).options[:source_url].should be_falsey
54 56
       response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
55 57
       response.headers['Content-Type'].should == 'application/json; charset=utf-8'

+ 5 - 4
spec/controllers/services_controller_spec.rb

@@ -10,7 +10,7 @@ describe ServicesController do
10 10
   describe "GET index" do
11 11
     it "only returns sevices of the current user" do
12 12
       get :index
13
-      assigns(:services).all? {|i| i.user.should == users(:bob) }.should be_true
13
+      assigns(:services).all? {|i| i.user.should == users(:bob) }.should == true
14 14
     end
15 15
   end
16 16
 
@@ -41,17 +41,18 @@ describe ServicesController do
41 41
   end
42 42
 
43 43
   describe "accepting a callback url" do
44
-    it "should update the users credentials" do
44
+    it "should update the user's credentials" do
45 45
       expect {
46 46
         get :callback, provider: 'twitter'
47 47
       }.to change { users(:bob).services.count }.by(1)
48 48
     end
49 49
 
50
-    it "should not work with an unknown provider" do
50
+    it "should work with an unknown provider (for now)" do
51 51
       request.env["omniauth.auth"]['provider'] = 'unknown'
52 52
       expect {
53 53
         get :callback, provider: 'unknown'
54
-      }.to change { users(:bob).services.count }.by(0)
54
+      }.to change { users(:bob).services.count }.by(1)
55
+      users(:bob).services.first.provider.should == 'unknown'
55 56
     end
56 57
   end
57 58
 end

+ 6 - 0
spec/fixtures/agents.yml

@@ -115,3 +115,9 @@ bob_basecamp_agent:
115 115
   user: bob
116 116
   service: generic
117 117
   guid: <%= SecureRandom.hex %>
118
+
119
+jane_basecamp_agent:
120
+  type: Agents::BasecampAgent
121
+  user: jane
122
+  service: generic
123
+  guid: <%= SecureRandom.hex %>

+ 14 - 0
spec/helpers/markdown_helper_spec.rb

@@ -0,0 +1,14 @@
1
+require 'spec_helper'
2
+
3
+describe MarkdownHelper do
4
+
5
+  describe '#markdown' do
6
+
7
+    it 'renders HTML from a markdown text' do
8
+      markdown('# Header').should =~ /<h1>Header<\/h1>/
9
+      markdown('## Header 2').should =~ /<h2>Header 2<\/h2>/
10
+    end
11
+
12
+  end
13
+
14
+end

+ 30 - 0
spec/helpers/scenario_helper_spec.rb

@@ -0,0 +1,30 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioHelper do
4
+  let(:scenario) { users(:bob).scenarios.build(name: 'Scene', tag_fg_color: '#AAAAAA', tag_bg_color: '#000000') }
5
+
6
+  describe '#style_colors' do
7
+    it 'returns a css style-formated version of the scenario foreground and background colors' do
8
+      style_colors(scenario).should == "color:#AAAAAA;background-color:#000000"
9
+    end
10
+
11
+    it 'defauls foreground and background colors' do
12
+      scenario.tag_fg_color = nil
13
+      scenario.tag_bg_color = nil
14
+      style_colors(scenario).should == "color:#FFFFFF;background-color:#5BC0DE"
15
+    end
16
+  end
17
+
18
+  describe '#scenario_label' do
19
+    it 'creates a scenario label with the scenario name' do
20
+      scenario_label(scenario).should ==
21
+        '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Scene</span>'
22
+    end
23
+
24
+    it 'creates a scenario label with the given text' do
25
+      scenario_label(scenario, 'Other').should ==
26
+        '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Other</span>'
27
+    end
28
+  end
29
+
30
+end

+ 8 - 2
spec/lib/agents_exporter_spec.rb

@@ -7,9 +7,13 @@ describe AgentsExporter do
7 7
     let(:name) { "My set of Agents" }
8 8
     let(:description) { "These Agents work together nicely!" }
9 9
     let(:guid) { "some-guid" }
10
+    let(:tag_fg_color) { "#ffffff" }
11
+    let(:tag_bg_color) { "#000000" }
10 12
     let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
11 13
     let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
12
-    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
14
+    let(:exporter) { AgentsExporter.new(
15
+      :agents => agent_list, :name => name, :description => description, :source_url => source_url,
16
+      :guid => guid, :tag_fg_color => tag_fg_color, :tag_bg_color => tag_bg_color) }
13 17
 
14 18
     it "outputs a structure containing name, description, the date, all agents & their links" do
15 19
       data = exporter.as_json
@@ -17,6 +21,8 @@ describe AgentsExporter do
17 21
       data[:description].should == description
18 22
       data[:source_url].should == source_url
19 23
       data[:guid].should == guid
24
+      data[:tag_fg_color].should == tag_fg_color
25
+      data[:tag_bg_color].should == tag_bg_color
20 26
       Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
21 27
       data[:links].should == [{ :source => 0, :receiver => 1 }]
22 28
       data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
@@ -58,4 +64,4 @@ describe AgentsExporter do
58 64
       AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
59 65
     end
60 66
   end
61
-end
67
+end

+ 5 - 4
spec/models/agents/basecamp_agent_spec.rb

@@ -6,7 +6,7 @@ describe Agents::BasecampAgent do
6 6
 
7 7
   before(:each) do
8 8
     stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
9
-    stub_request(:get, /Z$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
9
+    stub_request(:get, /02:00$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
10 10
     @valid_params = { :project_id => 6789 }
11 11
 
12 12
     @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params)
@@ -43,7 +43,7 @@ describe Agents::BasecampAgent do
43 43
 
44 44
     it "should provide the since attribute after the first run" do
45 45
       time = (Time.now-1.minute).iso8601
46
-      @checker.memory[:last_run] = time
46
+      @checker.memory[:last_event] = time
47 47
       @checker.save
48 48
       @checker.reload.send(:query_parameters).should == {:query => {:since => time}}
49 49
     end
@@ -51,9 +51,10 @@ describe Agents::BasecampAgent do
51 51
   describe "#check" do
52 52
     it "should not emit events on its first run" do
53 53
       expect { @checker.check }.to change { Event.count }.by(0)
54
+      expect(@checker.memory[:last_event]).to eq '2014-04-17T10:25:31.000+02:00'
54 55
     end
55 56
     it "should check that initial run creates an event" do
56
-      @checker.last_check_at = Time.now - 1.minute
57
+      @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00'
57 58
       expect { @checker.check }.to change { Event.count }.by(1)
58 59
     end
59 60
   end
@@ -61,7 +62,7 @@ describe Agents::BasecampAgent do
61 62
   describe "#working?" do
62 63
     it "it is working when at least one event was emited" do
63 64
       @checker.should_not be_working
64
-      @checker.last_check_at = Time.now - 1.minute
65
+      @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00'
65 66
       @checker.check
66 67
       @checker.reload.should be_working
67 68
     end

+ 69 - 1
spec/models/agents/website_agent_spec.rb

@@ -398,18 +398,86 @@ describe Agents::WebsiteAgent do
398 398
           event.payload['response']['title'].should == "hello!"
399 399
         end
400 400
       end
401
+
402
+      describe "text parsing" do
403
+        before do
404
+          stub_request(:any, /text-site/).to_return(body: <<-EOF, status: 200)
405
+water: wet
406
+fire: hot
407
+          EOF
408
+          site = {
409
+            'name' => 'Some Text Response',
410
+            'expected_update_period_in_days' => '2',
411
+            'type' => 'text',
412
+            'url' => 'http://text-site.com',
413
+            'mode' => 'on_change',
414
+            'extract' => {
415
+              'word' => { 'regexp' => '^(.+?): (.+)$', index: 1 },
416
+              'property' => { 'regexp' => '^(.+?): (.+)$', index: 2 },
417
+            }
418
+          }
419
+          @checker = Agents::WebsiteAgent.new(name: 'Text Site', options: site)
420
+          @checker.user = users(:bob)
421
+          @checker.save!
422
+        end
423
+
424
+        it "works with regexp" do
425
+          @checker.options = @checker.options.merge('extract' => {
426
+            'word' => { 'regexp' => '^(?<word>.+?): (?<property>.+)$', index: 'word' },
427
+            'property' => { 'regexp' => '^(?<word>.+?): (?<property>.+)$', index: 'property' },
428
+          })
429
+
430
+          lambda {
431
+            @checker.check
432
+          }.should change { Event.count }.by(2)
433
+
434
+          event1, event2 = Event.last(2)
435
+          event1.payload['word'].should == 'water'
436
+          event1.payload['property'].should == 'wet'
437
+          event2.payload['word'].should == 'fire'
438
+          event2.payload['property'].should == 'hot'
439
+        end
440
+
441
+        it "works with regexp with named capture" do
442
+          lambda {
443
+            @checker.check
444
+          }.should change { Event.count }.by(2)
445
+
446
+          event1, event2 = Event.last(2)
447
+          event1.payload['word'].should == 'water'
448
+          event1.payload['property'].should == 'wet'
449
+          event2.payload['word'].should == 'fire'
450
+          event2.payload['property'].should == 'hot'
451
+        end
452
+      end
401 453
     end
402 454
 
403 455
     describe "#receive" do
404
-      it "should scrape from the url element in incoming event payload" do
456
+      before do
405 457
         @event = Event.new
406 458
         @event.agent = agents(:bob_rain_notifier_agent)
407 459
         @event.payload = { 'url' => "http://xkcd.com" }
460
+      end
461
+
462
+      it "should scrape from the url element in incoming event payload" do
463
+        lambda {
464
+          @checker.options = @valid_options
465
+          @checker.receive([@event])
466
+        }.should change { Event.count }.by(1)
467
+      end
468
+
469
+      it "should interpolate values from incoming event payload" do
470
+        @event.payload['title'] = 'XKCD'
408 471
 
409 472
         lambda {
473
+          @valid_options['extract']['site_title'] = {
474
+            'css' => "#comic img", 'value' => "'{{title}}'"
475
+          }
410 476
           @checker.options = @valid_options
411 477
           @checker.receive([@event])
412 478
         }.should change { Event.count }.by(1)
479
+
480
+        Event.last.payload['site_title'].should == 'XKCD'
413 481
       end
414 482
     end
415 483
   end

+ 3 - 3
spec/models/concerns/oauthable.rb

@@ -16,14 +16,14 @@ shared_examples_for Oauthable do
16 16
     @agent.oauthable?.should == true
17 17
   end
18 18
 
19
-  describe "valid_services" do
19
+  describe "valid_services_for" do
20 20
     it "should return all available services without specifying valid_oauth_providers" do
21 21
       @agent = Agents::OauthableTestAgent.new
22
-      @agent.valid_services(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort
22
+      @agent.valid_services_for(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort
23 23
     end
24 24
 
25 25
     it "should filter the services based on the agent defaults" do
26
-      @agent.valid_services(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers)
26
+      @agent.valid_services_for(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers)
27 27
     end
28 28
   end
29 29
 end

+ 9 - 0
spec/models/event_spec.rb

@@ -102,6 +102,15 @@ describe EventDrop do
102 102
     interpolate(t, @event).should eq('some title: http://some.site.example.org/')
103 103
   end
104 104
 
105
+  it 'should use created_at from the payload if it exists' do
106
+    created_at = @event.created_at - 86400
107
+    # Avoid timezone issue by using %s
108
+    @event.payload['created_at'] = created_at.strftime("%s")
109
+    @event.save!
110
+    t = '{{created_at | date:"%s" }}'
111
+    interpolate(t, @event).should eq(created_at.strftime("%s"))
112
+  end
113
+
105 114
   it 'should be iteratable' do
106 115
     # to_liquid returns self
107 116
     t = "{% for pair in to_liquid %}{{pair | join:':' }}\n{% endfor %}"

+ 11 - 3
spec/models/scenario_import_spec.rb

@@ -3,6 +3,8 @@ require 'spec_helper'
3 3
 describe ScenarioImport do
4 4
   let(:user) { users(:bob) }
5 5
   let(:guid) { "somescenarioguid" }
6
+  let(:tag_fg_color) { "#ffffff" }
7
+  let(:tag_bg_color) { "#000000" }
6 8
   let(:description) { "This is a cool Huginn Scenario that does something useful!" }
7 9
   let(:name) { "A useful Scenario" }
8 10
   let(:source_url) { "http://example.com/scenarios/2/export.json" }
@@ -58,10 +60,12 @@ describe ScenarioImport do
58 60
     }
59 61
   end
60 62
   let(:valid_parsed_data) do
61
-    { 
63
+    {
62 64
       :name => name,
63 65
       :description => description,
64 66
       :guid => guid,
67
+      :tag_fg_color => tag_fg_color,
68
+      :tag_bg_color => tag_bg_color,
65 69
       :source_url => source_url,
66 70
       :exported_at => 2.days.ago.utc.iso8601,
67 71
       :agents => [
@@ -154,7 +158,7 @@ describe ScenarioImport do
154 158
       end
155 159
     end
156 160
   end
157
-  
161
+
158 162
   describe "#dangerous?" do
159 163
     it "returns false on most Agents" do
160 164
       ScenarioImport.new(:data => valid_data).should_not be_dangerous
@@ -183,6 +187,8 @@ describe ScenarioImport do
183 187
           scenario_import.scenario.name.should == name
184 188
           scenario_import.scenario.description.should == description
185 189
           scenario_import.scenario.guid.should == guid
190
+          scenario_import.scenario.tag_fg_color.should == tag_fg_color
191
+          scenario_import.scenario.tag_bg_color.should == tag_bg_color
186 192
           scenario_import.scenario.source_url.should == source_url
187 193
           scenario_import.scenario.public.should be_falsey
188 194
         end
@@ -281,6 +287,8 @@ describe ScenarioImport do
281 287
 
282 288
           existing_scenario.reload
283 289
           existing_scenario.guid.should == guid
290
+          existing_scenario.tag_fg_color.should == tag_fg_color
291
+          existing_scenario.tag_bg_color.should == tag_bg_color
284 292
           existing_scenario.description.should == description
285 293
           existing_scenario.name.should == name
286 294
           existing_scenario.source_url.should == source_url
@@ -463,4 +471,4 @@ describe ScenarioImport do
463 471
       end
464 472
     end
465 473
   end
466
-end
474
+end

+ 24 - 0
spec/models/scenario_spec.rb

@@ -20,6 +20,30 @@ describe Scenario do
20 20
       new_instance.should_not be_valid
21 21
     end
22 22
 
23
+    it "validates tag_fg_color is hex color" do
24
+      new_instance.tag_fg_color = '#N07H3X'
25
+      new_instance.should_not be_valid
26
+      new_instance.tag_fg_color = '#BADA55'
27
+      new_instance.should be_valid
28
+    end
29
+
30
+    it "allows nil tag_fg_color" do
31
+      new_instance.tag_fg_color = nil
32
+      new_instance.should be_valid
33
+    end
34
+
35
+    it "validates tag_bg_color is hex color" do
36
+      new_instance.tag_bg_color = '#N07H3X'
37
+      new_instance.should_not be_valid
38
+      new_instance.tag_bg_color = '#BADA55'
39
+      new_instance.should be_valid
40
+    end
41
+
42
+    it "allows nil tag_bg_color" do
43
+      new_instance.tag_bg_color = nil
44
+      new_instance.should be_valid
45
+    end
46
+
23 47
     it "only allows Agents owned by user" do
24 48
       new_instance.agent_ids = [agents(:bob_website_agent).id]
25 49
       new_instance.should be_valid

+ 30 - 8
spec/models/service_spec.rb

@@ -5,13 +5,32 @@ describe Service do
5 5
     @user = users(:bob)
6 6
   end
7 7
 
8
-  it "should toggle the global flag" do
9
-    @service = services(:generic)
10
-    @service.global.should == false
11
-    @service.toggle_availability!
12
-    @service.global.should == true
13
-    @service.toggle_availability!
14
-    @service.global.should == false
8
+  describe "#toggle_availability!" do
9
+    it "should toggle the global flag" do
10
+      @service = services(:generic)
11
+      @service.global.should == false
12
+      @service.toggle_availability!
13
+      @service.global.should == true
14
+      @service.toggle_availability!
15
+      @service.global.should == false
16
+    end
17
+
18
+    it "disconnects agents and disables them if the previously global service is made private again", focus: true do
19
+      agent = agents(:bob_basecamp_agent)
20
+      jane_agent = agents(:jane_basecamp_agent)
21
+
22
+      service = agent.service
23
+      service.toggle_availability!
24
+      service.agents.length.should == 2
25
+
26
+      service.toggle_availability!
27
+      jane_agent.reload
28
+      jane_agent.service_id.should be_nil
29
+      jane_agent.disabled.should be true
30
+
31
+      service.reload
32
+      service.agents.length.should == 1
33
+    end
15 34
   end
16 35
 
17 36
   it "disables all agents before beeing destroyed" do
@@ -20,7 +39,7 @@ describe Service do
20 39
     service.destroy
21 40
     agent.reload
22 41
     agent.service_id.should be_nil
23
-    agent.disabled.should be_true
42
+    agent.disabled.should be true
24 43
   end
25 44
 
26 45
   describe "preparing for a request" do
@@ -74,6 +93,7 @@ describe Service do
74 93
       }.to change { @user.services.count }.by(1)
75 94
       service = @user.services.first
76 95
       service.name.should == 'johnqpublic'
96
+      service.uid.should == '123456'
77 97
       service.provider.should == 'twitter'
78 98
       service.token.should == 'a1b2c3d4...'
79 99
       service.secret.should == 'abcdef1234'
@@ -88,6 +108,7 @@ describe Service do
88 108
       service.provider.should == '37signals'
89 109
       service.name.should == 'Dominik Sander'
90 110
       service.token.should == 'abcde'
111
+      service.uid.should == '12345'
91 112
       service.refresh_token.should == 'fghrefresh'
92 113
       service.options[:user_id].should == 12345
93 114
       service.expires_at = Time.at(1401554352)
@@ -101,6 +122,7 @@ describe Service do
101 122
       service = @user.services.first
102 123
       service.provider.should == 'github'
103 124
       service.name.should == 'dsander'
125
+      service.uid.should == '12345'
104 126
       service.token.should == 'agithubtoken'
105 127
     end
106 128
   end

+ 1 - 1
spec/spec_helper.rb

@@ -10,7 +10,7 @@ end
10 10
 
11 11
 # Required ENV variables that are normally set in .env are setup here for the test environment.
12 12
 require 'dotenv'
13
-Dotenv.load File.join(File.dirname(__FILE__), "env.test")
13
+Dotenv.overload File.join(File.dirname(__FILE__), "env.test")
14 14
 
15 15
 require File.expand_path("../../config/environment", __FILE__)
16 16
 require 'rspec/rails'