enwrite 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/enwrite.rb ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # enwrite - power a web site using Evernote
5
+ #
6
+ # Diego Zamboni, March 2015
7
+ # Time-stamp: <2015-04-30 13:39:37 diego>
8
+
9
+ require 'rubygems'
10
+ require 'bundler/setup'
11
+
12
+ require "digest/md5"
13
+ require 'evernote-thrift'
14
+ require 'evernote-utils'
15
+ require "optparse"
16
+ require "ostruct"
17
+ require 'util'
18
+ require 'yaml'
19
+ require 'deep_merge'
20
+
21
+ class Enwrite
22
+ PLUGINS = %w[hugo]
23
+ module Version
24
+ MAJOR = 0
25
+ MINOR = 2
26
+ PATCH = 0
27
+
28
+ STRING = [MAJOR, MINOR, PATCH].compact.join('.')
29
+ end
30
+
31
+ def self.run
32
+
33
+ options = OpenStruct.new
34
+ options.removetags = []
35
+ options.verbose = false
36
+ options.debug = false
37
+ options.outputplugin = 'hugo'
38
+ options.configtag = '_enwrite_config'
39
+ options.filestagprefix = '_enwrite_files'
40
+
41
+ opts = OptionParser.new do |opts|
42
+ def opts.version_string
43
+ "Enwrite v#{Enwrite::Version::STRING}"
44
+ end
45
+
46
+ opts.banner = "#{opts.version_string}\n\nUsage: #{$0} [options] (at least one of -n or -s has to be specified)"
47
+
48
+ def opts.show_usage
49
+ puts self
50
+ exit
51
+ end
52
+
53
+ def opts.show_version
54
+ puts version_string
55
+ exit
56
+ end
57
+
58
+ opts.separator "\nSearch options:"
59
+ opts.on("-n", "--notebook NOTEBOOK",
60
+ "Process notes from specified notebook.") do |notebook|
61
+ options.notebook = notebook
62
+ end
63
+ opts.on("-t", "--tag TAG",
64
+ "Process only notes that have this tag",
65
+ "within the given notebook.") do |tag|
66
+ options.tag = tag
67
+ end
68
+ opts.on("--remove-tags [t1,t2,t3]", Array,
69
+ "List of tags to remove from output posts.",
70
+ "If no argument given, defaults to --tag.") do |removetags|
71
+ options.removetags = removetags || [options.tag]
72
+ end
73
+ opts.on("-s", "--search SEARCHEXP",
74
+ "Process notes that match given search",
75
+ "expression. If specified, --notebook",
76
+ "and --tag are ignored.") do |searchexp|
77
+ options.searchexp = searchexp
78
+ options.tag = nil
79
+ options.notebook = nil
80
+ end
81
+ opts.separator 'Output options:'
82
+ opts.on("-p", "--output-plugin PLUGIN", PLUGINS,
83
+ "Output plugin to use (Valid values: #{PLUGINS.join(', ')})") do |plugin|
84
+ options.outputplugin = plugin
85
+ end
86
+ opts.on("-o", "--output-dir OUTDIR",
87
+ "Base dir of hugo output installation") do |outdir|
88
+ options.outdir = outdir
89
+ end
90
+ opts.on("--rebuild-all",
91
+ "Process all notes that match the given",
92
+ "conditions (normally only updated notes",
93
+ "are processed)") { options.rebuild_all = true }
94
+ opts.separator 'Other options:'
95
+ opts.on("--auth [TOKEN]",
96
+ "Force Evernote reauthentication (will",
97
+ "happen automatically if needed). Use",
98
+ "TOKEN if given, otherwise get one",
99
+ "interactively.") do |forceauth|
100
+ options.forceauth = true
101
+ options.authtoken = forceauth
102
+ end
103
+ opts.on("--config-tag TAG",
104
+ "Specify tag to determine config notes",
105
+ "(default: #{options.configtag})") { |conftag|
106
+ options.configtag = conftag
107
+ }
108
+ opts.on_tail("-v", "--verbose", "Verbose mode") { options.verbose=true }
109
+ opts.on_tail("-d", "--debug", "Debug output mode") {
110
+ options.debug=true
111
+ options.verbose=true
112
+ }
113
+ opts.on_tail("--version", "Show version") { opts.show_version }
114
+ opts.on_tail("-h", "--help", "Shows this help message") { opts.show_usage }
115
+ end
116
+
117
+ opts.parse!
118
+
119
+ begin
120
+ eval "require 'output/#{options.outputplugin}'"
121
+ rescue LoadError
122
+ error "There was an error loading output module '#{plugin}': #{e.to_s}"
123
+ exit 1
124
+ end
125
+
126
+ $enwrite_verbose = options.verbose
127
+ $enwrite_debug = options.debug
128
+
129
+ verbose("Options: " + options.to_s)
130
+
131
+ if not (options.notebook or options.searchexp or options.forceauth)
132
+ error "You have to specify at least one of --notebook, --search or --auth"
133
+ exit(1)
134
+ end
135
+ exps = [ options.searchexp ? options.searchexp : nil,
136
+ options.notebook ? "notebook:#{options.notebook}" : nil,
137
+ options.tag ? "tag:#{options.tag}" : nil,
138
+ ].reject(&:nil?)
139
+ searchexp = exps.join(' ')
140
+
141
+ verbose "Output dir: #{options.outdir}"
142
+ verbose "Search expression: #{searchexp}"
143
+
144
+ begin
145
+
146
+ # Initialize Evernote access
147
+ Evernote_utils.init(options.forceauth, options.authtoken)
148
+
149
+ if not searchexp # Only --auth was specified
150
+ exit 0
151
+ end
152
+
153
+ updatecount_index = "updatecount_#{searchexp}"
154
+ latestUpdateCount = config(updatecount_index, 0)
155
+ if options.rebuild_all
156
+ msg "Processing ALL notes (--rebuild-all)."
157
+ latestUpdateCount = 0
158
+ end
159
+
160
+ verbose "Latest stored update count for #{searchexp}: #{latestUpdateCount}"
161
+
162
+ currentState = Evernote_utils.noteStore.getSyncState(Evernote_utils.authToken)
163
+ currentUpdateCount = currentState.updateCount
164
+
165
+ verbose "Current update count for the account: #{currentUpdateCount}"
166
+
167
+ if (currentUpdateCount > latestUpdateCount)
168
+ msg "Reading #{options.rebuild_all ? 'all' : 'updated'} notes that match #{searchexp}"
169
+
170
+ filter = Evernote::EDAM::NoteStore::NoteFilter.new
171
+ filter.words = searchexp
172
+ filter.order = Evernote::EDAM::Type::NoteSortOrder::UPDATE_SEQUENCE_NUMBER
173
+ filter.ascending = false
174
+
175
+ spec = Evernote::EDAM::NoteStore::NotesMetadataResultSpec.new
176
+ spec.includeTitle = true
177
+ spec.includeCreated = true
178
+ spec.includeTagGuids = true
179
+ spec.includeContentLength = true
180
+ spec.includeUpdateSequenceNum = true
181
+ spec.includeDeleted = true
182
+
183
+ results = Evernote_utils.noteStore.findNotesMetadata(Evernote_utils.authToken,
184
+ filter,
185
+ 0,
186
+ Evernote::EDAM::Limits::EDAM_USER_NOTES_MAX,
187
+ spec)
188
+
189
+ # Get also deleted notes so we can remove from the blog
190
+ filter.inactive = true
191
+ delresults = Evernote_utils.noteStore.findNotesMetadata(Evernote_utils.authToken,
192
+ filter,
193
+ 0,
194
+ Evernote::EDAM::Limits::EDAM_USER_NOTES_MAX,
195
+ spec)
196
+
197
+ # Go through the list looking for config notes, parse them and remove
198
+ # them from the list
199
+ enwriteconfig = { 'hugo' => {
200
+ 'base_dir' => options.outdir,
201
+ 'rebuild_all' => options.rebuild_all,
202
+ },
203
+ }
204
+
205
+ if Evernote_utils.tags.include?(options.configtag)
206
+ config_tag_guid = Evernote_utils.tags[options.configtag].guid
207
+ results.notes.select { |note|
208
+ note.tagGuids.include?(config_tag_guid)
209
+ }.each { |confignotemd|
210
+ msg "Found config note '#{confignotemd.title}'"
211
+ confignote = Evernote_utils.getWholeNote(confignotemd)
212
+ enml = ENML_utils.new(confignote.content)
213
+ configtext = enml.to_text
214
+ debug " Config note text: '#{configtext}'"
215
+ configyaml = YAML.load(configtext)
216
+ debug " Config note YAML: #{configyaml}"
217
+ enwriteconfig.deep_merge!(configyaml)
218
+ debug " enwriteconfig = #{enwriteconfig}"
219
+ results.notes.delete(confignotemd)
220
+ results.totalNotes -= 1
221
+ }
222
+ end
223
+ verbose "Final enwrite config: #{enwriteconfig}"
224
+
225
+ files_tag = "#{options.filestagprefix}_#{options.outputplugin}"
226
+
227
+ if Evernote_utils.tags.include?(files_tag)
228
+ files_tag_guid = Evernote_utils.tags[files_tag].guid
229
+ results.notes.select { |note|
230
+ note.tagGuids.include?(files_tag_guid) &&
231
+ note.updateSequenceNum > latestUpdateCount
232
+ }.each { |filesnotemd|
233
+ msg "Found files note '#{filesnotemd.title}'"
234
+ filesnote = Evernote_utils.getWholeNote(filesnotemd)
235
+ enml = ENML_utils.new(filesnote.content, filesnote.resources)
236
+ files = enml.resource_files
237
+ Dir.chdir("#{options.outdir}")
238
+ files.each do |file|
239
+ case
240
+ when file[:basename] =~ /\.tar.gz$/
241
+ f = Tempfile.new('enwrite')
242
+ begin
243
+ verbose " Saving file #{file[:basename]} to #{f.path}"
244
+ f.write(file[:data])
245
+ f.close
246
+ verbose " Unpacking file #{f.path} with tar"
247
+ ok = system("tar zxf #{f.path}")
248
+ unless ok
249
+ error " An error occurred when unpacking #{f.path}"
250
+ end
251
+ ensure
252
+ f.close
253
+ f.unlink
254
+ end
255
+ else
256
+ open("#{options.outdir}/#{file[:basename]}", "w") do |f|
257
+ verbose " Saving file #{f.path}"
258
+ f.write(file[:data])
259
+ end
260
+ end
261
+ end
262
+ results.notes.delete(filesnotemd)
263
+ results.totalNotes -= 1
264
+ }
265
+ end
266
+
267
+ debug "Evaluating: #{options.outputplugin.capitalize}.new(enwriteconfig[options.outputplugin])"
268
+ writer = eval "#{options.outputplugin.capitalize}.new(enwriteconfig[options.outputplugin])"
269
+
270
+ (results.notes + delresults.notes).select {
271
+ |note| note.updateSequenceNum > latestUpdateCount
272
+ }.sort_by {
273
+ |note| note.updateSequenceNum
274
+ }.each do |metadata|
275
+ verbose "######################################################################"
276
+ note = Evernote_utils.getWholeNote(metadata)
277
+ note.tagNames = note.tagNames - options.removetags
278
+ # This either creates or deletes posts as appropriate
279
+ writer.output_note(note)
280
+ end
281
+ # Persist the latest updatecount for next time
282
+ setconfig(updatecount_index, currentUpdateCount)
283
+
284
+ exit 0
285
+ else
286
+ msg "No updated notes that match #{searchexp}"
287
+ exit 1
288
+ end
289
+ rescue Evernote::EDAM::Error::EDAMUserException => e
290
+ #the exceptions that come back from Evernote are hard to read, but really important to keep track of
291
+ msg = "Caught an exception from Evernote trying to create a note. #{Evernote_utils.translate_error(e)}"
292
+ raise msg
293
+ rescue Evernote::EDAM::Error::EDAMSystemException => e
294
+ #the exceptions that come back from Evernote are hard to read, but really important to keep track of
295
+ msg = "Caught an exception from Evernote trying to create a note. #{Evernote_utils.translate_error(e)}"
296
+ raise msg
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,234 @@
1
+ #
2
+ # Evernote access utilities
3
+ #
4
+ # Diego Zamboni, March 2015
5
+ # Time-stamp: <2015-04-16 23:09:37 diego>
6
+
7
+ # Load libraries required by the Evernote OAuth
8
+ require 'oauth'
9
+ require 'oauth/consumer'
10
+
11
+ # Load Thrift & Evernote Ruby libraries
12
+ require "evernote_oauth"
13
+
14
+ class Evernote_utils
15
+
16
+ # Client credentials for enwrite
17
+ OAUTH_CONSUMER_KEY = "zzamboni-2648"
18
+ OAUTH_CONSUMER_SECRET = "05f988c37b5e8c68"
19
+
20
+ # Connect to Sandbox server?
21
+ SANDBOX = false
22
+
23
+ # Environment variable in which to look for the token
24
+ ENVVAR = 'ENWRITE_AUTH_TOKEN'
25
+
26
+ @@authToken = nil
27
+ @@userStore = nil
28
+ @@noteStore = nil
29
+ @@notebooks = nil
30
+ @@tags = nil
31
+ @@forceAuth = false
32
+
33
+ def self.interactiveGetToken
34
+ callback_url = "http://zzamboni.org/enwrite-callback.html"
35
+ client = EvernoteOAuth::Client.new(token: nil, consumer_key: OAUTH_CONSUMER_KEY, consumer_secret: OAUTH_CONSUMER_SECRET, sandbox: SANDBOX)
36
+ request_token = client.request_token(:oauth_callback => callback_url)
37
+ authorize_url = request_token.authorize_url
38
+
39
+ puts("Welcome to enwrite's Evernote authentication.
40
+
41
+ Please open the following URL:
42
+ #{authorize_url}
43
+
44
+ Once you authenticate you will be redirected to
45
+ a page in the zzamboni.org domain that will show you an authentication verifier
46
+ token. Please enter that token now.")
47
+ print("> ")
48
+ $stdout.flush
49
+ oauth_verifier = gets.chomp
50
+
51
+ access_token = request_token.get_access_token(:oauth_verifier => oauth_verifier)
52
+
53
+ puts("Thank you! Your access token is the following string:
54
+ #{access_token.token}
55
+
56
+ I can store the token for you in the config file (#{config_file}),
57
+ then enwrite will use it automatically in the future.
58
+ ")
59
+ print "Would you like me to do that for you now (Y/n)? "
60
+ $stdout.flush
61
+ yesno = gets.chomp
62
+ if yesno =~ /^[yY]/
63
+ setconfig(:evernote_auth_token, access_token.token)
64
+ puts "Token stored."
65
+ else
66
+ puts "OK, I won't store the token, just use it for now.
67
+
68
+ You can also store it in the ENWRITE_AUTH_TOKEN environment variable."
69
+ end
70
+ # Cancel force mode after we've gotten the token
71
+ @@forceAuth = false
72
+
73
+ return access_token.token
74
+ end
75
+
76
+ def self.getToken
77
+ if @@forceAuth
78
+ return self.interactiveGetToken
79
+ elsif not ENV[ENVVAR].nil?
80
+ return ENV[ENVVAR]
81
+ elsif not config(:evernote_auth_token).nil?
82
+ return config(:evernote_auth_token)
83
+ else
84
+ return self.interactiveGetToken
85
+ end
86
+ end
87
+
88
+ def self.authToken
89
+ if @@authToken == nil || @@forceAuth
90
+ @@authToken = self.getToken
91
+ end
92
+ return @@authToken
93
+ end
94
+
95
+ def self.userStore
96
+ if @@userStore == nil
97
+ # Initial development is performed on our sandbox server. To use the production
98
+ # service, change "sandbox.evernote.com" to "www.evernote.com" and replace your
99
+ # developer token above with a token from
100
+ # https://www.evernote.com/api/DeveloperToken.action
101
+ evernoteHost = SANDBOX ? "sandbox.evernote.com" : "www.evernote.com"
102
+ userStoreUrl = "https://#{evernoteHost}/edam/user"
103
+
104
+ userStoreTransport = Thrift::HTTPClientTransport.new(userStoreUrl)
105
+ userStoreProtocol = Thrift::BinaryProtocol.new(userStoreTransport)
106
+ @@userStore = Evernote::EDAM::UserStore::UserStore::Client.new(userStoreProtocol)
107
+ end
108
+ return @@userStore
109
+ end
110
+
111
+ def self.checkVersion
112
+ versionOK = self.userStore.checkVersion("enwrite",
113
+ Evernote::EDAM::UserStore::EDAM_VERSION_MAJOR,
114
+ Evernote::EDAM::UserStore::EDAM_VERSION_MINOR)
115
+ verbose "Is my Evernote API version up to date? #{versionOK}"
116
+ unless versionOK
117
+ error "Please update the Evernote Ruby libraries - they are not up to date."
118
+ exit(1)
119
+ end
120
+ end
121
+
122
+ def self.noteStore
123
+ if @@noteStore == nil
124
+ # Get the URL used to interact with the contents of the user's account
125
+ # When your application authenticates using OAuth, the NoteStore URL will
126
+ # be returned along with the auth token in the final OAuth request.
127
+ # In that case, you don't need to make this call.
128
+ noteStoreUrl = self.userStore.getNoteStoreUrl(self.authToken)
129
+
130
+ noteStoreTransport = Thrift::HTTPClientTransport.new(noteStoreUrl)
131
+ noteStoreProtocol = Thrift::BinaryProtocol.new(noteStoreTransport)
132
+ @@noteStore = Evernote::EDAM::NoteStore::NoteStore::Client.new(noteStoreProtocol)
133
+ end
134
+ return @@noteStore
135
+ end
136
+
137
+ def self.notebooks(force=false)
138
+ if (@@notebooks == nil) or force
139
+ # List all of the notebooks in the user's account
140
+ @@notebooks = self.noteStore.listNotebooks(self.authToken)
141
+ verbose "Found #{notebooks.size} notebooks:"
142
+ defaultNotebook = notebooks.first
143
+ notebooks.each do |notebook|
144
+ verbose " * #{notebook.name}"
145
+ end
146
+ end
147
+ return @@notebooks
148
+ end
149
+
150
+ def self.tags(force=false)
151
+ if (@@tags == nil) or force
152
+ verbose "Reading all tags:"
153
+
154
+ # Get list of all tags, cache it for future use
155
+ taglist = self.noteStore.listTags(self.authToken)
156
+ # Create a hash for easier reference
157
+ @@tags = {}
158
+ tagstr = ""
159
+ for t in taglist
160
+ @@tags[t.guid] = t
161
+ @@tags[t.name] = t
162
+ tagstr += "#{t.name} "
163
+ end
164
+ verbose tagstr
165
+ end
166
+ return @@tags
167
+ end
168
+
169
+ def self.init(force=false, token=nil)
170
+ @@forceAuth = force
171
+ if not token.nil?
172
+ @@forceAuth = false
173
+ @@authToken = token
174
+ end
175
+ self.authToken
176
+ self.userStore
177
+ self.checkVersion
178
+ self.noteStore
179
+
180
+ self.notebooks
181
+ self.tags
182
+ end
183
+
184
+ def self.getWholeNote(metadata)
185
+ note = self.noteStore.getNote(self.authToken, metadata.guid, true, true, false, false)
186
+ note.tagNames = []
187
+ if metadata.tagGuids != nil
188
+ tags = Evernote_utils.tags
189
+ note.tagNames = metadata.tagGuids.map { |guid| tags[guid].name }
190
+ end
191
+ verbose "Tags: #{note.tagNames}"
192
+ return note
193
+ end
194
+
195
+ # From http://pollen.io/2012/12/creating-a-note-in-evernote-from-ruby/
196
+ # With changes to handle RATE_LIMIT_REACHED
197
+ def self.translate_error(e)
198
+ error_name = "unknown"
199
+ case e.errorCode
200
+ when Evernote::EDAM::Error::EDAMErrorCode::AUTH_EXPIRED
201
+ error_name = "AUTH_EXPIRED"
202
+ when Evernote::EDAM::Error::EDAMErrorCode::BAD_DATA_FORMAT
203
+ error_name = "BAD_DATA_FORMAT"
204
+ when Evernote::EDAM::Error::EDAMErrorCode::DATA_CONFLICT
205
+ error_name = "DATA_CONFLICT"
206
+ when Evernote::EDAM::Error::EDAMErrorCode::DATA_REQUIRED
207
+ error_name = "DATA_REQUIRED"
208
+ when Evernote::EDAM::Error::EDAMErrorCode::ENML_VALIDATION
209
+ error_name = "ENML_VALIDATION"
210
+ when Evernote::EDAM::Error::EDAMErrorCode::INTERNAL_ERROR
211
+ error_name = "INTERNAL_ERROR"
212
+ when Evernote::EDAM::Error::EDAMErrorCode::INVALID_AUTH
213
+ error_name = "INVALID_AUTH"
214
+ when Evernote::EDAM::Error::EDAMErrorCode::LIMIT_REACHED
215
+ error_name = "LIMIT_REACHED"
216
+ when Evernote::EDAM::Error::EDAMErrorCode::PERMISSION_DENIED
217
+ error_name = "PERMISSION_DENIED"
218
+ when Evernote::EDAM::Error::EDAMErrorCode::QUOTA_REACHED
219
+ error_name = "QUOTA_REACHED"
220
+ when Evernote::EDAM::Error::EDAMErrorCode::SHARD_UNAVAILABLE
221
+ error_name = "SHARD_UNAVAILABLE"
222
+ when Evernote::EDAM::Error::EDAMErrorCode::UNKNOWN
223
+ error_name = "UNKNOWN"
224
+ when Evernote::EDAM::Error::EDAMErrorCode::VALID_VALUES
225
+ error_name = "VALID_VALUES"
226
+ when Evernote::EDAM::Error::EDAMErrorCode::VALUE_MAP
227
+ error_name = "VALUE_MAP"
228
+ when Evernote::EDAM::Error::EDAMErrorCode::RATE_LIMIT_REACHED
229
+ error_name = "RATE_LIMIT_REACHED"
230
+ e.message = "Rate limit reached. Please retry in #{e.rateLimitDuration} seconds"
231
+ end
232
+ rv = "Error code was: #{error_name}[#{e.errorCode}] and parameter: [#{e.message}]"
233
+ end
234
+ end