gsquire 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: