evernote_agent.rb 12 KB

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