evernote_agent.rb 12KB

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