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 +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:
|