gsquire 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .rvmrc
6
+ .yardoc
7
+ doc
8
+ tmp
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --markup markdown --charset utf-8 --main README.md
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in gsquire.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # GSquire
2
+
3
+ Back in the Age of Heroes, GSquire would carry thy armor and sword, would get
4
+ thy lordship dressed and accompany in battle. He would fight side by side with
5
+ those who stood brave against the toughest of the foes. These were good times
6
+ to be alive.
7
+
8
+ Then a swift, strange, wind blew upon this land and everything we knew was
9
+ washed away and replaced by something new. All we used to know about living,
10
+ eating, singing and smiling was made anew. Not everyone could handle that, many
11
+ were forced into this. Those who were born during this time, never knew how was
12
+ the world before.
13
+
14
+ Some received this wind as a blessing from gods, others deemed it cursed. The
15
+ only agreement was in calling it Web 2.0.
16
+
17
+ ## Really, WTF is this?
18
+
19
+ GSquire is a Google Tasks squire. More explicitly, it is one more fucking tool
20
+ to handle all the fucking data. Its main purpose is to export/import tasks
21
+ across accounts and as a side effect it has powers to create, read, update and
22
+ delete those tasks too.
23
+
24
+ A commandline interface is provided and can be used to dump tasklists to files
25
+ also to import them on another account. It also has commands to create, read,
26
+ update and delete tasks and tasklists, so you feel a real hacker reading and
27
+ writing stuff on a black screen.
28
+
29
+ Lastly, and of course, it can be used as a library.
30
+
31
+ Now you should be aware that you could come up with very creative ways to write
32
+ and manage a shitload more of data, and everyone is expected to do it, right?
33
+
34
+ _Remember kids: data, like [Ubik](http://en.wikipedia.org/wiki/Ubik), is
35
+ everywhere. Take as directed and do not exceed recommended dosage._
36
+
37
+ ## I want to deal with a shitload more of data
38
+
39
+ * Instantiate an {GSquire::Application#initialize Application} and access its
40
+ `accounts` (instance attribute)
41
+ * Access the {GSquire::Accounts#authorize_url authorization url} and
42
+ {GSquire::Accounts#authorize! add} an account
43
+ * Embrace and {GSquire::Accounts#[] hold} that account
44
+ * Keep pushing the {GSquire::Client#create_task bright},
45
+ {GSquire::Client#create_tasklist shiny}, {GSquire::Client#delete_tasklist
46
+ colorful}, {GSquire::Client#task tasty}, {GSquire::Client#tasklist nice},
47
+ {GSquire::Client#tasklists chuncky bacon}, {GSquire::Client#tasks pretty} and
48
+ {GSquire::Client#update_tasklist amazing} buttons!
49
+
50
+ ## I want to deal with a shitload more of data from a black screen
51
+
52
+ * Turn off the lights (enhances special effects)
53
+ * Take the **red** pill (special effects begin)
54
+ * As fast as you can, type `gsquire help`
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/bin/gsquire ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ require 'thor'
5
+ require 'gsquire'
6
+
7
+ class App < Thor
8
+ include Thor::Actions
9
+
10
+ EXPORT_FORMATS = %w(json).freeze
11
+ INPUT_FORMATS = %w(json).freeze
12
+
13
+ attr_reader :app
14
+
15
+ def initialize(*)
16
+ super
17
+ app_opts = { :log => options.debug? ? :debug : nil }
18
+ app_opts[:path] = ENV['GSQUIRE_PATH'] if ENV['GSQUIRE_PATH']
19
+ @app = GSquire::Application.new app_opts
20
+ end
21
+
22
+ %w(add list rm default).each do |task|
23
+ map "account:#{task}" => "account_#{task}"
24
+ end
25
+ map "account" => "account_list"
26
+
27
+ desc "export ACCOUNTS [options]", "Export task lists and tasks for ACCOUNTS"
28
+ method_option :format, :aliases => '-f', :type => :string, :default => 'json', :desc => "Output file format (valid: #{EXPORT_FORMATS.map(&:to_s).join(', ')})"
29
+ method_option :output, :aliases => '-o', :type => :string, :default => "{account}", :desc => "Output file name (use `{account}` to be replaced by account name. Format will be appended as file extension)"
30
+ def export(*accounts)
31
+ if not EXPORT_FORMATS.include? options.format
32
+ say_status "export", "invalid format #{options.format}", :red
33
+ exit 1
34
+ end
35
+
36
+ if accounts.empty?
37
+ say_status "export", "need an account name", :red
38
+ exit 1
39
+ end
40
+
41
+ accounts.each do |account|
42
+ result = []
43
+ client = app.accounts[account]
44
+ begin
45
+ client.tasklists.each do |tasklist|
46
+ tasklist[:tasks] = client.tasks tasklist[:id]
47
+ result << tasklist
48
+ say_status "tasklist", tasklist['title']
49
+ end
50
+ rescue GSquire::Accounts::NotAuthorized
51
+ authorize account
52
+ retry
53
+ rescue GSquire::Accounts::NotFound
54
+ say_status "export", "account #{account} not found, skipping", :yellow
55
+ next
56
+ rescue
57
+ say_status "export", "error exporting #{account}", :red
58
+ next
59
+ end
60
+
61
+ output = options.output.gsub /{account}/, account
62
+ output << ".#{options.format}"
63
+
64
+ case fmt = options.format.to_sym
65
+ when :json
66
+ File.open(output, 'w') {|f| f.write JSON.pretty_generate result }
67
+ =begin Yeah, I'm committing commented code, I feel bad.
68
+ when :dot, :png
69
+ graph = GraphViz.new 'Tasks', :type => :digraph
70
+ result.each do |tasklist|
71
+ list_node = graph.add_nodes tasklist['id'], :label => "#{tasklist['name']} (#{tasklist['id']})"
72
+ tasklist['tasks'].each do |task|
73
+ task_node = graph.add_nodes task['id'], :label => "#{task['name']} (#{task['id']})"
74
+ graph.add_edges task_node, list_node
75
+ end
76
+ end
77
+ graph.output(fmt => output)
78
+ =end
79
+ end
80
+
81
+ task_count = result.inject(0) {|sum, h| sum += h[:tasks].size }
82
+ say_status "export:#{options.format}",
83
+ format("%s [%s tasklists, %s tasks]", cyan(output, :bold), blue(result.size.to_s, :bold), magenta(task_count.to_s, :bold))
84
+ end
85
+ end
86
+
87
+ desc "import ACCOUNT INPUT [options]", "Import task lists and tasks from INPUT file into ACCOUNT"
88
+ method_option :format, :aliases => '-f', :type => :string, :default => 'json', :desc => "Input file format (valid: #{INPUT_FORMATS.join(', ')})"
89
+ method_option :pretend, :aliases => '-p', :type => :boolean, :default => false, :desc => "Run but do not make any changes"
90
+ method_option :debug, :aliases => '-d', :type => :boolean, :default => false, :desc => "Turn on debugging"
91
+ #method_option :graph, :aliases => '-g', :type => :boolean, :default => false, :desc => "Generates a graph image of both the import and result sets"
92
+ def import(account, input)
93
+ if not INPUT_FORMATS.include? options.format
94
+ say_status "export", "invalid format #{options.format}", :red
95
+ exit 1
96
+ end
97
+
98
+ =begin I'm doing it again, fuck me.
99
+ if options.graph?
100
+ begin
101
+ require 'graphviz'
102
+ rescue LoadError
103
+ raise Thor::Error, "'ruby-graphviz' gem is required to generate a graph visualization"
104
+ end
105
+
106
+ graph_src = GraphViz.new 'Import', :type => :digraph
107
+ graph_dest = GraphViz.new 'Result', :type => :digraph
108
+ end
109
+ =end
110
+
111
+ tasklists = case options.format.to_sym
112
+ when :json
113
+ JSON.parse(File.read input)
114
+ end
115
+
116
+ parents, orphans = prepare_tasklists!(tasklists)
117
+
118
+ if options.pretend?
119
+ client = nil
120
+ tasklist_id_seq = id_seq
121
+ else
122
+ begin
123
+ client = app.accounts[account]
124
+ rescue GSquire::Accounts::NotAuthorized
125
+ authorize account
126
+ retry
127
+ end
128
+ end
129
+
130
+ total_tasks = 0
131
+ tasklists.each do |tasklist|
132
+ task_count = 0
133
+
134
+ if options.pretend?
135
+ new_tasklist = { 'title' => tasklist['title'], 'id' => "tasklist-#{tasklist_id_seq.next}" }
136
+ task_id_seq = id_seq
137
+ else
138
+ new_tasklist = client.create_tasklist(tasklist)
139
+ end
140
+
141
+ =begin When you do it twice, you stop caring.
142
+ if options.graph?
143
+ graph_src.add_node(tasklist['id'])
144
+ graph_dest.add_node(new_tasklist['id'])
145
+ end
146
+ =end
147
+
148
+ debug "tasklist:create '#{new_tasklist['title']}' (#{new_tasklist['id']})"
149
+
150
+ tasklist['tasks'].each do |task|
151
+ if orphans.include? task['id']
152
+ debug "skip: orphaned task #{task['title']} (#{task['id']})"
153
+ next
154
+ end
155
+
156
+ if task.has_key? 'parent'
157
+ if parents.fetch(task['parent'], :placeholder) == :placeholder
158
+ parent = tasklist['tasks'].find {|t| t['id'] == task['parent']}
159
+ tasks = [task, parent].map {|t| "#{t['id'].rjust(35)} #{t['position'].to_s.rjust(20)}" }.join("\n")
160
+ debug "skip: child called not yet initialized parent:\n#{tasks}"
161
+ next
162
+ end
163
+
164
+ debug "parent:rename #{task['parent']} #{parents[task['parent']]}"
165
+ task['parent'] = parents[task['parent']]
166
+ end
167
+
168
+ task['title'].strip!
169
+
170
+ if options.pretend?
171
+ new_task = task.dup.update 'id' => "task-#{task_id_seq.next}"
172
+ else
173
+ new_task = client.create_task(task, new_tasklist['id'])
174
+ end
175
+
176
+ debug "task:create '#{new_task['title']}' (#{new_task['id']}, #{task['id']})#{new_task['parent'] ? " [child of #{new_task['parent']}]" : ""}"
177
+
178
+ task_count += 1
179
+
180
+ if parents.include? task['id']
181
+ debug "trying to set parent id #{task['id']} twice" if parents[task['id']] != :placeholder
182
+ parents[task['id']] = new_task['id']
183
+ end
184
+ end
185
+
186
+ total_tasks += task_count
187
+
188
+ say_status "tasklist",
189
+ format("%s [%s %s]",
190
+ blue(tasklist['title'], :bold),
191
+ magenta(task_count, :bold),
192
+ pluralize(task_count, "task"))
193
+ end
194
+
195
+ say_status "import:#{options.format}",
196
+ format("%s [%s %s, %s %s]",
197
+ cyan(account, :bold),
198
+ blue(tasklists.size, :bold),
199
+ pluralize(tasklists.size, "tasklist"),
200
+ magenta(total_tasks, :bold),
201
+ pluralize(total_tasks, "task"))
202
+ end
203
+
204
+ desc "account:add NAME", "Adds an account with NAME"
205
+ def account_add(name)
206
+ if app.accounts.include? name
207
+ say_status "skip", "account already exists", :yellow
208
+ return
209
+ end
210
+
211
+ begin
212
+ authorize name
213
+ say_status "account:add", name
214
+ rescue
215
+ say_status "account:add", "Something went wrong authorizing this account. Did you wait too much to enter the authorization code provided Google? Please try again", :red
216
+ end
217
+ end
218
+
219
+ desc 'account:list', 'List accounts (default is marked with `*`)'
220
+ def account_list
221
+ accounts = app.accounts.map do |acc|
222
+ acc == app.accounts.default ? "* #{green(acc)}" : acc
223
+ end
224
+ say accounts.join("\n")
225
+ end
226
+
227
+ desc 'account:default [ACCOUNT]', 'If given, set default account to ACCOUNT, otherwise show current default account'
228
+ def account_default(account = nil)
229
+ if account
230
+ begin
231
+ app.accounts.default = account
232
+ say_status 'account:default', account
233
+ rescue GSquire::Accounts::NotFound
234
+ say_status 'account:default', 'account not found', :red
235
+ end
236
+ else
237
+ say app.accounts.default
238
+ end
239
+ end
240
+
241
+ desc 'account:rm ACCOUNT', 'Removes ACCOUNT'
242
+ def account_rm(account)
243
+ if app.accounts.delete account
244
+ say_status 'account:rm', account
245
+ else
246
+ say_status 'account:rm', 'account not found', :red
247
+ end
248
+ end
249
+
250
+ protected
251
+
252
+ def authorize(name)
253
+ msg = <<-EOM
254
+ GSquire needs your seal of approval to manage tasks on #{name} account.
255
+ Point your browser to
256
+
257
+ #{app.accounts.authorize_url}
258
+
259
+ to get one and bring it here to him!
260
+
261
+ EOM
262
+
263
+ say msg
264
+ code = ask "Enter code:"
265
+
266
+ app.accounts[name] = code
267
+ end
268
+
269
+ colors = %w(bold clear) +
270
+ %w(black red green yellow blue magenta cyan white).map do |color|
271
+ [color, "on_#{color}"]
272
+ end.flatten
273
+
274
+ colors.each do |color|
275
+ class_eval(<<-EOF, __FILE__, __LINE__ + 1)
276
+ no_tasks do
277
+ def #{color}(s, bold = false)
278
+ paint s, #{color.to_sym.inspect}, bold
279
+ end
280
+ end
281
+ EOF
282
+ end
283
+
284
+ def paint(s, color, bold = false)
285
+ color_shell.set_color(s, color.to_sym, bold == :bold)
286
+ end
287
+
288
+ def color_shell
289
+ @color_shell ||= begin
290
+ shell.is_a?(Thor::Shell::Color) ? shell : \
291
+ Object.new.instance_eval {|me| def set_color(s, *); s; end; me }
292
+ end
293
+ end
294
+
295
+ def pluralize(count, singular, plural = nil)
296
+ (count == 1 || count =~ /^1(\.0+)?$/) ? singular : (plural || "#{singular}s")
297
+ end
298
+
299
+ def debug(msg)
300
+ say_status "debug", msg, :white if options.debug?
301
+ end
302
+
303
+ # Handle parent-child relationships
304
+ def prepare_tasklists!(tasklists)
305
+ parents = {}
306
+ orphans = []
307
+ tasklist_ids = tasklists.map {|list| list['id'] }
308
+ tasklists.each do |tasklist|
309
+ task_ids = tasklist['tasks'].map {|task| task['id'] }
310
+ tasklist['tasks'].each do |task|
311
+ next unless task.has_key? 'parent'
312
+ if not task_ids.include?(task['parent'])
313
+ if tasklist_ids.include?(task['parent'])
314
+ task.delete 'parent'
315
+ else
316
+ orphans << task['id']
317
+ end
318
+ else
319
+ parents[task['parent']] ||= :placeholder
320
+ end
321
+ end
322
+
323
+ tasklist['tasks'].sort_by! {|t| t.include?('parent') ? 1 : 0 }
324
+ end
325
+
326
+ [parents, orphans]
327
+ end
328
+
329
+ def id_seq
330
+ Enumerator.new {|e| i = -1; loop { e << (i += 1) } }
331
+ end
332
+ end
333
+
334
+ App.start
data/gsquire.gemspec ADDED
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ $:.push File.expand_path("../lib", __FILE__)
4
+
5
+ require "gsquire/version"
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "gsquire"
9
+ s.version = GSquire::VERSION
10
+ s.authors = ["Kiyoshi '13k' Murata"]
11
+ s.email = ["13k@linhareta.net"]
12
+ s.homepage = "http://commita.com/community"
13
+ s.summary = %q{A commandline and library squire to look for thy lordly Google Tasks.}
14
+ s.description = %q{Back in the Age of Heroes, GSquire would carry thy armor
15
+ and sword, would get thy lordship dressed and accompany in battle. He would
16
+ fight side by side with those who stood brave against the toughest of the
17
+ foes. These were good times to be alive.
18
+
19
+ Then a swift, strange, wind blew upon this land and everything we knew was
20
+ washed away and replaced by something new. All we used to know about
21
+ living, eating, singing and smiling was made anew. Not everyone could
22
+ handle that, many were forced into this. Those who were born during this
23
+ time, never knew how was the world before.
24
+
25
+ Some received this wind as a blessing from gods, others deemed it cursed.
26
+ The only agreement was in calling it Web 2.0.}
27
+
28
+ s.add_dependency 'oauth2', '~> 0.5.0'
29
+ s.add_dependency 'thor', '~> 0.14.6'
30
+ s.add_dependency 'hashie', '~> 1.1.0'
31
+
32
+ s.add_development_dependency 'rdoc'
33
+ s.add_development_dependency 'rdiscount'
34
+ s.add_development_dependency 'yard'
35
+ s.add_development_dependency 'awesome_print'
36
+
37
+ s.files = `git ls-files`.split("\n")
38
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
39
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
40
+ s.require_paths = ["lib"]
41
+ end
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+
3
+ require 'hashie'
4
+
5
+ module GSquire
6
+ class Accounts
7
+ class TasksApiMiddleware < Faraday::Response::Middleware
8
+ def on_complete(env)
9
+ if (env[:status] == 200) && json?(env)
10
+ env[:tasks_api_result] = api_result env
11
+ end
12
+ end
13
+
14
+ protected
15
+
16
+ def api_result(env)
17
+ result = JSON.parse env[:body]
18
+ if result.include? 'items'
19
+ result['items'].map {|h| mash h }
20
+ else
21
+ mash result
22
+ end
23
+ end
24
+
25
+ def mash(hash)
26
+ Hashie::Mash.new hash
27
+ end
28
+
29
+ def content_type(env)
30
+ k, v = env[:response_headers].find {|k, v| k =~ /content-type/i} || ["", ""]
31
+ v.split(';').first.to_s.downcase
32
+ end
33
+
34
+ def json?(env)
35
+ %w(application/json text/javascript).include? content_type(env)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,112 @@
1
+ # encoding: utf-8
2
+
3
+ require 'json'
4
+ require 'oauth2'
5
+
6
+ module GSquire
7
+ class Accounts
8
+ class Tokens < Hash
9
+ attr_reader :options, :client, :path
10
+
11
+ TOKENS_FILE = 'tokens.json'
12
+
13
+ # @option opts [String] :path Path to GSquire database. Required and must exist.
14
+ # @option opts [Client] :client {GSquire::Client} instance. Required.
15
+ def initialize(opts = {})
16
+ [:path, :client].each do |o|
17
+ raise ArgumentError, "Option #{o.inspect} is required" unless opts.include?(o)
18
+ end
19
+
20
+ @options = opts
21
+ @path = File.join options[:path], TOKENS_FILE
22
+ @client = options[:client]
23
+
24
+ super
25
+
26
+ self.load!
27
+ end
28
+
29
+ def save
30
+ if dirty?
31
+ File.open(path, 'w') do |f|
32
+ f.write self.to_json
33
+ end
34
+ clean!
35
+ end
36
+ self
37
+ end
38
+
39
+ def load!
40
+ hash = begin
41
+ from_json(File.read path)
42
+ rescue JSON::ParserError, Errno::ENOENT
43
+ {}
44
+ end
45
+ replace hash
46
+ clean!
47
+ self
48
+ end
49
+
50
+ def dirty?
51
+ @dirty
52
+ end
53
+
54
+ def clean?
55
+ !dirty?
56
+ end
57
+
58
+ def to_json
59
+ inject({}) do |hash, key_value|
60
+ hash.store key_value[0], token_hash(key_value[1])
61
+ hash
62
+ end.to_json
63
+ end
64
+
65
+ def []=(name, token)
66
+ token.params[:name] = name if token.respond_to? :params
67
+ r = super
68
+ dirty!
69
+ save
70
+ r
71
+ end
72
+ alias :store :[]=
73
+
74
+ def delete(name)
75
+ r = super
76
+ dirty!
77
+ save
78
+ r
79
+ end
80
+
81
+ protected
82
+
83
+ def clean!
84
+ @dirty = false
85
+ end
86
+
87
+ def dirty!
88
+ @dirty = true
89
+ end
90
+
91
+ def token_hash(token)
92
+ return token if not token.is_a? OAuth2::AccessToken
93
+
94
+ {
95
+ :access_token => token.token,
96
+ :refresh_token => token.refresh_token,
97
+ :expires_at => token.expires_at,
98
+ :expires_in => token.expires_in
99
+ }
100
+ end
101
+
102
+ def from_json(json)
103
+ hash = JSON.parse(json)
104
+ hash.update(hash) do |k, v, *|
105
+ t = OAuth2::AccessToken.from_hash client, v
106
+ t.params[:name] = k
107
+ t
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,168 @@
1
+ # encoding: utf-8
2
+
3
+ require 'json'
4
+ require 'oauth2'
5
+ require 'gsquire/client'
6
+ require 'gsquire/logging'
7
+ require 'gsquire/accounts/tokens'
8
+ require 'gsquire/accounts/tasks_api_middleware'
9
+
10
+ module GSquire
11
+
12
+ #
13
+ # Main class for GSquire.
14
+ #
15
+ # It handles multiple accounts and maintains a list
16
+ # of {Client} instances, each one associated with one Google account.
17
+ # Each client can then perform actions on tasklists and tasks (read, create,
18
+ # update, delete).
19
+ #
20
+
21
+ class Accounts
22
+ include Enumerable
23
+
24
+ CLIENT_ID = '365482102803.apps.googleusercontent.com'
25
+ CLIENT_SECRET = 'Y3UeEPOUEc60d_DbJOzFsr2Y'
26
+ OAUTH_OUTOFBAND = 'urn:ietf:wg:oauth:2.0:oob'
27
+ GOOGLE_TASKS_SCOPE = 'https://www.googleapis.com/auth/tasks'
28
+ GOOGLE_OAUTH2_AUTH = 'https://accounts.google.com/o/oauth2/auth'
29
+ GOOGLE_OAUTH2_TOKEN = 'https://accounts.google.com/o/oauth2/token'
30
+ DEFAULT_FILE = 'default'
31
+
32
+ class NotAuthorized < Exception; end
33
+ class NotFound < Exception; end
34
+
35
+ attr_reader :tokens, :logger, :options
36
+
37
+ # @option opts [String] :path Path to GSquire database. Required and must exist.
38
+ # @option opts [Logger] :logger Logger instance to be used for logging.
39
+ def initialize(opts = {})
40
+ if opts[:path].nil? || !File.exist?(opts[:path])
41
+ raise ArgumentError, ":path option is required and must exist"
42
+ end
43
+
44
+ @options = opts
45
+ @logger = options[:logger] || DummyLogger.new
46
+ @tokens = Tokens.new :path => options[:path], :client => oauth_client
47
+ @clients = {}
48
+ end
49
+
50
+ def each
51
+ tokens.keys.each {|key| yield key }
52
+ end
53
+
54
+ # Returns an account client
55
+ # @param [String] name Account name
56
+ # @return [Client] account client
57
+ def [](name)
58
+ raise NotFound, "Account #{name} not found" unless tokens.include? name
59
+
60
+ token = tokens[name]
61
+
62
+ if token.expired? and token.refresh_token.to_s.strip.empty?
63
+ @clients.delete name
64
+ raise NotAuthorized, "Token for account #{name} is expired and cannot be renewed"
65
+ end
66
+
67
+ if token.expired?
68
+ logger.debug "Token for account #{name} expired, renewing"
69
+ token = tokens.store name, token.refresh!
70
+ @clients[name] = nil
71
+ end
72
+
73
+ @clients[name] ||= Client.new token
74
+ end
75
+
76
+ # Authorizes GSquire with the token Google gave to user
77
+ # @param [String] name Account name
78
+ # @param [String] code Authorization code provided by Google
79
+ # @return [String] OAuth token string
80
+ def authorize!(name, code)
81
+ token = oauth_client.auth_code.get_token code, redirect_uri: OAUTH_OUTOFBAND
82
+ tokens[name] = token
83
+ end
84
+ alias :[]= :authorize!
85
+
86
+ # Removes account
87
+ # @param [String] name Account name
88
+ # @return [OAuth2::AccessToken] Access token associated to that account
89
+ def delete(name)
90
+ tokens.delete name
91
+ end
92
+
93
+ # Returns default account name
94
+ # @return [String] default account name
95
+ def default
96
+ @default ||= begin
97
+ name = begin
98
+ File.read(default_path).strip
99
+ rescue Errno::ENOENT
100
+ ""
101
+ end
102
+ if name.empty?
103
+ if tokens.size == 1
104
+ self.default = tokens.first.first
105
+ else
106
+ nil
107
+ end
108
+ else
109
+ if tokens.include?(name)
110
+ name
111
+ else
112
+ self.default = nil
113
+ self.default
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ # Sets default account
120
+ # @param [String] name Account name
121
+ def default=(name)
122
+ if name.nil?
123
+ File.truncate default_path, 0
124
+ nil
125
+ else
126
+ raise NotFound, "Account #{name} not found" unless tokens.include?(name)
127
+ File.open(default_path, 'w') {|f| f.write(name) }
128
+ name
129
+ end
130
+ end
131
+
132
+ # Use this method to get the URL to show user to authorize GSquire
133
+ # @return [String] authorization URL
134
+ def authorize_url
135
+ oauth_client.auth_code.authorize_url \
136
+ redirect_uri: OAUTH_OUTOFBAND,
137
+ scope: GOOGLE_TASKS_SCOPE
138
+ end
139
+
140
+ def inspect
141
+ tokens.keys.inspect
142
+ end
143
+
144
+ def to_s
145
+ tokens.keys.to_s
146
+ end
147
+
148
+ protected
149
+
150
+ def oauth_client
151
+ @oauth_client ||= begin
152
+ OAuth2::Client.new(CLIENT_ID, CLIENT_SECRET,
153
+ authorize_url: GOOGLE_OAUTH2_AUTH,
154
+ token_url: GOOGLE_OAUTH2_TOKEN) do |builder|
155
+ builder.request :url_encoded
156
+ builder.response :logger, logger
157
+ builder.response :raise_error
158
+ builder.use GSquire::Accounts::TasksApiMiddleware
159
+ builder.adapter :net_http
160
+ end
161
+ end
162
+ end
163
+
164
+ def default_path
165
+ File.join options[:path], DEFAULT_FILE
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,65 @@
1
+ # encoding: utf-8
2
+
3
+ require 'logger'
4
+ require 'fileutils'
5
+ require 'gsquire/accounts'
6
+ require 'gsquire/logging'
7
+
8
+ module GSquire
9
+
10
+ #
11
+ # This is the entry-point class for GSquire. Clients should use it to implement applications that use GSquire.
12
+ #
13
+ # As of now it is only a simple container used to setup logging and the database directory.
14
+ #
15
+ # Since GSquire supports multiple accounts by default, this class simply wraps an {Accounts} instance that is available through the `accounts` instance attribute.
16
+ #
17
+ class Application
18
+ # {Accounts} instance that holds all authorized Google accounts in the GSquire database stored at `options[:path]`.
19
+ attr_reader :accounts
20
+ # Parsed options.
21
+ attr_reader :options
22
+
23
+ # @option opts [String] :path ("~/.gsquire") Where to store GSquire database
24
+ # @option opts [Logger,String,Symbol] :log (nil) Enables logging. Pass a configured Logger object to be used directly; or a String to change the filename and use the default level (must be absolute path); or a Symbol to change log level and use the default filename. If not present (`nil`), logging is disabled.
25
+ def initialize(opts = {})
26
+ @options = {
27
+ :path => File.join(ENV['HOME'], '.gsquire')
28
+ }.merge(opts)
29
+
30
+ begin
31
+ FileUtils.mkdir_p options[:path]
32
+ rescue
33
+ abort "Error creating GSquire database directory: #{$!}"
34
+ end
35
+
36
+ @accounts = Accounts.new :path => options[:path], :logger => logger
37
+ end
38
+
39
+ protected
40
+
41
+ def logger
42
+ @logger ||= begin
43
+ case options[:log]
44
+ when Logger
45
+ options[:log]
46
+ when Symbol, String
47
+ file, level = case options[:log]
48
+ when Symbol
49
+ [File.join(options[:path], 'gsquire.log'), Logger.const_get(options[:log].to_s.upcase)]
50
+ when String
51
+ [options[:log], Logger::INFO]
52
+ end
53
+ log = Logger.new file
54
+ log.progname = 'GSquire'
55
+ log.formatter = Logger::Formatter.new
56
+ log.formatter.datetime_format = "%Y-%m-%d %H:%M:%S "
57
+ log.level = level
58
+ log
59
+ else
60
+ DummyLogger.new
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,128 @@
1
+ # encoding: utf-8
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module GSquire
7
+ # Very simple Google Tasks API wrapper. It won't perform authentication.
8
+ # Instead, it requires an already authenticated and authorized
9
+ # OAuth2::AccessToken token.
10
+ class Client
11
+ GOOGLE_TASKS_API = URI.parse 'https://www.googleapis.com/tasks/v1'
12
+
13
+ attr_accessor :oauth_token
14
+
15
+ def initialize(token)
16
+ @oauth_token = token
17
+ end
18
+
19
+ # Pulls all task lists for authorized user
20
+ # @return [Array] Array of task list hashes
21
+ def tasklists
22
+ get gtasks_tasklists_url
23
+ end
24
+
25
+ # Pulls a task list
26
+ # @param [String] tasklist_id ('@default') Task list ID
27
+ # @return [Hash] task list
28
+ def tasklist(tasklist_id = '@default')
29
+ get gtasks_tasklist_url(tasklist_id)
30
+ end
31
+
32
+ def create_tasklist(tasklist)
33
+ post gtasks_tasklists_url, strip(:tasklist, :create, tasklist)
34
+ end
35
+
36
+ def update_tasklist(tasklist)
37
+ put gtasks_tasklist_url(tasklist[:id]), strip(:tasklist, :update, tasklist)
38
+ end
39
+
40
+ def delete_tasklist(tasklist_id)
41
+ delete gtasks_tasklist_url(tasklist_id)
42
+ end
43
+
44
+ # Pulls all tasks of a task list
45
+ # @param [String] tasklist_id ('@default') Task list ID
46
+ # @return [Array] Array of task hashes
47
+ def tasks(tasklist_id = '@default')
48
+ get gtasks_tasks_url(tasklist_id)
49
+ end
50
+
51
+ # Pulls a task of a task list
52
+ # @param [String] task_id Task ID
53
+ # @param [String] tasklist_id ('@default') Task list ID
54
+ # @return [Hash] Task hash
55
+ def task(task_id, tasklist_id = '@default')
56
+ get gtasks_task_url(task_id, tasklist_id)
57
+ end
58
+
59
+ def create_task(task, tasklist_id = '@default')
60
+ post gtasks_tasks_url(tasklist_id), strip(:task, :create, task)
61
+ end
62
+
63
+ protected
64
+
65
+ def get(url)
66
+ _ oauth_token.get(url)
67
+ end
68
+
69
+ def post(url, content)
70
+ _ oauth_token.post(url, body: content.to_json, headers: {'Content-Type' => 'application/json'})
71
+ end
72
+
73
+ def put(url, content)
74
+ _ oauth_token.put(url, body: content.to_json, headers: {'Content-Type' => 'application/json'})
75
+ end
76
+
77
+ def delete(url)
78
+ oauth_token.delete(url) and true
79
+ end
80
+
81
+ def gtasks_tasklists_url
82
+ gtasks_urls(:tasklists, '@me')
83
+ end
84
+
85
+ def gtasks_tasklist_url(tasklist_id = '@default')
86
+ gtasks_urls(:tasklist, '@me', tasklist_id)
87
+ end
88
+
89
+ def gtasks_tasks_url(tasklist_id = '@default')
90
+ gtasks_urls(:tasks, tasklist_id)
91
+ end
92
+
93
+ def gtasks_task_url(task_id, tasklist_id = '@default')
94
+ gtasks_urls(:task, tasklist_id, task_id)
95
+ end
96
+
97
+ def gtasks_urls(resource, *params)
98
+ segments = case resource
99
+ when :tasklists
100
+ "/users/$/lists"
101
+ when :tasklist
102
+ "/users/$/lists/$"
103
+ when :tasks
104
+ "/lists/$/tasks"
105
+ when :task
106
+ "/lists/$/tasks/$"
107
+ end.split('/')
108
+ subpath = segments.map {|seg| seg == '$' ? params.shift : seg }.join('/')
109
+ GOOGLE_TASKS_API.merge(GOOGLE_TASKS_API.path + subpath)
110
+ end
111
+
112
+ def strip(entity, method, data)
113
+ meta = %w(id kind selfLink)
114
+ valid = case entity
115
+ when :tasklist
116
+ %w(title)
117
+ when :task
118
+ %w(title notes due status parent previous)
119
+ end
120
+ valid += meta if method == :update
121
+ data.select {|k, *| valid.include? k.to_s }
122
+ end
123
+
124
+ def _(result)
125
+ result.response.env[:tasks_api_result]
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,12 @@
1
+ # encoding: utf-8
2
+
3
+ module GSquire
4
+ # Fake logger, does nothing when its methods are called
5
+ class DummyLogger
6
+ FAKE_METHODS = %w[<< add log debug error fatal info unknown warn]
7
+
8
+ FAKE_METHODS.each do |method|
9
+ define_method(method) {|*| }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+
3
+ module GSquire
4
+ VERSION = "0.0.3"
5
+ end
data/lib/gsquire.rb ADDED
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+
3
+ require 'gsquire/version'
4
+ require 'gsquire/application'
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gsquire
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Kiyoshi '13k' Murata
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-01-03 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: oauth2
16
+ requirement: &16757400 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.5.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *16757400
25
+ - !ruby/object:Gem::Dependency
26
+ name: thor
27
+ requirement: &16756920 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 0.14.6
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *16756920
36
+ - !ruby/object:Gem::Dependency
37
+ name: hashie
38
+ requirement: &16756460 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 1.1.0
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *16756460
47
+ - !ruby/object:Gem::Dependency
48
+ name: rdoc
49
+ requirement: &16756080 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *16756080
58
+ - !ruby/object:Gem::Dependency
59
+ name: rdiscount
60
+ requirement: &16755620 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *16755620
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: &16755200 !ruby/object:Gem::Requirement
72
+ none: false
73
+ requirements:
74
+ - - ! '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: *16755200
80
+ - !ruby/object:Gem::Dependency
81
+ name: awesome_print
82
+ requirement: &16754780 !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ! '>='
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: *16754780
91
+ description: ! "Back in the Age of Heroes, GSquire would carry thy armor\n and
92
+ sword, would get thy lordship dressed and accompany in battle. He would\n fight
93
+ side by side with those who stood brave against the toughest of the\n foes. These
94
+ were good times to be alive.\n\n Then a swift, strange, wind blew upon this land
95
+ and everything we knew was\n washed away and replaced by something new. All we
96
+ used to know about\n living, eating, singing and smiling was made anew. Not everyone
97
+ could\n handle that, many were forced into this. Those who were born during this\n
98
+ \ time, never knew how was the world before.\n\n Some received this wind as
99
+ a blessing from gods, others deemed it cursed.\n The only agreement was in calling
100
+ it Web 2.0."
101
+ email:
102
+ - 13k@linhareta.net
103
+ executables:
104
+ - gsquire
105
+ extensions: []
106
+ extra_rdoc_files: []
107
+ files:
108
+ - .gitignore
109
+ - .yardopts
110
+ - Gemfile
111
+ - README.md
112
+ - Rakefile
113
+ - bin/gsquire
114
+ - gsquire.gemspec
115
+ - lib/gsquire.rb
116
+ - lib/gsquire/accounts.rb
117
+ - lib/gsquire/accounts/tasks_api_middleware.rb
118
+ - lib/gsquire/accounts/tokens.rb
119
+ - lib/gsquire/application.rb
120
+ - lib/gsquire/client.rb
121
+ - lib/gsquire/logging.rb
122
+ - lib/gsquire/version.rb
123
+ homepage: http://commita.com/community
124
+ licenses: []
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ none: false
131
+ requirements:
132
+ - - ! '>='
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ none: false
137
+ requirements:
138
+ - - ! '>='
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubyforge_project:
143
+ rubygems_version: 1.8.10
144
+ signing_key:
145
+ specification_version: 3
146
+ summary: A commandline and library squire to look for thy lordly Google Tasks.
147
+ test_files: []
148
+ has_rdoc: