evernote_agent.rb 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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. end
  125. end
  126. private
  127. def note_params(options)
  128. params = interpolated(options)[:note]
  129. errors.add(:base, "only one notebook allowed") unless params[:notebook].to_s.split(/\s*,\s*/) == 1
  130. params[:tagNames] = params[:tagNames].to_s.split(/\s*,\s*/)
  131. params
  132. end
  133. def evernote_note_store
  134. evernote_client.note_store
  135. end
  136. def note_store
  137. @note_store ||= NoteStore.new(evernote_note_store)
  138. end
  139. # wrapper for evernote api NoteStore
  140. # https://dev.evernote.com/doc/reference/
  141. class NoteStore
  142. attr_reader :en_note_store
  143. delegate :createNote, :updateNote, :getNote, :listNotebooks, :listTags, :getNotebook,
  144. :createNotebook, :findNotesMetadata, :getNoteTagNames, :to => :en_note_store
  145. def initialize(en_note_store)
  146. @en_note_store = en_note_store
  147. end
  148. def create_or_update_note(params)
  149. search = Search.new(self, {title: params[:title], notebook: params[:notebook]})
  150. # evernote search can only filter notes with titles containing a substring;
  151. # this finds a note with the exact title
  152. note = search.notes.detect {|note| note.title == params[:title]}
  153. if note
  154. # a note with specified title and notebook exists, so update it
  155. update_note(params.merge(guid: note.guid, notebookGuid: note.notebookGuid))
  156. else
  157. # create the notebook unless it already exists
  158. notebook = find_notebook(name: params[:notebook])
  159. notebook_guid =
  160. notebook ? notebook.guid : create_notebook(params[:notebook]).guid
  161. create_note(params.merge(notebookGuid: notebook_guid))
  162. end
  163. end
  164. def create_note(params)
  165. note = Evernote::EDAM::Type::Note.new(with_wrapped_content(params))
  166. en_note = createNote(note)
  167. find_note(en_note.guid)
  168. end
  169. def update_note(params)
  170. # do not empty note properties that have not been set in `params`
  171. params.keys.each { |key| params.delete(key) unless params[key].present? }
  172. params = with_wrapped_content(params)
  173. # append specified tags instead of replacing current tags
  174. tags = getNoteTagNames(params[:guid])
  175. tags.each { |tag|
  176. params[:tagNames] << tag unless params[:tagNames].include?(tag) }
  177. note = Evernote::EDAM::Type::Note.new(params)
  178. updateNote(note)
  179. find_note(params[:guid])
  180. end
  181. def find_note(guid)
  182. # https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_getNote
  183. en_note = getNote(guid, true, false, false, false)
  184. build_note(en_note)
  185. end
  186. def build_note(en_note)
  187. notebook = find_notebook(guid: en_note.notebookGuid).name
  188. tags = en_note.tagNames || find_tags(en_note.tagGuids.to_a).map(&:name)
  189. Note.new(en_note, notebook, tags)
  190. end
  191. def find_tags(guids)
  192. listTags.select {|tag| guids.include?(tag.guid)}
  193. end
  194. def find_notebook(params)
  195. if params[:guid]
  196. listNotebooks.detect {|notebook| notebook.guid == params[:guid]}
  197. elsif params[:name]
  198. listNotebooks.detect {|notebook| notebook.name == params[:name]}
  199. end
  200. end
  201. def create_notebook(name)
  202. notebook = Evernote::EDAM::Type::Notebook.new(name: name)
  203. createNotebook(notebook)
  204. end
  205. def with_wrapped_content(params)
  206. params.delete(:notebook)
  207. if params[:content]
  208. params[:content] =
  209. "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
  210. "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
  211. "<en-note>#{params[:content].encode(:xml => :text)}</en-note>"
  212. end
  213. params
  214. end
  215. class Search
  216. attr_reader :note_store, :opts
  217. def initialize(note_store, opts)
  218. @note_store = note_store
  219. @opts = opts
  220. end
  221. def filtered_metadata
  222. filter, spec = create_filter, create_spec
  223. metadata = note_store.findNotesMetadata(filter, 0, 100, spec).notes
  224. end
  225. def note_guids
  226. filtered_metadata.map(&:guid)
  227. end
  228. def notes
  229. metadata = filtered_metadata
  230. if opts[:last_checked_at] && opts[:tagNames]
  231. # evernote does note change Note#updated timestamp when a tag is added to a note
  232. # the following selects recently updated notes
  233. # and notes that recently had the specified tags added
  234. metadata.select! do |note_data|
  235. note_data.updated > opts[:last_checked_at] ||
  236. (!opts[:notes_with_tags].include?(note_data.guid) && note_data.created > opts[:agent_created_at])
  237. end
  238. elsif opts[:last_checked_at]
  239. metadata.select! { |note_data| note_data.updated > opts[:last_checked_at] }
  240. end
  241. metadata.map! { |note_data| note_store.find_note(note_data.guid) }
  242. metadata
  243. end
  244. private
  245. def create_filter
  246. filter = Evernote::EDAM::NoteStore::NoteFilter.new
  247. # evernote search grammar:
  248. # https://dev.evernote.com/doc/articles/search_grammar.php#Search_Terms
  249. query_terms = []
  250. query_terms << "notebook:\"#{opts[:notebook]}\"" if opts[:notebook].present?
  251. query_terms << "intitle:\"#{opts[:title]}\"" if opts[:title].present?
  252. query_terms << "updated:day-1" if opts[:last_checked_at].present?
  253. opts[:tagNames].to_a.each { |tag| query_terms << "tag:#{tag}" }
  254. filter.words = query_terms.join(" ")
  255. filter
  256. end
  257. def create_spec
  258. Evernote::EDAM::NoteStore::NotesMetadataResultSpec.new(
  259. includeTitle: true,
  260. includeAttributes: true,
  261. includeNotebookGuid: true,
  262. includeTagGuids: true,
  263. includeUpdated: true,
  264. includeCreated: true
  265. )
  266. end
  267. end
  268. end
  269. class Note
  270. attr_accessor :en_note
  271. attr_reader :notebook, :tags
  272. delegate :guid, :notebookGuid, :title, :tagGuids, :content, :resources,
  273. :attributes, :to => :en_note
  274. def initialize(en_note, notebook, tags)
  275. @en_note = en_note
  276. @notebook = notebook
  277. @tags = tags
  278. end
  279. def attr(opts = {})
  280. return_attr = {
  281. title: title,
  282. notebook: notebook,
  283. tags: tags,
  284. source: attributes.source,
  285. source_url: attributes.sourceURL
  286. }
  287. return_attr[:content] = content if opts[:include_content]
  288. if opts[:include_resources] && resources
  289. return_attr[:resources] = []
  290. resources.each do |resource|
  291. return_attr[:resources] << {
  292. url: resource.attributes.sourceURL,
  293. name: resource.attributes.fileName,
  294. mime_type: resource.mime
  295. }
  296. end
  297. end
  298. return_attr
  299. end
  300. end
  301. end
  302. end