Merge pull request #1032 from bencornelis/evernote_agent

Evernote agent

Andrew Cantino 8 years ago
parent
commit
9c5847451b

+ 5 - 0
.env.example

@@ -108,6 +108,11 @@ DROPBOX_OAUTH_SECRET=
108 108
 WUNDERLIST_OAUTH_KEY=
109 109
 WUNDERLIST_OAUTH_SECRET=
110 110
 
111
+EVERNOTE_OAUTH_KEY=
112
+EVERNOTE_OAUTH_SECRET=
113
+# Set to true in development, false in production
114
+USE_EVERNOTE_SANDBOX=true
115
+
111 116
 #############################
112 117
 #  AWS and Mechanical Turk  #
113 118
 #############################

+ 4 - 0
Gemfile

@@ -39,6 +39,10 @@ gem 'omniauth-dropbox'
39 39
 # UserLocationAgent
40 40
 gem 'haversine'
41 41
 
42
+# EvernoteAgent
43
+gem 'omniauth-evernote'
44
+gem 'evernote_oauth'
45
+
42 46
 # Optional Services.
43 47
 gem 'omniauth-37signals'          # BasecampAgent
44 48
 gem 'omniauth-wunderlist', github: 'wunderlist/omniauth-wunderlist', ref: 'd0910d0396107b9302aa1bc50e74bb140990ccb8'

+ 10 - 0
Gemfile.lock

@@ -179,6 +179,10 @@ GEM
179 179
     ethon (0.7.1)
180 180
       ffi (>= 1.3.0)
181 181
     eventmachine (1.0.7)
182
+    evernote-thrift (1.25.1)
183
+    evernote_oauth (0.2.3)
184
+      evernote-thrift
185
+      oauth (>= 0.4.1)
182 186
     execjs (2.3.0)
183 187
     extlib (0.9.16)
184 188
     faraday (0.9.1)
@@ -318,6 +322,10 @@ GEM
318 322
       omniauth-oauth2 (~> 1.0)
319 323
     omniauth-dropbox (0.2.0)
320 324
       omniauth-oauth (~> 1.0)
325
+    omniauth-evernote (1.2.1)
326
+      evernote-thrift
327
+      multi_json (~> 1.0)
328
+      omniauth-oauth (~> 1.0)
321 329
     omniauth-oauth (1.0.1)
322 330
       oauth
323 331
       omniauth (~> 1.0)
@@ -543,6 +551,7 @@ DEPENDENCIES
543 551
   dotenv-rails (~> 2.0.1)
544 552
   dropbox-api
545 553
   em-http-request (~> 1.1.2)
554
+  evernote_oauth
546 555
   faraday (~> 0.9.0)
547 556
   faraday_middleware (>= 0.10.0)
548 557
   feed-normalizer
@@ -576,6 +585,7 @@ DEPENDENCIES
576 585
   omniauth
577 586
   omniauth-37signals
578 587
   omniauth-dropbox
588
+  omniauth-evernote
579 589
   omniauth-tumblr
580 590
   omniauth-twitter
581 591
   omniauth-wunderlist!

+ 48 - 0
app/concerns/evernote_concern.rb

@@ -0,0 +1,48 @@
1
+module EvernoteConcern
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    include Oauthable
6
+
7
+    validate :validate_evernote_options
8
+
9
+    valid_oauth_providers :evernote
10
+
11
+    gem_dependency_check { defined?(EvernoteOAuth) && Devise.omniauth_providers.include?(:evernote) }
12
+  end
13
+
14
+  def evernote_client
15
+    EvernoteOAuth::Client.new(
16
+      token:           evernote_oauth_token,
17
+      consumer_key:    evernote_consumer_key,
18
+      consumer_secret: evernote_consumer_secret,
19
+      sandbox:         use_sandbox?
20
+    )
21
+  end
22
+
23
+  private
24
+
25
+  def use_sandbox?
26
+    ENV["USE_EVERNOTE_SANDBOX"] == "true"
27
+  end
28
+
29
+  def validate_evernote_options
30
+    unless evernote_consumer_key.present? &&
31
+      evernote_consumer_secret.present? &&
32
+      evernote_oauth_token.present?
33
+      errors.add(:base, "Evernote ENV variables and a Service are required")
34
+    end
35
+  end
36
+
37
+  def evernote_consumer_key
38
+    (config = Devise.omniauth_configs[:evernote]) && config.strategy.consumer_key
39
+  end
40
+
41
+  def evernote_consumer_secret
42
+    (config = Devise.omniauth_configs[:evernote]) && config.strategy.consumer_secret
43
+  end
44
+
45
+  def evernote_oauth_token
46
+    service && service.token
47
+  end
48
+end

+ 383 - 0
app/models/agents/evernote_agent.rb

