gsquire 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/.yardopts +1 -0
- data/Gemfile +4 -0
- data/README.md +54 -0
- data/Rakefile +1 -0
- data/bin/gsquire +334 -0
- data/gsquire.gemspec +41 -0
- data/lib/gsquire/accounts/tasks_api_middleware.rb +39 -0
- data/lib/gsquire/accounts/tokens.rb +112 -0
- data/lib/gsquire/accounts.rb +168 -0
- data/lib/gsquire/application.rb +65 -0
- data/lib/gsquire/client.rb +128 -0
- data/lib/gsquire/logging.rb +12 -0
- data/lib/gsquire/version.rb +5 -0
- data/lib/gsquire.rb +4 -0
- metadata +148 -0
data/.gitignore
ADDED
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup markdown --charset utf-8 --main README.md
|
data/Gemfile
ADDED
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
|
data/lib/gsquire.rb
ADDED
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:
|