evernote_agent.rb 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  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. Setting `title` only filters
  30. notes whose titles contain `title` as a substring, not as the exact title.
  31. For example, to find all notes with tag 'CS' in the notebook 'xkcd', use:
  32. "notes": {
  33. "title": "",
  34. "content": "",
  35. "notebook": "xkcd",
  36. "tagNames": "CS"
  37. }
  38. MD
  39. event_description <<-MD
  40. When `mode` is `update`, events look like:
  41. {
  42. "title": "...",
  43. "content": "...",
  44. "notebook": "...",
  45. "tags": "...",
  46. "source": "...",
  47. "sourceURL": "..."
  48. }
  49. When `mode` is `read`, events look like:
  50. {
  51. "title": "...",
  52. "content": "...",
  53. "notebook": "...",
  54. "tags": "...",
  55. "source": "...",
  56. "sourceURL": "...",
  57. "resources" : [
  58. {
  59. "url": "resource1_url",
  60. "name": "resource1_name",
  61. "mime_type": "resource1_mime_type"
  62. }
  63. ...
  64. ]
  65. }
  66. MD
  67. default_schedule "never"
  68. def working?
  69. event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
  70. end
  71. def default_options
  72. {
  73. "expected_update_period_in_days" => "2",
  74. "mode" => "update",
  75. "include_xhtml_content" => "false",
  76. "note" => {
  77. "title" => "{{title}}",
  78. "content" => "{{content}}",
  79. "notebook" => "{{notebook}}",
  80. "tagNames" => "{{tag1}}, {{tag2}}"
  81. }
  82. }
  83. end
  84. def validate_options
  85. errors.add(:base, "mode must be 'update' or 'read'") unless %w(read update).include?(options[:mode])
  86. if options[:mode] == "update" && schedule != "never"
  87. errors.add(:base, "when mode is set to 'update', schedule must be 'never'")
  88. end
  89. if options[:mode] == "read" && schedule == "never"
  90. errors.add(:base, "when mode is set to 'read', agent must have a schedule")
  91. end
  92. errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
  93. if options[:mode] == "update" && options[:note].values.all?(&:empty?)
  94. errors.add(:base, "you must specify at least one note parameter to create or update a note")
  95. end
  96. end
  97. def include_xhtml_content?
  98. options[:include_xhtml_content] == "true"
  99. end
  100. def receive(incoming_events)
  101. if options[:mode] == "update"
  102. incoming_events.each do |event|
  103. note = note_store.create_or_update_note(note_params(event))
  104. create_event :payload => note.attr(include_content: include_xhtml_content?)
  105. end
  106. end
  107. end
  108. def check
  109. if options[:mode] == "read"
  110. opts = note_params(options)
  111. # convert time to evernote timestamp format:
  112. # https://dev.evernote.com/doc/reference/Types.html#Typedef_Timestamp
  113. opts.merge!(agent_created_at: created_at.to_i * 1000)
  114. opts.merge!(last_checked_at: (memory[:last_checked_at] ||= created_at.to_i * 1000))
  115. if opts[:tagNames]
  116. opts.merge!(notes_with_tags: (memory[:notes_with_tags] ||=
  117. NoteStore::Search.new(note_store, {tagNames: opts[:tagNames]}).note_guids))
  118. end
  119. notes = NoteStore::Search.new(note_store, opts).notes
  120. notes.each do |note|
  121. memory[:notes_with_tags] << note.guid unless memory[:notes_with_tags].include?(note.guid)
  122. create_event :payload => note.attr(include_resources: true, include_content: include_xhtml_content?)
  123. end
  124. memory[:last_checked_at] = Time.now.to_i * 1000
  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(/\s*,\s*/) == 1
  131. params[:tagNames] = params[:tagNames].to_s.split(/\s*,\s*/)
  132. params[:title].strip!
  133. params[:notebook].strip!
  134. params
  135. end
  136. def evernote_note_store
  137. evernote_client.note_store
  138. end
  139. def note_store
  140. @note_store ||= NoteStore.new(evernote_note_store)
  141. end
  142. # wrapper for evernote api NoteStore
  143. # https://dev.evernote.com/doc/reference/
  144. class NoteStore
  145. attr_reader :en_note_store
  146. delegate :createNote, :updateNote, :getNote, :listNotebooks, :listTags, :getNotebook,
  147. :createNotebook, :findNotesMetadata, :getNoteTagNames, :to => :en_note_store
  148. def initialize(en_note_store)
  149. @en_note_store = en_note_store
  150. end
  151. def create_or_update_note(params)
  152. search = Search.new(self, {title: params[:title], notebook: params[:notebook]})
  153. # evernote search can only filter notes with titles containing a substring;
  154. # this finds a note with the exact title
  155. note = search.notes.detect {|note| note.title == params[:title]}
  156. if note
  157. # a note with specified title and notebook exists, so update it
  158. update_note(params.merge(guid: note.guid, notebookGuid: note.notebookGuid))
  159. else
  160. # create the notebook unless it already exists
  161. notebook = find_notebook(name: params[:notebook])
  162. notebook_guid =
  163. notebook ? notebook.guid : create_notebook(params[:notebook]).guid
  164. create_note(params.merge(notebookGuid: notebook_guid))
  165. end
  166. end
  167. def create_note(params)
  168. note = Evernote::EDAM::Type::Note.new(with_wrapped_content(params))
  169. en_note = createNote(note)
  170. find_note(en_note.guid)
  171. end
  172. def update_note(params)
  173. # do not empty note properties that have not been set in `params`
  174. params.keys.each { |key| params.delete(key) unless params[key].present? }
  175. params = with_wrapped_content(params)
  176. # append specified tags instead of replacing current tags
  177. # evernote will create any new tags
  178. tags = getNoteTagNames(params[:guid])
  179. tags.each { |tag|
  180. params[:tagNames] << tag unless params[:tagNames].include?(tag) }
  181. note = Evernote::EDAM::Type::Note.new(params)
  182. updateNote(note)
  183. find_note(params[:guid])
  184. end
  185. def find_note(guid)
  186. # https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_getNote
  187. en_note = getNote(guid, true, false, false, false)
  188. build_note(en_note)
  189. end
  190. def build_note(en_note)
  191. notebook = find_notebook(guid: en_note.notebookGuid).try(:name)
  192. tags = en_note.tagNames || find_tags(en_note.tagGuids.to_a).map(&:name)
  193. Note.new(en_note, notebook, tags)
  194. end
  195. def find_tags(guids)
  196. listTags.select {|tag| guids.include?(tag.guid)}
  197. end
  198. def find_notebook(params)
  199. if params[:guid]
  200. listNotebooks.detect {|notebook| notebook.guid == params[:guid]}
  201. elsif params[:name]
  202. listNotebooks.detect {|notebook| notebook.name == params[:name]}
  203. end
  204. end
  205. def create_notebook(name)
  206. notebook = Evernote::EDAM::Type::Notebook.new(name: name)
  207. createNotebook(notebook)
  208. end
  209. def with_wrapped_content(params)
  210. params.delete(:notebook)
  211. if params[:content]
  212. params[:content] =
  213. "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" \
  214. "<!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\">" \
  215. "<en-note>#{params[:content].encode(:xml => :text)}</en-note>"
  216. end
  217. params
  218. end
  219. class Search
  220. attr_reader :note_store, :opts
  221. def initialize(note_store, opts)
  222. @note_store = note_store
  223. @opts = opts
  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)
  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. def create_filter
  245. filter = Evernote::EDAM::NoteStore::NoteFilter.new
  246. # evernote search grammar:
  247. # https://dev.evernote.com/doc/articles/search_grammar.php#Search_Terms
  248. query_terms = []
  249. query_terms << "notebook:\"#{opts[:notebook]}\"" if opts[:notebook].present?
  250. query_terms << "intitle:\"#{opts[:title]}\"" if opts[:title].present?
  251. query_terms << "updated:day-1" if opts[:last_checked_at].present?
  252. opts[:tagNames].to_a.each { |tag| query_terms << "tag:#{tag}" }
  253. filter.words = query_terms.join(" ")
  254. filter
  255. end
  256. private
  257. def filtered_metadata
  258. filter, spec = create_filter, create_spec
  259. metadata = note_store.findNotesMetadata(filter, 0, 100, spec).notes
  260. end
  261. def create_spec
  262. Evernote::EDAM::NoteStore::NotesMetadataResultSpec.new(
  263. includeTitle: true,
  264. includeAttributes: true,
  265. includeNotebookGuid: true,
  266. includeTagGuids: true,
  267. includeUpdated: true,
  268. includeCreated: true
  269. )
  270. end
  271. end
  272. end
  273. class Note
  274. attr_accessor :en_note
  275. attr_reader :notebook, :tags
  276. delegate :guid, :notebookGuid, :title, :tagGuids, :content, :resources,
  277. :attributes, :to => :en_note
  278. def initialize(en_note, notebook, tags)
  279. @en_note = en_note
  280. @notebook = notebook
  281. @tags = tags
  282. end
  283. def attr(opts = {})
  284. return_attr = {
  285. title: title,
  286. notebook: notebook,
  287. tags: tags,
  288. source: attributes.source,
  289. source_url: attributes.sourceURL
  290. }
  291. return_attr[:content] = content if opts[:include_content]
  292. if opts[:include_resources] && resources
  293. return_attr[:resources] = []
  294. resources.each do |resource|
  295. return_attr[:resources] << {
  296. url: resource.attributes.sourceURL,
  297. name: resource.attributes.fileName,
  298. mime_type: resource.mime
  299. }
  300. end
  301. end
  302. return_attr
  303. end
  304. end
  305. end
  306. end