@@ -0,0 +1,383 @@
1
+module Agents
2
+  class EvernoteAgent < Agent
3
+    include EvernoteConcern
4
+
5
+    description <<-MD
6
+      The Evernote Agent connects with a user's Evernote note store.
7
+
8
+      Visit [Evernote](https://dev.evernote.com/doc/) to set up an Evernote app and receive an api key and secret.
9
+      Store these in the Evernote environment variables in the .env file.
10
+      You will also need to create a [Sandbox](https://sandbox.evernote.com/Registration.action) account to use during development.
11
+
12
+      Next, you'll need to authenticate with Evernote in the [Services](/services) section.
13
+
14
+      Options:
15
+
16
+        * `mode` - Two possible values:
17
+
18
+            - `update` Based on events it receives, the agent will create notes
19
+                       or update notes with the same `title` and `notebook`
20
+
21
+            - `read`   On a schedule, it will generate events containing data for newly
22
+                       added or updated notes
23
+
24
+        * `include_xhtml_content` - Set to `true` to include the content in ENML (Evernote Markup Language) of the note
25
+
26
+        * `note`
27
+
28
+          - When `mode` is `update` the parameters of `note` are the attributes of the note to be added/edited.
29
+            To edit a note, both `title` and `notebook` must be set.
30
+
31
+            For example, to add the tags 'comic' and 'CS' to a note titled 'xkcd Survey' in the notebook 'xkcd', use:
32
+
33
+                "notes": {
34
+                  "title": "xkcd Survey",
35
+                  "content": "",
36
+                  "notebook": "xkcd",
37
+                  "tagNames": "comic, CS"
38
+                }
39
+
40
+            If a note with the above title and notebook did note exist already, one would be created.
41
+
42
+          - When `mode` is `read` the values are search parameters.
43
+            Note: The `content` parameter is not used for searching. Setting `title` only filters
44
+            notes whose titles contain `title` as a substring, not as the exact title.
45
+
46
+            For example, to find all notes with tag 'CS' in the notebook 'xkcd', use:
47
+
48
+                "notes": {
49
+                  "title": "",
50
+                  "content": "",
51
+                  "notebook": "xkcd",
52
+                  "tagNames": "CS"
53
+                }
54
+    MD
55
+
56
+    event_description <<-MD
57
+      When `mode` is `update`, events look like:
58
+
59
+          {
60
+            "title": "...",
61
+            "content": "...",
62
+            "notebook": "...",
63
+            "tags": "...",
64
+            "source": "...",
65
+            "sourceURL": "..."
66
+          }
67
+
68
+      When `mode` is `read`, events look like:
69
+
70
+          {
71
+            "title": "...",
72
+            "content": "...",
73
+            "notebook": "...",
74
+            "tags": "...",
75
+            "source": "...",
76
+            "sourceURL": "...",
77
+            "resources" : [
78
+              {
79
+                "url": "resource1_url",
80
+                "name": "resource1_name",
81
+                "mime_type": "resource1_mime_type"
82
+              }
83
+              ...
84
+            ]
85
+          }
86
+    MD
87
+
88
+    default_schedule "never"
89
+
90
+    def working?
91
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
92
+    end
93
+
94
+    def default_options
95
+      {
96
+        "expected_update_period_in_days" => "2",
97
+        "mode" => "update",
98
+        "include_xhtml_content" => "false",
99
+        "note" => {
100
+          "title" => "{{title}}",
101
+          "content" => "{{content}}",
102
+          "notebook" => "{{notebook}}",
103
+          "tagNames" => "{{tag1}}, {{tag2}}"
104
+        }
105
+      }
106
+    end
107
+
108
+    def validate_options
109
+      errors.add(:base, "mode must be 'update' or 'read'") unless %w(read update).include?(options[:mode])
110
+
111
+      if options[:mode] == "update" && schedule != "never"
112
+        errors.add(:base, "when mode is set to 'update', schedule must be 'never'")
113
+      end
114
+
115
+      if options[:mode] == "read" && schedule == "never"
116
+        errors.add(:base, "when mode is set to 'read', agent must have a schedule")
117
+      end
118
+
119
+      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
120
+
121
+      if options[:mode] == "update" && options[:note].values.all?(&:empty?)
122
+        errors.add(:base, "you must specify at least one note parameter to create or update a note")
123
+      end
124
+    end
125
+
126
+    def include_xhtml_content?
127
+      options[:include_xhtml_content] == "true"
128
+    end
129
+
130
+    def receive(incoming_events)
131
+      if options[:mode] == "update"
132
+        incoming_events.each do |event|
133
+          note = note_store.create_or_update_note(note_params(event))
134
+          create_event :payload => note.attr(include_content: include_xhtml_content?)
135
+        end
136
+      end
137
+    end
138
+
139
+    def check
140
+      if options[:mode] == "read"
141
+        opts = note_params(options)
142
+
143
+        # convert time to evernote timestamp format:
144
+        # https://dev.evernote.com/doc/reference/Types.html#Typedef_Timestamp
145
+        opts.merge!(agent_created_at: created_at.to_i * 1000)
146
+        opts.merge!(last_checked_at: (memory[:last_checked_at] ||= created_at.to_i * 1000))
147
+
148
+        if opts[:tagNames]
149
+          opts.merge!(notes_with_tags: (memory[:notes_with_tags] ||=
150
+            NoteStore::Search.new(note_store, {tagNames: opts[:tagNames]}).note_guids))
151
+        end
152
+
153
+        notes = NoteStore::Search.new(note_store, opts).notes
154
+        notes.each do |note|
155
+          memory[:notes_with_tags] << note.guid unless memory[:notes_with_tags].include?(note.guid)
156
+
157
+          create_event :payload => note.attr(include_resources: true, include_content: include_xhtml_content?)
158
+        end
159
+
160
+        memory[:last_checked_at] = Time.now.to_i * 1000
161
+      end
162
+    end
163
+
164
+    private
165
+
166
+    def note_params(options)
167
+      params = interpolated(options)[:note]
168
+      errors.add(:base, "only one notebook allowed") unless params[:notebook].to_s.split(/\s*,\s*/) == 1
169
+
170
+      params[:tagNames] = params[:tagNames].to_s.split(/\s*,\s*/)
171
+      params[:title].strip!
172
+      params[:notebook].strip!
173
+      params
174
+    end
175
+
176
+    def evernote_note_store
177
+      evernote_client.note_store
178
+    end
179
+
180
+    def note_store
181
+      @note_store ||= NoteStore.new(evernote_note_store)
182
+    end
183
+
184
+    # wrapper for evernote api NoteStore
185
+    # https://dev.evernote.com/doc/reference/
186
+    class NoteStore
187
+      attr_reader :en_note_store
188
+      delegate :createNote, :updateNote, :getNote, :listNotebooks, :listTags, :getNotebook,
189
+               :createNotebook, :findNotesMetadata, :getNoteTagNames, :to => :en_note_store
190
+
191
+      def initialize(en_note_store)
192
+        @en_note_store = en_note_store
193
+      end
194
+
195
+      def create_or_update_note(params)
196
+        search = Search.new(self, {title: params[:title], notebook: params[:notebook]})
197
+
198
+        # evernote search can only filter notes with titles containing a substring;
199
+        # this finds a note with the exact title
200
+        note = search.notes.detect {|note| note.title == params[:title]}
201
+
202
+        if note
203
+          # a note with specified title and notebook exists, so update it
204
+          update_note(params.merge(guid: note.guid, notebookGuid: note.notebookGuid))
205
+        else
206
+          # create the notebook unless it already exists
207
+          notebook = find_notebook(name: params[:notebook])
208
+          notebook_guid =
209
+            notebook ? notebook.guid : create_notebook(params[:notebook]).guid
210
+
211
+          create_note(params.merge(notebookGuid: notebook_guid))
212
+        end
213
+      end
214
+
215
+      def create_note(params)
216
+        note = Evernote::EDAM::Type::Note.new(with_wrapped_content(params))
217
+        en_note = createNote(note)
218
+        find_note(en_note.guid)
219
+      end
220
+
221
+      def update_note(params)
222
+        # do not empty note properties that have not been set in `params`
223
+        params.keys.each { |key| params.delete(key) unless params[key].present? }
224
+        params = with_wrapped_content(params)
225
+
226
+        # append specified tags instead of replacing current tags
227
+        # evernote will create any new tags
228
+        tags = getNoteTagNames(params[:guid])
229
+        tags.each { |tag|
230
+          params[:tagNames] << tag unless params[:tagNames].include?(tag) }
231
+
232
+        note = Evernote::EDAM::Type::Note.new(params)
233
+        updateNote(note)
234
+        find_note(params[:guid])
235
+      end
236
+
237
+      def find_note(guid)
238
+        # https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_getNote
239
+        en_note = getNote(guid, true, false, false, false)
240
+        build_note(en_note)
241
+      end
242
+
243
+      def build_note(en_note)
244
+        notebook = find_notebook(guid: en_note.notebookGuid).try(:name)
245
+        tags = en_note.tagNames || find_tags(en_note.tagGuids.to_a).map(&:name)
246
+        Note.new(en_note, notebook, tags)
247
+      end
248
+
249
+      def find_tags(guids)
250
+        listTags.select {|tag| guids.include?(tag.guid)}
251
+      end
252
+
253
+      def find_notebook(params)
254
+        if params[:guid]
255
+          listNotebooks.detect {|notebook| notebook.guid == params[:guid]}
256
+        elsif params[:name]
257
+          listNotebooks.detect {|notebook| notebook.name == params[:name]}
258
+        end
259
+      end
260
+
261
+      def create_notebook(name)
262
+        notebook = Evernote::EDAM::Type::Notebook.new(name: name)
263
+        createNotebook(notebook)
264
+      end
265
+
266
+      def with_wrapped_content(params)
267
+        params.delete(:notebook)
268
+
269
+        if params[:content]
270
+          params[:content] =
271
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
272
+            "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
273
+            "<en-note>#{params[:content].encode(:xml => :text)}</en-note>"
274
+        end
275
+
276
+        params
277
+      end
278
+
279
+      class Search
280
+        attr_reader :note_store, :opts
281
+        def initialize(note_store, opts)
282
+          @note_store = note_store
283
+          @opts = opts
284
+        end
285
+
286
+        def note_guids
287
+          filtered_metadata.map(&:guid)
288
+        end
289
+
290
+        def notes
291
+          metadata = filtered_metadata
292
+
293
+          if opts[:last_checked_at] && opts[:tagNames]
294
+
295
+            # evernote does note change Note#updated timestamp when a tag is added to a note
296
+            # the following selects recently updated notes
297
+            # and notes that recently had the specified tags added
298
+            metadata.select! do |note_data|
299
+              note_data.updated > opts[:last_checked_at] ||
300
+              !opts[:notes_with_tags].include?(note_data.guid)
301
+            end
302
+
303
+          elsif opts[:last_checked_at]
304
+            metadata.select! { |note_data| note_data.updated > opts[:last_checked_at] }
305
+          end
306
+
307
+          metadata.map! { |note_data| note_store.find_note(note_data.guid) }
308
+          metadata
309
+        end
310
+
311
+        def create_filter
312
+          filter = Evernote::EDAM::NoteStore::NoteFilter.new
313
+
314
+          # evernote search grammar:
315
+          # https://dev.evernote.com/doc/articles/search_grammar.php#Search_Terms
316
+          query_terms = []
317
+          query_terms << "notebook:\"#{opts[:notebook]}\"" if opts[:notebook].present?
318
+          query_terms << "intitle:\"#{opts[:title]}\""     if opts[:title].present?
319
+          query_terms << "updated:day-1"                   if opts[:last_checked_at].present?
320
+          opts[:tagNames].to_a.each { |tag| query_terms << "tag:#{tag}" }
321
+
322
+          filter.words = query_terms.join(" ")
323
+          filter
324
+        end
325
+
326
+        private
327
+
328
+        def filtered_metadata
329
+          filter, spec = create_filter, create_spec
330
+          metadata = note_store.findNotesMetadata(filter, 0, 100, spec).notes
331
+        end
332
+
333
+        def create_spec
334
+          Evernote::EDAM::NoteStore::NotesMetadataResultSpec.new(
335
+            includeTitle: true,
336
+            includeAttributes: true,
337
+            includeNotebookGuid: true,
338
+            includeTagGuids: true,
339
+            includeUpdated: true,
340
+            includeCreated: true
341
+          )
342
+        end
343
+      end
344
+    end
345
+
346
+    class Note
347
+      attr_accessor :en_note
348
+      attr_reader :notebook, :tags
349
+      delegate :guid, :notebookGuid, :title, :tagGuids, :content, :resources,
350
+               :attributes, :to => :en_note
351
+
352
+      def initialize(en_note, notebook, tags)
353
+        @en_note = en_note
354
+        @notebook = notebook
355
+        @tags = tags
356
+      end
357
+
358
+      def attr(opts = {})
359
+        return_attr = {
360
+          title:        title,
361
+          notebook:     notebook,
362
+          tags:         tags,
363
+          source:       attributes.source,
364
+          source_url:   attributes.sourceURL
365
+        }
366
+
367
+        return_attr[:content] = content if opts[:include_content]
368
+
369
+        if opts[:include_resources] && resources
370
+          return_attr[:resources] = []
371
+          resources.each do |resource|
372
+            return_attr[:resources] << {
373
+              url:       resource.attributes.sourceURL,
374
+              name:      resource.attributes.fileName,
375
+              mime_type: resource.mime
376
+            }
377
+          end
378
+        end
379
+        return_attr
380
+      end
381
+    end
382
+  end
383
+end

+ 11 - 0
config/initializers/devise.rb

@@ -263,6 +263,17 @@ Devise.setup do |config|
263 263
     config.omniauth :wunderlist, key, secret
264 264
   end
265 265
 
266
+  if defined?(OmniAuth::Strategies::Evernote) &&
267
+    (key = ENV["EVERNOTE_OAUTH_KEY"]).present? &&
268
+    (secret = ENV["EVERNOTE_OAUTH_SECRET"]).present?
269
+
270
+    if ENV["USE_EVERNOTE_SANDBOX"] == "true"
271
+      config.omniauth :evernote, key, secret, client_options: { :site => 'https://sandbox.evernote.com' }
272
+    else
273
+      config.omniauth :evernote, key, secret
274
+    end
275
+  end
276
+
266 277
   # ==> Warden configuration
267 278
   # If you want to use other strategies, that are not supported by Devise, or
268 279
   # change the failure app, you can configure them inside the config.warden block.

+ 1 - 0
config/locales/devise.en.yml

@@ -33,6 +33,7 @@ en:
33 33
       37signals: "37Signals (Basecamp)"
34 34
       dropbox: "Dropbox"
35 35
       wunderlist: 'Wunderlist'
36
+      evernote: "Evernote"
36 37
     passwords:
37 38
       no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
38 39
       send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."

+ 3 - 1
spec/env.test

@@ -8,4 +8,6 @@ THIRTY_SEVEN_SIGNALS_OAUTH_SECRET=TESTSECRET
8 8
 DROPBOX_OAUTH_KEY=dropboxoauthkey
9 9
 DROPBOX_OAUTH_SECRET=dropboxoauthsecret
10 10
 WUNDERLIST_OAUTH_KEY=wunderoauthkey
11
-FAILED_JOBS_TO_KEEP=2
11
+EVERNOTE_OAUTH_KEY=evernoteoauthkey
12
+EVERNOTE_OAUTH_SECRET=evernoteoauthsecret
13
+FAILED_JOBS_TO_KEEP=2

+ 576 - 0
spec/models/agents/evernote_agent_spec.rb

@@ -0,0 +1,576 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::EvernoteAgent do
4
+  class FakeEvernoteNoteStore
5
+    attr_accessor :notes, :tags, :notebooks
6
+    def initialize
7
+      @notes, @tags, @notebooks = [], [], []
8
+    end
9
+
10
+    def createNote(note)
11
+      note.attributes = OpenStruct.new(source: nil, sourceURL: nil)
12
+      note.guid = @notes.length + 1
13
+      @notes << note
14
+      note
15
+    end
16
+
17
+    def updateNote(note)
18
+      note.attributes = OpenStruct.new(source: nil, sourceURL: nil)
19
+      old_note = @notes.find {|en_note| en_note.guid == note.guid}
20
+      @notes[@notes.index(old_note)] = note
21
+      note
22
+    end
23
+
24
+    def getNote(guid, *other_args)
25
+      @notes.find {|note| note.guid == guid}
26
+    end
27
+
28
+    def createNotebook(notebook)
29
+      notebook.guid = @notebooks.length + 1
30
+      @notebooks << notebook
31
+      notebook
32
+    end
33
+
34
+    def createTag(tag)
35
+      tag.guid = @tags.length + 1
36
+      @tags << tag
37
+      tag
38
+    end
39
+
40
+    def listNotebooks; @notebooks; end
41
+
42
+    def listTags; @tags; end
43
+
44
+    def getNoteTagNames(guid)
45
+      getNote(guid).try(:tagNames) || []
46
+    end
47
+
48
+    def findNotesMetadata(*args); end
49
+  end
50
+
51
+  let(:en_note_store) do
52
+    FakeEvernoteNoteStore.new
53
+  end
54
+
55
+  before do
56
+    stub.any_instance_of(Agents::EvernoteAgent).evernote_note_store { en_note_store }
57
+  end
58
+
59
+  describe "#receive" do
60
+    context "when mode is set to 'update'" do
61
+      before do
62
+        @options = {
63
+          :mode => "update",
64
+          :include_xhtml_content => "false",
65
+          :expected_update_period_in_days => "2",
66
+          :note => {
67
+            :title     => "{{title}}",
68
+            :content   => "{{content}}",
69
+            :notebook  => "{{notebook}}",
70
+            :tagNames  => "{{tag1}}, {{tag2}}"
71
+          }
72
+        }
73
+        @agent = Agents::EvernoteAgent.new(:name => "evernote updater", :options => @options)
74
+        @agent.service = services(:generic)
75
+        @agent.user = users(:bob)
76
+        @agent.save!
77
+
78
+        @event = Event.new
79
+        @event.agent = agents(:bob_website_agent)
80
+        @event.payload = { :title => "xkcd Survey",
81
+                           :content => "The xkcd Survey: Big Data for a Big Planet",
82
+                           :notebook => "xkcd",
83
+                           :tag1 => "funny",
84
+                           :tag2 => "data" }
85
+        @event.save!
86
+
87
+        tag1 = OpenStruct.new(name: "funny")
88
+        tag2 = OpenStruct.new(name: "data")
89
+        [tag1, tag2].each { |tag| en_note_store.createTag(tag) }
90
+      end
91
+
92
+      it "adds a note for any payload it receives" do
93
+        stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
94
+        Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
95
+
96
+        expect(en_note_store.notes.size).to eq(1)
97
+        expect(en_note_store.notes.first.title).to eq("xkcd Survey")
98
+        expect(en_note_store.notebooks.size).to eq(1)
99
+        expect(en_note_store.tags.size).to eq(2)
100
+
101
+        expect(@agent.events.count).to eq(1)
102
+        expect(@agent.events.first.payload).to eq({
103
+          "title" => "xkcd Survey",
104
+          "notebook" => "xkcd",
105
+          "tags" => ["funny", "data"],
106
+          "source" => nil,
107
+          "source_url" => nil
108
+        })
109
+      end
110
+
111
+      context "a note with the same title and notebook exists" do
112
+        before do
113
+          note1 = OpenStruct.new(title: "xkcd Survey", notebookGuid: 1)
114
+          note2 = OpenStruct.new(title: "Footprints", notebookGuid: 1)
115
+          [note1, note2].each { |note| en_note_store.createNote(note) }
116
+          en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
117
+
118
+          stub(en_note_store).findNotesMetadata {
119
+            OpenStruct.new(notes: [note1]) }
120
+        end
121
+
122
+        it "updates the existing note" do
123
+          Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
124
+
125
+          expect(en_note_store.notes.size).to eq(2)
126
+          expect(en_note_store.getNote(1).tagNames).to eq(["funny", "data"])
127
+          expect(@agent.events.count).to eq(1)
128
+        end
129
+      end
130
+
131
+      context "include_xhtml_content is set to 'true'" do
132
+        before do
133
+          @agent.options[:include_xhtml_content] = "true"
134
+          @agent.save!
135
+        end
136
+
137
+        it "creates an event with note content wrapped in ENML" do
138
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
139
+          Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
140
+
141
+          payload = @agent.events.first.payload
142
+
143
+          expect(payload[:content]).to eq(
144
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
145
+            "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
146
+            "<en-note>The xkcd Survey: Big Data for a Big Planet</en-note>"
147
+          )
148
+        end
149
+      end
150
+    end
151
+  end
152
+
153
+  describe "#check" do
154
+    context "when mode is set to 'read'" do
155
+      before do
156
+        @options = {
157
+          :mode => "read",
158
+          :include_xhtml_content => "false",
159
+          :expected_update_period_in_days => "2",
160
+          :note => {
161
+            :title     => "",
162
+            :content   => "",
163
+            :notebook  => "xkcd",
164
+            :tagNames  => "funny, comic"
165
+          }
166
+        }
167
+        @checker = Agents::EvernoteAgent.new(:name => "evernote reader", :options => @options)
168
+
169
+        @checker.service = services(:generic)
170
+        @checker.user = users(:bob)
171
+        @checker.schedule = "every_2h"
172
+
173
+        @checker.save!
174
+        @checker.created_at = 1.minute.ago
175
+
176
+        en_note_store.createNote(
177
+          OpenStruct.new(title: "xkcd Survey",
178
+                         notebookGuid: 1,
179
+                         updated: 2.minutes.ago.to_i * 1000,
180
+                         tagNames: ["funny", "comic"])
181
+        )
182
+        en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
183
+        tag1 = OpenStruct.new(name: "funny")
184
+        tag2 = OpenStruct.new(name: "comic")
185
+        [tag1, tag2].each { |tag| en_note_store.createTag(tag) }
186
+
187
+        stub(en_note_store).findNotesMetadata {
188
+          notes = en_note_store.notes.select do |note|
189
+            note.notebookGuid == 1 &&
190
+            %w(funny comic).all? { |tag_name| note.tagNames.include?(tag_name) }
191
+          end
192
+          OpenStruct.new(notes: notes)
193
+        }
194
+      end
195
+
196
+      context "the first time it checks" do
197
+        it "returns only notes created/updated since it was created" do
198
+          expect { @checker.check }.to change { Event.count }.by(0)
199
+        end
200
+      end
201
+
202
+      context "on subsequent checks" do
203
+        it "returns notes created/updated since the last time it checked" do
204
+          expect { @checker.check }.to change { Event.count }.by(0)
205
+
206
+          future_time = (Time.now + 1.minute).to_i * 1000
207
+          en_note_store.createNote(
208
+            OpenStruct.new(title: "Footprints",
209
+                           notebookGuid: 1,
210
+                           tagNames: ["funny", "comic", "recent"],
211
+                           updated: future_time))
212
+
213
+          en_note_store.createNote(
214
+            OpenStruct.new(title: "something else",
215
+                           notebookGuid: 2,
216
+                           tagNames: ["funny", "comic"],
217
+                           updated: future_time))
218
+
219
+          expect { @checker.check }.to change { Event.count }.by(1)
220
+        end
221
+
222
+        it "returns notes tagged since the last time it checked" do
223
+          en_note_store.createNote(
224
+            OpenStruct.new(title: "Footprints",
225
+                           notebookGuid: 1,
226
+                           tagNames: [],
227
+                           created: Time.now.to_i * 1000,
228
+                           updated: Time.now.to_i * 1000))
229
+          @checker.check
230
+
231
+          en_note_store.getNote(2).tagNames = ["funny", "comic"]
232
+
233
+          expect { @checker.check }.to change { Event.count }.by(1)
234
+        end
235
+      end
236
+    end
237
+  end
238
+
239
+  describe "#validation" do
240
+    before do
241
+      @options = {
242
+        :mode => "update",
243
+        :include_xhtml_content => "false",
244
+        :expected_update_period_in_days => "2",
245
+        :note => {
246
+          :title     => "{{title}}",
247
+          :content   => "{{content}}",
248
+          :notebook  => "{{notebook}}",
249
+          :tagNames  => "{{tag1}}, {{tag2}}"
250
+        }
251
+      }
252
+      @agent = Agents::EvernoteAgent.new(:name => "evernote updater", :options => @options)
253
+      @agent.service = services(:generic)
254
+      @agent.user = users(:bob)
255
+      @agent.save!
256
+
257
+      expect(@agent).to be_valid
258
+    end
259
+
260
+    it "requires the mode to be 'update' or 'read'" do
261
+      @agent.options[:mode] = ""
262
+      expect(@agent).not_to be_valid
263
+    end
264
+
265
+    context "mode is set to 'update'" do
266
+      before do
267
+        @agent.options[:mode] = "update"
268
+      end
269
+
270
+      it "requires some note parameter to be present" do
271
+        @agent.options[:note].keys.each { |k| @agent.options[:note][k] = "" }
272
+        expect(@agent).not_to be_valid
273
+      end
274
+
275
+      it "requires schedule to be 'never'" do
276
+        @agent.schedule = 'never'
277
+        expect(@agent).to be_valid
278
+
279
+        @agent.schedule = 'every_1m'
280
+        expect(@agent).not_to be_valid
281
+      end
282
+    end
283
+
284
+    context "mode is set to 'read'" do
285
+      before do
286
+        @agent.options[:mode] = "read"
287
+      end
288
+
289
+      it "requires a schedule to be set" do
290
+        @agent.schedule = 'every_1m'
291
+        expect(@agent).to be_valid
292
+
293
+        @agent.schedule = 'never'
294
+        expect(@agent).not_to be_valid
295
+      end
296
+    end
297
+  end
298
+
299
+  # api wrapper classes
300
+  describe Agents::EvernoteAgent::NoteStore do
301
+    let(:note_store) { Agents::EvernoteAgent::NoteStore.new(en_note_store) }
302
+
303
+    let(:note1) { OpenStruct.new(title: "first note") }
304
+    let(:note2) { OpenStruct.new(title: "second note") }
305
+
306
+    before do
307
+      en_note_store.createNote(note1)
308
+      en_note_store.createNote(note2)
309
+    end
310
+
311
+    describe "#create_note" do
312
+      it "creates a note with given params in evernote note store" do
313
+        note_store.create_note(title: "third note")
314
+
315
+        expect(en_note_store.notes.size).to eq(3)
316
+        expect(en_note_store.notes.last.title).to eq("third note")
317
+      end
318
+
319
+      it "returns a note" do
320
+        expect(note_store.create_note(title: "third note")).to be_a(Agents::EvernoteAgent::Note)
321
+      end
322
+    end
323
+
324
+    describe "#update_note" do
325
+      it "updates an existing note with given params" do
326
+        note_store.update_note(guid: 1, content: "some words")
327
+
328
+        expect(en_note_store.notes.first.content).not_to be_nil
329
+        expect(en_note_store.notes.size).to eq(2)
330
+      end
331
+
332
+      it "returns a note" do
333
+        expect(note_store.update_note(guid: 1, content: "some words")).to be_a(Agents::EvernoteAgent::Note)
334
+      end
335
+    end
336
+
337
+    describe "#find_note" do
338
+      it "gets a note with the given guid" do
339
+        note = note_store.find_note(2)
340
+
341
+        expect(note.title).to eq("second note")
342
+        expect(note).to be_a(Agents::EvernoteAgent::Note)
343
+      end
344
+    end
345
+
346
+    describe "#find_tags" do
347
+      let(:tag1) { OpenStruct.new(name: "tag1") }
348
+      let(:tag2) { OpenStruct.new(name: "tag2") }
349
+      let(:tag3) { OpenStruct.new(name: "tag3") }
350
+
351
+      before do
352
+        [tag1, tag2, tag3].each { |tag| en_note_store.createTag(tag) }
353
+      end
354
+
355
+      it "finds tags with the given guids" do
356
+        expect(note_store.find_tags([1,3])).to eq([tag1, tag3])
357
+      end
358
+    end
359
+
360
+    describe "#find_notebook" do
361
+      let(:notebook1) { OpenStruct.new(name: "notebook1") }
362
+      let(:notebook2) { OpenStruct.new(name: "notebook2") }
363
+
364
+      before do
365
+        [notebook1, notebook2].each {|notebook| en_note_store.createNotebook(notebook)}
366
+      end
367
+
368
+      it "finds a notebook with given name" do
369
+        expect(note_store.find_notebook(name: "notebook1")).to eq(notebook1)
370
+        expect(note_store.find_notebook(name: "notebook3")).to be_nil
371
+      end
372
+
373
+      it "finds a notebook with a given guid" do
374
+        expect(note_store.find_notebook(guid: 2)).to eq(notebook2)
375
+        expect(note_store.find_notebook(guid: 3)).to be_nil
376
+      end
377
+    end
378
+
379
+    describe "#create_or_update_note" do
380
+      let(:notebook1) { OpenStruct.new(name: "first notebook")}
381
+
382
+      before do
383
+        en_note_store.createNotebook(notebook1)
384
+      end
385
+
386
+      context "a note with given title and notebook does not exist" do
387
+        before do
388
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
389
+        end
390
+
391
+        it "creates a note" do
392
+          result = note_store.create_or_update_note(title: "third note", notebook: "first notebook")
393
+
394
+          expect(result).to be_a(Agents::EvernoteAgent::Note)
395
+          expect(en_note_store.getNote(3)).to_not be_nil
396
+        end
397
+
398
+        it "also creates the notebook if it does not exist" do
399
+          note_store.create_or_update_note(title: "third note", notebook: "second notebook")
400
+
401
+          expect(note_store.find_notebook(name: "second notebook")).to_not be_nil
402
+        end
403
+      end
404
+
405
+      context "such a note does exist" do
406
+        let(:note) { OpenStruct.new(title: "a note", notebookGuid: 1) }
407
+
408
+        before do
409
+          en_note_store.createNote(note)
410
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note]) }
411
+        end
412
+
413
+        it "updates the note" do
414
+          prior_note_count = en_note_store.notes.size
415
+
416
+          result = note_store.create_or_update_note(
417
+            title: "a note", notebook: "first notebook", content: "test content")
418
+
419
+          expect(result).to be_a(Agents::EvernoteAgent::Note)
420
+          expect(en_note_store.notes.size).to eq(prior_note_count)
421
+          expect(en_note_store.getNote(3).content).to include("test content")
422
+        end
423
+      end
424
+    end
425
+  end
426
+
427
+  describe Agents::EvernoteAgent::NoteStore::Search do
428
+    let(:note_store) { Agents::EvernoteAgent::NoteStore.new(en_note_store) }
429
+
430
+    let(:note1) {
431
+      OpenStruct.new(title: "first note", notebookGuid: 1, tagNames: ["funny", "comic"], updated: Time.now) }
432
+    let(:note2) {
433
+      OpenStruct.new(title: "second note", tagNames: ["funny", "comic"], updated: Time.now) }
434
+    let(:note3) {
435
+      OpenStruct.new(title: "third note", notebookGuid: 1, updated: Time.now - 2.minutes) }
436
+
437
+    let(:search) do
438
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
439
+        { tagNames: ["funny", "comic"], notebook: "xkcd" })
440
+    end
441
+
442
+    let(:search_with_time) do
443
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
444
+        { notebook: "xkcd", last_checked_at: Time.now - 1.minute })
445
+    end
446
+
447
+    let(:search_with_time_and_tags) do
448
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
449
+        { notebook: "xkcd", tagNames: ["funny", "comic"], notes_with_tags: [1], last_checked_at: Time.now - 1.minute })
450
+    end
451
+
452
+    before do
453
+      en_note_store.createTag(OpenStruct.new(name: "funny"))
454
+      en_note_store.createTag(OpenStruct.new(name: "comic"))
455
+      en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
456
+
457
+      [note1, note2, note3].each { |note| en_note_store.createNote(note) }
458
+    end
459
+
460
+    describe "#note_guids" do
461
+      it "returns the guids of notes satisfying search options" do
462
+        stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1]) }
463
+        result = search.note_guids
464
+
465
+        expect(result.size).to eq(1)
466
+        expect(result.first).to eq(1)
467
+      end
468
+    end
469
+
470
+    describe "#notes" do
471
+      context "last_checked_at is not set" do
472
+        it "returns notes satisfying the search options" do
473
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1]) }
474
+          result = search.notes
475
+
476
+          expect(result.size).to eq(1)
477
+          expect(result.first.title).to eq("first note")
478
+          expect(result.first).to be_a(Agents::EvernoteAgent::Note)
479
+        end
480
+      end
481
+
482
+      context "last_checked_at is set" do
483
+        context "notes_with_tags is not set" do
484
+          it "only returns notes updated since then" do
485
+            stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1, note3]) }
486
+            result = search_with_time.notes
487
+
488
+            expect(result.size).to eq(1)
489
+            expect(result.first.title).to eq("first note")
490
+          end
491
+        end
492
+
493
+        context "notes_with_tags is set" do
494
+          it "returns notes updated since then or notes with recently added tags" do
495
+            note3.tagNames = ["funny", "comic"]
496
+            stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1, note3]) }
497
+
498
+            result = search_with_time_and_tags.notes
499
+            expect(result.size).to eq(2)
500
+            expect(result.last.title).to eq("third note")
501
+          end
502
+        end
503
+      end
504
+    end
505
+
506
+    describe "#create_filter" do
507
+      it "builds an evernote search filter using search grammar" do
508
+        filter = search.create_filter
509
+        expect(filter.words).to eq("notebook:\"xkcd\" tag:funny tag:comic")
510
+      end
511
+    end
512
+  end
513
+
514
+  describe Agents::EvernoteAgent::Note do
515
+    let(:resource) {
516
+      OpenStruct.new(mime: "image/png",
517
+                     attributes: OpenStruct.new(sourceURL: "http://imgs.xkcd.com/comics/xkcd_survey.png", fileName: "xkcd_survey.png"))
518
+    }
519
+
520
+    let(:en_note_attributes) {
521
+      OpenStruct.new(source: "web.clip", sourceURL: "http://xkcd.com/1572/")
522
+    }
523
+
524
+    let(:en_note) {
525
+      OpenStruct.new(title: "xkcd Survey",
526
+                     tagNames: ["funny", "data"],
527
+                     content: "The xkcd Survey: Big Data for a Big Planet",
528
+                     attributes: en_note_attributes,
529
+                     resources: [resource])
530
+    }
531
+
532
+    describe "#attr" do
533
+      let(:note) {
534
+        Agents::EvernoteAgent::Note.new(en_note, "xkcd", ["funny", "data"])
535
+      }
536
+
537
+      context "when no option is set" do
538
+        it "returns a hash with title, tags, notebook, source and source url" do
539
+          expect(note.attr).to eq(
540
+            {
541
+              title:        en_note.title,
542
+              notebook:     "xkcd",
543
+              tags:         ["funny", "data"],
544
+              source:       en_note.attributes.source,
545
+              source_url:   en_note.attributes.sourceURL
546
+            }
547
+          )
548
+        end
549
+      end
550
+
551
+      context "when include_content is set to true" do
552
+        it "includes content" do
553
+          note_attr = note.attr(include_content: true)
554
+
555
+          expect(note_attr[:content]).to eq(
556
+            "The xkcd Survey: Big Data for a Big Planet"
557
+          )
558
+        end
559
+      end
560
+
561
+      context "when include_resources is set to true" do
562
+        it "includes resources" do
563
+          note_attr = note.attr(include_resources: true)
564
+
565
+          expect(note_attr[:resources].first).to eq(
566
+            {
567
+              url: resource.attributes.sourceURL,
568
+              name:  resource.attributes.fileName,
569
+              mime_type: resource.mime
570
+            }
571
+          )
572
+        end
573
+      end
574
+    end
575
+  end
576
+end