فهرست منبع

Merge pull request #1032 from bencornelis/evernote_agent

Evernote agent
Andrew Cantino 9 سال پیش
والد
کامیت
9c5847451b

+ 5 - 0
.env.example

@@ -108,6 +108,11 @@ DROPBOX_OAUTH_SECRET=
 WUNDERLIST_OAUTH_KEY=
 WUNDERLIST_OAUTH_SECRET=
 
+EVERNOTE_OAUTH_KEY=
+EVERNOTE_OAUTH_SECRET=
+# Set to true in development, false in production
+USE_EVERNOTE_SANDBOX=true
+
 #############################
 #  AWS and Mechanical Turk  #
 #############################

+ 4 - 0
Gemfile

@@ -39,6 +39,10 @@ gem 'omniauth-dropbox'
 # UserLocationAgent
 gem 'haversine'
 
+# EvernoteAgent
+gem 'omniauth-evernote'
+gem 'evernote_oauth'
+
 # Optional Services.
 gem 'omniauth-37signals'          # BasecampAgent
 gem 'omniauth-wunderlist', github: 'wunderlist/omniauth-wunderlist', ref: 'd0910d0396107b9302aa1bc50e74bb140990ccb8'

+ 10 - 0
Gemfile.lock

@@ -179,6 +179,10 @@ GEM
     ethon (0.7.1)
       ffi (>= 1.3.0)
     eventmachine (1.0.7)
+    evernote-thrift (1.25.1)
+    evernote_oauth (0.2.3)
+      evernote-thrift
+      oauth (>= 0.4.1)
     execjs (2.3.0)
     extlib (0.9.16)
     faraday (0.9.1)
@@ -318,6 +322,10 @@ GEM
       omniauth-oauth2 (~> 1.0)
     omniauth-dropbox (0.2.0)
       omniauth-oauth (~> 1.0)
+    omniauth-evernote (1.2.1)
+      evernote-thrift
+      multi_json (~> 1.0)
+      omniauth-oauth (~> 1.0)
     omniauth-oauth (1.0.1)
       oauth
       omniauth (~> 1.0)
@@ -543,6 +551,7 @@ DEPENDENCIES
   dotenv-rails (~> 2.0.1)
   dropbox-api
   em-http-request (~> 1.1.2)
+  evernote_oauth
   faraday (~> 0.9.0)
   faraday_middleware (>= 0.10.0)
   feed-normalizer
@@ -576,6 +585,7 @@ DEPENDENCIES
   omniauth
   omniauth-37signals
   omniauth-dropbox
+  omniauth-evernote
   omniauth-tumblr
   omniauth-twitter
   omniauth-wunderlist!

+ 48 - 0
app/concerns/evernote_concern.rb

@@ -0,0 +1,48 @@
+module EvernoteConcern
+  extend ActiveSupport::Concern
+
+  included do
+    include Oauthable
+
+    validate :validate_evernote_options
+
+    valid_oauth_providers :evernote
+
+    gem_dependency_check { defined?(EvernoteOAuth) && Devise.omniauth_providers.include?(:evernote) }
+  end
+
+  def evernote_client
+    EvernoteOAuth::Client.new(
+      token:           evernote_oauth_token,
+      consumer_key:    evernote_consumer_key,
+      consumer_secret: evernote_consumer_secret,
+      sandbox:         use_sandbox?
+    )
+  end
+
+  private
+
+  def use_sandbox?
+    ENV["USE_EVERNOTE_SANDBOX"] == "true"
+  end
+
+  def validate_evernote_options
+    unless evernote_consumer_key.present? &&
+      evernote_consumer_secret.present? &&
+      evernote_oauth_token.present?
+      errors.add(:base, "Evernote ENV variables and a Service are required")
+    end
+  end
+
+  def evernote_consumer_key
+    (config = Devise.omniauth_configs[:evernote]) && config.strategy.consumer_key
+  end
+
+  def evernote_consumer_secret
+    (config = Devise.omniauth_configs[:evernote]) && config.strategy.consumer_secret
+  end
+
+  def evernote_oauth_token
+    service && service.token
+  end
+end

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

@@ -0,0 +1,383 @@
+module Agents
+  class EvernoteAgent < Agent
+    include EvernoteConcern
+
+    description <<-MD
+      The Evernote Agent connects with a user's Evernote note store.
+
+      Visit [Evernote](https://dev.evernote.com/doc/) to set up an Evernote app and receive an api key and secret.
+      Store these in the Evernote environment variables in the .env file.
+      You will also need to create a [Sandbox](https://sandbox.evernote.com/Registration.action) account to use during development.
+
+      Next, you'll need to authenticate with Evernote in the [Services](/services) section.
+
+      Options:
+
+        * `mode` - Two possible values:
+
+            - `update` Based on events it receives, the agent will create notes
+                       or update notes with the same `title` and `notebook`
+
+            - `read`   On a schedule, it will generate events containing data for newly
+                       added or updated notes
+
+        * `include_xhtml_content` - Set to `true` to include the content in ENML (Evernote Markup Language) of the note
+
+        * `note`
+
+          - When `mode` is `update` the parameters of `note` are the attributes of the note to be added/edited.
+            To edit a note, both `title` and `notebook` must be set.
+
+            For example, to add the tags 'comic' and 'CS' to a note titled 'xkcd Survey' in the notebook 'xkcd', use:
+
+                "notes": {
+                  "title": "xkcd Survey",
+                  "content": "",
+                  "notebook": "xkcd",
+                  "tagNames": "comic, CS"
+                }
+
+            If a note with the above title and notebook did note exist already, one would be created.
+
+          - When `mode` is `read` the values are search parameters.
+            Note: The `content` parameter is not used for searching. Setting `title` only filters
+            notes whose titles contain `title` as a substring, not as the exact title.
+
+            For example, to find all notes with tag 'CS' in the notebook 'xkcd', use:
+
+                "notes": {
+                  "title": "",
+                  "content": "",
+                  "notebook": "xkcd",
+                  "tagNames": "CS"
+                }
+    MD
+
+    event_description <<-MD
+      When `mode` is `update`, events look like:
+
+          {
+            "title": "...",
+            "content": "...",
+            "notebook": "...",
+            "tags": "...",
+            "source": "...",
+            "sourceURL": "..."
+          }
+
+      When `mode` is `read`, events look like:
+
+          {
+            "title": "...",
+            "content": "...",
+            "notebook": "...",
+            "tags": "...",
+            "source": "...",
+            "sourceURL": "...",
+            "resources" : [
+              {
+                "url": "resource1_url",
+                "name": "resource1_name",
+                "mime_type": "resource1_mime_type"
+              }
+              ...
+            ]
+          }
+    MD
+
+    default_schedule "never"
+
+    def working?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
+    end
+
+    def default_options
+      {
+        "expected_update_period_in_days" => "2",
+        "mode" => "update",
+        "include_xhtml_content" => "false",
+        "note" => {
+          "title" => "{{title}}",
+          "content" => "{{content}}",
+          "notebook" => "{{notebook}}",
+          "tagNames" => "{{tag1}}, {{tag2}}"
+        }
+      }
+    end
+
+    def validate_options
+      errors.add(:base, "mode must be 'update' or 'read'") unless %w(read update).include?(options[:mode])
+
+      if options[:mode] == "update" && schedule != "never"
+        errors.add(:base, "when mode is set to 'update', schedule must be 'never'")
+      end
+
+      if options[:mode] == "read" && schedule == "never"
+        errors.add(:base, "when mode is set to 'read', agent must have a schedule")
+      end
+
+      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
+
+      if options[:mode] == "update" && options[:note].values.all?(&:empty?)
+        errors.add(:base, "you must specify at least one note parameter to create or update a note")
+      end
+    end
+
+    def include_xhtml_content?
+      options[:include_xhtml_content] == "true"
+    end
+
+    def receive(incoming_events)
+      if options[:mode] == "update"
+        incoming_events.each do |event|
+          note = note_store.create_or_update_note(note_params(event))
+          create_event :payload => note.attr(include_content: include_xhtml_content?)
+        end
+      end
+    end
+
+    def check
+      if options[:mode] == "read"
+        opts = note_params(options)
+
+        # convert time to evernote timestamp format:
+        # https://dev.evernote.com/doc/reference/Types.html#Typedef_Timestamp
+        opts.merge!(agent_created_at: created_at.to_i * 1000)
+        opts.merge!(last_checked_at: (memory[:last_checked_at] ||= created_at.to_i * 1000))
+
+        if opts[:tagNames]
+          opts.merge!(notes_with_tags: (memory[:notes_with_tags] ||=
+            NoteStore::Search.new(note_store, {tagNames: opts[:tagNames]}).note_guids))
+        end
+
+        notes = NoteStore::Search.new(note_store, opts).notes
+        notes.each do |note|
+          memory[:notes_with_tags] << note.guid unless memory[:notes_with_tags].include?(note.guid)
+
+          create_event :payload => note.attr(include_resources: true, include_content: include_xhtml_content?)
+        end
+
+        memory[:last_checked_at] = Time.now.to_i * 1000
+      end
+    end
+
+    private
+
+    def note_params(options)
+      params = interpolated(options)[:note]
+      errors.add(:base, "only one notebook allowed") unless params[:notebook].to_s.split(/\s*,\s*/) == 1
+
+      params[:tagNames] = params[:tagNames].to_s.split(/\s*,\s*/)
+      params[:title].strip!
+      params[:notebook].strip!
+      params
+    end
+
+    def evernote_note_store
+      evernote_client.note_store
+    end
+
+    def note_store
+      @note_store ||= NoteStore.new(evernote_note_store)
+    end
+
+    # wrapper for evernote api NoteStore
+    # https://dev.evernote.com/doc/reference/
+    class NoteStore
+      attr_reader :en_note_store
+      delegate :createNote, :updateNote, :getNote, :listNotebooks, :listTags, :getNotebook,
+               :createNotebook, :findNotesMetadata, :getNoteTagNames, :to => :en_note_store
+
+      def initialize(en_note_store)
+        @en_note_store = en_note_store
+      end
+
+      def create_or_update_note(params)
+        search = Search.new(self, {title: params[:title], notebook: params[:notebook]})
+
+        # evernote search can only filter notes with titles containing a substring;
+        # this finds a note with the exact title
+        note = search.notes.detect {|note| note.title == params[:title]}
+
+        if note
+          # a note with specified title and notebook exists, so update it
+          update_note(params.merge(guid: note.guid, notebookGuid: note.notebookGuid))
+        else
+          # create the notebook unless it already exists
+          notebook = find_notebook(name: params[:notebook])
+          notebook_guid =
+            notebook ? notebook.guid : create_notebook(params[:notebook]).guid
+
+          create_note(params.merge(notebookGuid: notebook_guid))
+        end
+      end
+
+      def create_note(params)
+        note = Evernote::EDAM::Type::Note.new(with_wrapped_content(params))
+        en_note = createNote(note)
+        find_note(en_note.guid)
+      end
+
+      def update_note(params)
+        # do not empty note properties that have not been set in `params`
+        params.keys.each { |key| params.delete(key) unless params[key].present? }
+        params = with_wrapped_content(params)
+
+        # append specified tags instead of replacing current tags
+        # evernote will create any new tags
+        tags = getNoteTagNames(params[:guid])
+        tags.each { |tag|
+          params[:tagNames] << tag unless params[:tagNames].include?(tag) }
+
+        note = Evernote::EDAM::Type::Note.new(params)
+        updateNote(note)
+        find_note(params[:guid])
+      end
+
+      def find_note(guid)
+        # https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_getNote
+        en_note = getNote(guid, true, false, false, false)
+        build_note(en_note)
+      end
+
+      def build_note(en_note)
+        notebook = find_notebook(guid: en_note.notebookGuid).try(:name)
+        tags = en_note.tagNames || find_tags(en_note.tagGuids.to_a).map(&:name)
+        Note.new(en_note, notebook, tags)
+      end
+
+      def find_tags(guids)
+        listTags.select {|tag| guids.include?(tag.guid)}
+      end
+
+      def find_notebook(params)
+        if params[:guid]
+          listNotebooks.detect {|notebook| notebook.guid == params[:guid]}
+        elsif params[:name]
+          listNotebooks.detect {|notebook| notebook.name == params[:name]}
+        end
+      end
+
+      def create_notebook(name)
+        notebook = Evernote::EDAM::Type::Notebook.new(name: name)
+        createNotebook(notebook)
+      end
+
+      def with_wrapped_content(params)
+        params.delete(:notebook)
+
+        if params[:content]
+          params[:content] =
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
+            "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
+            "<en-note>#{params[:content].encode(:xml => :text)}</en-note>"
+        end
+
+        params
+      end
+
+      class Search
+        attr_reader :note_store, :opts
+        def initialize(note_store, opts)
+          @note_store = note_store
+          @opts = opts
+        end
+
+        def note_guids
+          filtered_metadata.map(&:guid)
+        end
+
+        def notes
+          metadata = filtered_metadata
+
+          if opts[:last_checked_at] && opts[:tagNames]
+
+            # evernote does note change Note#updated timestamp when a tag is added to a note
+            # the following selects recently updated notes
+            # and notes that recently had the specified tags added
+            metadata.select! do |note_data|
+              note_data.updated > opts[:last_checked_at] ||
+              !opts[:notes_with_tags].include?(note_data.guid)
+            end
+
+          elsif opts[:last_checked_at]
+            metadata.select! { |note_data| note_data.updated > opts[:last_checked_at] }
+          end
+
+          metadata.map! { |note_data| note_store.find_note(note_data.guid) }
+          metadata
+        end
+
+        def create_filter
+          filter = Evernote::EDAM::NoteStore::NoteFilter.new
+
+          # evernote search grammar:
+          # https://dev.evernote.com/doc/articles/search_grammar.php#Search_Terms
+          query_terms = []
+          query_terms << "notebook:\"#{opts[:notebook]}\"" if opts[:notebook].present?
+          query_terms << "intitle:\"#{opts[:title]}\""     if opts[:title].present?
+          query_terms << "updated:day-1"                   if opts[:last_checked_at].present?
+          opts[:tagNames].to_a.each { |tag| query_terms << "tag:#{tag}" }
+
+          filter.words = query_terms.join(" ")
+          filter
+        end
+
+        private
+
+        def filtered_metadata
+          filter, spec = create_filter, create_spec
+          metadata = note_store.findNotesMetadata(filter, 0, 100, spec).notes
+        end
+
+        def create_spec
+          Evernote::EDAM::NoteStore::NotesMetadataResultSpec.new(
+            includeTitle: true,
+            includeAttributes: true,
+            includeNotebookGuid: true,
+            includeTagGuids: true,
+            includeUpdated: true,
+            includeCreated: true
+          )
+        end
+      end
+    end
+
+    class Note
+      attr_accessor :en_note
+      attr_reader :notebook, :tags
+      delegate :guid, :notebookGuid, :title, :tagGuids, :content, :resources,
+               :attributes, :to => :en_note
+
+      def initialize(en_note, notebook, tags)
+        @en_note = en_note
+        @notebook = notebook
+        @tags = tags
+      end
+
+      def attr(opts = {})
+        return_attr = {
+          title:        title,
+          notebook:     notebook,
+          tags:         tags,
+          source:       attributes.source,
+          source_url:   attributes.sourceURL
+        }
+
+        return_attr[:content] = content if opts[:include_content]
+
+        if opts[:include_resources] && resources
+          return_attr[:resources] = []
+          resources.each do |resource|
+            return_attr[:resources] << {
+              url:       resource.attributes.sourceURL,
+              name:      resource.attributes.fileName,
+              mime_type: resource.mime
+            }
+          end
+        end
+        return_attr
+      end
+    end
+  end
+end

+ 11 - 0
config/initializers/devise.rb

@@ -263,6 +263,17 @@ Devise.setup do |config|
     config.omniauth :wunderlist, key, secret
   end
 
+  if defined?(OmniAuth::Strategies::Evernote) &&
+    (key = ENV["EVERNOTE_OAUTH_KEY"]).present? &&
+    (secret = ENV["EVERNOTE_OAUTH_SECRET"]).present?
+
+    if ENV["USE_EVERNOTE_SANDBOX"] == "true"
+      config.omniauth :evernote, key, secret, client_options: { :site => 'https://sandbox.evernote.com' }
+    else
+      config.omniauth :evernote, key, secret
+    end
+  end
+
   # ==> Warden configuration
   # If you want to use other strategies, that are not supported by Devise, or
   # 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:
       37signals: "37Signals (Basecamp)"
       dropbox: "Dropbox"
       wunderlist: 'Wunderlist'
+      evernote: "Evernote"
     passwords:
       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."
       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
 DROPBOX_OAUTH_KEY=dropboxoauthkey
 DROPBOX_OAUTH_SECRET=dropboxoauthsecret
 WUNDERLIST_OAUTH_KEY=wunderoauthkey
-FAILED_JOBS_TO_KEEP=2
+EVERNOTE_OAUTH_KEY=evernoteoauthkey
+EVERNOTE_OAUTH_SECRET=evernoteoauthsecret
+FAILED_JOBS_TO_KEEP=2

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

@@ -0,0 +1,576 @@
+require 'spec_helper'
+
+describe Agents::EvernoteAgent do
+  class FakeEvernoteNoteStore
+    attr_accessor :notes, :tags, :notebooks
+    def initialize
+      @notes, @tags, @notebooks = [], [], []
+    end
+
+    def createNote(note)
+      note.attributes = OpenStruct.new(source: nil, sourceURL: nil)
+      note.guid = @notes.length + 1
+      @notes << note
+      note
+    end
+
+    def updateNote(note)
+      note.attributes = OpenStruct.new(source: nil, sourceURL: nil)
+      old_note = @notes.find {|en_note| en_note.guid == note.guid}
+      @notes[@notes.index(old_note)] = note
+      note
+    end
+
+    def getNote(guid, *other_args)
+      @notes.find {|note| note.guid == guid}
+    end
+
+    def createNotebook(notebook)
+      notebook.guid = @notebooks.length + 1
+      @notebooks << notebook
+      notebook
+    end
+
+    def createTag(tag)
+      tag.guid = @tags.length + 1
+      @tags << tag
+      tag
+    end
+
+    def listNotebooks; @notebooks; end
+
+    def listTags; @tags; end
+
+    def getNoteTagNames(guid)
+      getNote(guid).try(:tagNames) || []
+    end
+
+    def findNotesMetadata(*args); end
+  end
+
+  let(:en_note_store) do
+    FakeEvernoteNoteStore.new
+  end
+
+  before do
+    stub.any_instance_of(Agents::EvernoteAgent).evernote_note_store { en_note_store }
+  end
+
+  describe "#receive" do
+    context "when mode is set to 'update'" do
+      before do
+        @options = {
+          :mode => "update",
+          :include_xhtml_content => "false",
+          :expected_update_period_in_days => "2",
+          :note => {
+            :title     => "{{title}}",
+            :content   => "{{content}}",
+            :notebook  => "{{notebook}}",
+            :tagNames  => "{{tag1}}, {{tag2}}"
+          }
+        }
+        @agent = Agents::EvernoteAgent.new(:name => "evernote updater", :options => @options)
+        @agent.service = services(:generic)
+        @agent.user = users(:bob)
+        @agent.save!
+
+        @event = Event.new
+        @event.agent = agents(:bob_website_agent)
+        @event.payload = { :title => "xkcd Survey",
+                           :content => "The xkcd Survey: Big Data for a Big Planet",
+                           :notebook => "xkcd",
+                           :tag1 => "funny",
+                           :tag2 => "data" }
+        @event.save!
+
+        tag1 = OpenStruct.new(name: "funny")
+        tag2 = OpenStruct.new(name: "data")
+        [tag1, tag2].each { |tag| en_note_store.createTag(tag) }
+      end
+
+      it "adds a note for any payload it receives" do
+        stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
+        Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
+
+        expect(en_note_store.notes.size).to eq(1)
+        expect(en_note_store.notes.first.title).to eq("xkcd Survey")
+        expect(en_note_store.notebooks.size).to eq(1)
+        expect(en_note_store.tags.size).to eq(2)
+
+        expect(@agent.events.count).to eq(1)
+        expect(@agent.events.first.payload).to eq({
+          "title" => "xkcd Survey",
+          "notebook" => "xkcd",
+          "tags" => ["funny", "data"],
+          "source" => nil,
+          "source_url" => nil
+        })
+      end
+
+      context "a note with the same title and notebook exists" do
+        before do
+          note1 = OpenStruct.new(title: "xkcd Survey", notebookGuid: 1)
+          note2 = OpenStruct.new(title: "Footprints", notebookGuid: 1)
+          [note1, note2].each { |note| en_note_store.createNote(note) }
+          en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
+
+          stub(en_note_store).findNotesMetadata {
+            OpenStruct.new(notes: [note1]) }
+        end
+
+        it "updates the existing note" do
+          Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
+
+          expect(en_note_store.notes.size).to eq(2)
+          expect(en_note_store.getNote(1).tagNames).to eq(["funny", "data"])
+          expect(@agent.events.count).to eq(1)
+        end
+      end
+
+      context "include_xhtml_content is set to 'true'" do
+        before do
+          @agent.options[:include_xhtml_content] = "true"
+          @agent.save!
+        end
+
+        it "creates an event with note content wrapped in ENML" do
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
+          Agents::EvernoteAgent.async_receive(@agent.id, [@event.id])
+
+          payload = @agent.events.first.payload
+
+          expect(payload[:content]).to eq(
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
+            "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
+            "<en-note>The xkcd Survey: Big Data for a Big Planet</en-note>"
+          )
+        end
+      end
+    end
+  end
+
+  describe "#check" do
+    context "when mode is set to 'read'" do
+      before do
+        @options = {
+          :mode => "read",
+          :include_xhtml_content => "false",
+          :expected_update_period_in_days => "2",
+          :note => {
+            :title     => "",
+            :content   => "",
+            :notebook  => "xkcd",
+            :tagNames  => "funny, comic"
+          }
+        }
+        @checker = Agents::EvernoteAgent.new(:name => "evernote reader", :options => @options)
+
+        @checker.service = services(:generic)
+        @checker.user = users(:bob)
+        @checker.schedule = "every_2h"
+
+        @checker.save!
+        @checker.created_at = 1.minute.ago
+
+        en_note_store.createNote(
+          OpenStruct.new(title: "xkcd Survey",
+                         notebookGuid: 1,
+                         updated: 2.minutes.ago.to_i * 1000,
+                         tagNames: ["funny", "comic"])
+        )
+        en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
+        tag1 = OpenStruct.new(name: "funny")
+        tag2 = OpenStruct.new(name: "comic")
+        [tag1, tag2].each { |tag| en_note_store.createTag(tag) }
+
+        stub(en_note_store).findNotesMetadata {
+          notes = en_note_store.notes.select do |note|
+            note.notebookGuid == 1 &&
+            %w(funny comic).all? { |tag_name| note.tagNames.include?(tag_name) }
+          end
+          OpenStruct.new(notes: notes)
+        }
+      end
+
+      context "the first time it checks" do
+        it "returns only notes created/updated since it was created" do
+          expect { @checker.check }.to change { Event.count }.by(0)
+        end
+      end
+
+      context "on subsequent checks" do
+        it "returns notes created/updated since the last time it checked" do
+          expect { @checker.check }.to change { Event.count }.by(0)
+
+          future_time = (Time.now + 1.minute).to_i * 1000
+          en_note_store.createNote(
+            OpenStruct.new(title: "Footprints",
+                           notebookGuid: 1,
+                           tagNames: ["funny", "comic", "recent"],
+                           updated: future_time))
+
+          en_note_store.createNote(
+            OpenStruct.new(title: "something else",
+                           notebookGuid: 2,
+                           tagNames: ["funny", "comic"],
+                           updated: future_time))
+
+          expect { @checker.check }.to change { Event.count }.by(1)
+        end
+
+        it "returns notes tagged since the last time it checked" do
+          en_note_store.createNote(
+            OpenStruct.new(title: "Footprints",
+                           notebookGuid: 1,
+                           tagNames: [],
+                           created: Time.now.to_i * 1000,
+                           updated: Time.now.to_i * 1000))
+          @checker.check
+
+          en_note_store.getNote(2).tagNames = ["funny", "comic"]
+
+          expect { @checker.check }.to change { Event.count }.by(1)
+        end
+      end
+    end
+  end
+
+  describe "#validation" do
+    before do
+      @options = {
+        :mode => "update",
+        :include_xhtml_content => "false",
+        :expected_update_period_in_days => "2",
+        :note => {
+          :title     => "{{title}}",
+          :content   => "{{content}}",
+          :notebook  => "{{notebook}}",
+          :tagNames  => "{{tag1}}, {{tag2}}"
+        }
+      }
+      @agent = Agents::EvernoteAgent.new(:name => "evernote updater", :options => @options)
+      @agent.service = services(:generic)
+      @agent.user = users(:bob)
+      @agent.save!
+
+      expect(@agent).to be_valid
+    end
+
+    it "requires the mode to be 'update' or 'read'" do
+      @agent.options[:mode] = ""
+      expect(@agent).not_to be_valid
+    end
+
+    context "mode is set to 'update'" do
+      before do
+        @agent.options[:mode] = "update"
+      end
+
+      it "requires some note parameter to be present" do
+        @agent.options[:note].keys.each { |k| @agent.options[:note][k] = "" }
+        expect(@agent).not_to be_valid
+      end
+
+      it "requires schedule to be 'never'" do
+        @agent.schedule = 'never'
+        expect(@agent).to be_valid
+
+        @agent.schedule = 'every_1m'
+        expect(@agent).not_to be_valid
+      end
+    end
+
+    context "mode is set to 'read'" do
+      before do
+        @agent.options[:mode] = "read"
+      end
+
+      it "requires a schedule to be set" do
+        @agent.schedule = 'every_1m'
+        expect(@agent).to be_valid
+
+        @agent.schedule = 'never'
+        expect(@agent).not_to be_valid
+      end
+    end
+  end
+
+  # api wrapper classes
+  describe Agents::EvernoteAgent::NoteStore do
+    let(:note_store) { Agents::EvernoteAgent::NoteStore.new(en_note_store) }
+
+    let(:note1) { OpenStruct.new(title: "first note") }
+    let(:note2) { OpenStruct.new(title: "second note") }
+
+    before do
+      en_note_store.createNote(note1)
+      en_note_store.createNote(note2)
+    end
+
+    describe "#create_note" do
+      it "creates a note with given params in evernote note store" do
+        note_store.create_note(title: "third note")
+
+        expect(en_note_store.notes.size).to eq(3)
+        expect(en_note_store.notes.last.title).to eq("third note")
+      end
+
+      it "returns a note" do
+        expect(note_store.create_note(title: "third note")).to be_a(Agents::EvernoteAgent::Note)
+      end
+    end
+
+    describe "#update_note" do
+      it "updates an existing note with given params" do
+        note_store.update_note(guid: 1, content: "some words")
+
+        expect(en_note_store.notes.first.content).not_to be_nil
+        expect(en_note_store.notes.size).to eq(2)
+      end
+
+      it "returns a note" do
+        expect(note_store.update_note(guid: 1, content: "some words")).to be_a(Agents::EvernoteAgent::Note)
+      end
+    end
+
+    describe "#find_note" do
+      it "gets a note with the given guid" do
+        note = note_store.find_note(2)
+
+        expect(note.title).to eq("second note")
+        expect(note).to be_a(Agents::EvernoteAgent::Note)
+      end
+    end
+
+    describe "#find_tags" do
+      let(:tag1) { OpenStruct.new(name: "tag1") }
+      let(:tag2) { OpenStruct.new(name: "tag2") }
+      let(:tag3) { OpenStruct.new(name: "tag3") }
+
+      before do
+        [tag1, tag2, tag3].each { |tag| en_note_store.createTag(tag) }
+      end
+
+      it "finds tags with the given guids" do
+        expect(note_store.find_tags([1,3])).to eq([tag1, tag3])
+      end
+    end
+
+    describe "#find_notebook" do
+      let(:notebook1) { OpenStruct.new(name: "notebook1") }
+      let(:notebook2) { OpenStruct.new(name: "notebook2") }
+
+      before do
+        [notebook1, notebook2].each {|notebook| en_note_store.createNotebook(notebook)}
+      end
+
+      it "finds a notebook with given name" do
+        expect(note_store.find_notebook(name: "notebook1")).to eq(notebook1)
+        expect(note_store.find_notebook(name: "notebook3")).to be_nil
+      end
+
+      it "finds a notebook with a given guid" do
+        expect(note_store.find_notebook(guid: 2)).to eq(notebook2)
+        expect(note_store.find_notebook(guid: 3)).to be_nil
+      end
+    end
+
+    describe "#create_or_update_note" do
+      let(:notebook1) { OpenStruct.new(name: "first notebook")}
+
+      before do
+        en_note_store.createNotebook(notebook1)
+      end
+
+      context "a note with given title and notebook does not exist" do
+        before do
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: []) }
+        end
+
+        it "creates a note" do
+          result = note_store.create_or_update_note(title: "third note", notebook: "first notebook")
+
+          expect(result).to be_a(Agents::EvernoteAgent::Note)
+          expect(en_note_store.getNote(3)).to_not be_nil
+        end
+
+        it "also creates the notebook if it does not exist" do
+          note_store.create_or_update_note(title: "third note", notebook: "second notebook")
+
+          expect(note_store.find_notebook(name: "second notebook")).to_not be_nil
+        end
+      end
+
+      context "such a note does exist" do
+        let(:note) { OpenStruct.new(title: "a note", notebookGuid: 1) }
+
+        before do
+          en_note_store.createNote(note)
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note]) }
+        end
+
+        it "updates the note" do
+          prior_note_count = en_note_store.notes.size
+
+          result = note_store.create_or_update_note(
+            title: "a note", notebook: "first notebook", content: "test content")
+
+          expect(result).to be_a(Agents::EvernoteAgent::Note)
+          expect(en_note_store.notes.size).to eq(prior_note_count)
+          expect(en_note_store.getNote(3).content).to include("test content")
+        end
+      end
+    end
+  end
+
+  describe Agents::EvernoteAgent::NoteStore::Search do
+    let(:note_store) { Agents::EvernoteAgent::NoteStore.new(en_note_store) }
+
+    let(:note1) {
+      OpenStruct.new(title: "first note", notebookGuid: 1, tagNames: ["funny", "comic"], updated: Time.now) }
+    let(:note2) {
+      OpenStruct.new(title: "second note", tagNames: ["funny", "comic"], updated: Time.now) }
+    let(:note3) {
+      OpenStruct.new(title: "third note", notebookGuid: 1, updated: Time.now - 2.minutes) }
+
+    let(:search) do
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
+        { tagNames: ["funny", "comic"], notebook: "xkcd" })
+    end
+
+    let(:search_with_time) do
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
+        { notebook: "xkcd", last_checked_at: Time.now - 1.minute })
+    end
+
+    let(:search_with_time_and_tags) do
+      Agents::EvernoteAgent::NoteStore::Search.new(note_store,
+        { notebook: "xkcd", tagNames: ["funny", "comic"], notes_with_tags: [1], last_checked_at: Time.now - 1.minute })
+    end
+
+    before do
+      en_note_store.createTag(OpenStruct.new(name: "funny"))
+      en_note_store.createTag(OpenStruct.new(name: "comic"))
+      en_note_store.createNotebook(OpenStruct.new(name: "xkcd"))
+
+      [note1, note2, note3].each { |note| en_note_store.createNote(note) }
+    end
+
+    describe "#note_guids" do
+      it "returns the guids of notes satisfying search options" do
+        stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1]) }
+        result = search.note_guids
+
+        expect(result.size).to eq(1)
+        expect(result.first).to eq(1)
+      end
+    end
+
+    describe "#notes" do
+      context "last_checked_at is not set" do
+        it "returns notes satisfying the search options" do
+          stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1]) }
+          result = search.notes
+
+          expect(result.size).to eq(1)
+          expect(result.first.title).to eq("first note")
+          expect(result.first).to be_a(Agents::EvernoteAgent::Note)
+        end
+      end
+
+      context "last_checked_at is set" do
+        context "notes_with_tags is not set" do
+          it "only returns notes updated since then" do
+            stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1, note3]) }
+            result = search_with_time.notes
+
+            expect(result.size).to eq(1)
+            expect(result.first.title).to eq("first note")
+          end
+        end
+
+        context "notes_with_tags is set" do
+          it "returns notes updated since then or notes with recently added tags" do
+            note3.tagNames = ["funny", "comic"]
+            stub(en_note_store).findNotesMetadata { OpenStruct.new(notes: [note1, note3]) }
+
+            result = search_with_time_and_tags.notes
+            expect(result.size).to eq(2)
+            expect(result.last.title).to eq("third note")
+          end
+        end
+      end
+    end
+
+    describe "#create_filter" do
+      it "builds an evernote search filter using search grammar" do
+        filter = search.create_filter
+        expect(filter.words).to eq("notebook:\"xkcd\" tag:funny tag:comic")
+      end
+    end
+  end
+
+  describe Agents::EvernoteAgent::Note do
+    let(:resource) {
+      OpenStruct.new(mime: "image/png",
+                     attributes: OpenStruct.new(sourceURL: "http://imgs.xkcd.com/comics/xkcd_survey.png", fileName: "xkcd_survey.png"))
+    }
+
+    let(:en_note_attributes) {
+      OpenStruct.new(source: "web.clip", sourceURL: "http://xkcd.com/1572/")
+    }
+
+    let(:en_note) {
+      OpenStruct.new(title: "xkcd Survey",
+                     tagNames: ["funny", "data"],
+                     content: "The xkcd Survey: Big Data for a Big Planet",
+                     attributes: en_note_attributes,
+                     resources: [resource])
+    }
+
+    describe "#attr" do
+      let(:note) {
+        Agents::EvernoteAgent::Note.new(en_note, "xkcd", ["funny", "data"])
+      }
+
+      context "when no option is set" do
+        it "returns a hash with title, tags, notebook, source and source url" do
+          expect(note.attr).to eq(
+            {
+              title:        en_note.title,
+              notebook:     "xkcd",
+              tags:         ["funny", "data"],
+              source:       en_note.attributes.source,
+              source_url:   en_note.attributes.sourceURL
+            }
+          )
+        end
+      end
+
+      context "when include_content is set to true" do
+        it "includes content" do
+          note_attr = note.attr(include_content: true)
+
+          expect(note_attr[:content]).to eq(
+            "The xkcd Survey: Big Data for a Big Planet"
+          )
+        end
+      end
+
+      context "when include_resources is set to true" do
+        it "includes resources" do
+          note_attr = note.attr(include_resources: true)
+
+          expect(note_attr[:resources].first).to eq(
+            {
+              url: resource.attributes.sourceURL,
+              name:  resource.attributes.fileName,
+              mime_type: resource.mime
+            }
+          )
+        end
+      end
+    end
+  end
+end