freeagent-cli 0.2 → 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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/exe/fa +633 -21
  4. data/lib/freeagent.rb +118 -8
  5. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7abedc65bda2e935eac6777922e6b06f227745e2de0b1e60b8e1de95c72a0d83
4
- data.tar.gz: b2c6ae0c9595a8087cf87360a8dc62ba9c5c4eb02da2e6b0a7cba346fb7f0a4b
3
+ metadata.gz: 2703612db678ca26cedc911b865604fec14eb13221ec8f359518da6b5b53a8e9
4
+ data.tar.gz: 26fc95d6335bd2173009fffb59f10dc5412108cd9543f553e4f8d234950c4fa7
5
5
  SHA512:
6
- metadata.gz: 29fd16c93ea509618580da1d8b6e45738cc25917a945d77e213ef53cd8e345c57e118a2cfe493d28b497ba7de9b5b658b57af6fb26edb58a2e4307ea859e1d0e
7
- data.tar.gz: c648be179c9d9ab46a9831b4a2d3ca412fd47bb2c4db729f86737b588bf46819d1ac0dbc747841a9d0ac7db6f025e5be63c4f39773e2c6444c727ce5d9f198ff
6
+ metadata.gz: 78f9c14112b395a5a011a5e5caa82ce4838531f59ec09a9e2955b0b59fc8e1722ce645f8db4b6fc28922d154a881afb85c0b0a38cdfb754559f64c326acd716d
7
+ data.tar.gz: 29192da5d9ef6905a7aa1f6342b7a925e35506f6342d8821331021076a979b4a8137a8df99eae482edf40ca87e53617a28a4c128506df9e0256d00b5a7b71aac
data/Rakefile CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ task default: %i[build]
data/exe/fa CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'dotenv/load'
4
4
  require 'thor'
5
+ require 'snaky_hash'
5
6
  require_relative '../lib/freeagent'
6
7
 
7
8
  def match type, collection, needle, *values
@@ -10,7 +11,7 @@ def match type, collection, needle, *values
10
11
  end
11
12
  if found.none?
12
13
  possibles = collection.product(values).map {|item, value| value.to_proc.call(item).to_s }
13
- raise ArgumentError, "No #{type} matching '#{needle}' found, expecting one from #{possibles}"
14
+ raise ArgumentError, "No #{type} matching '#{needle}' found, expecting one from:\n#{possibles.sort.join("\n")}"
14
15
  end
15
16
  unless found.one?
16
17
  raise ArgumentError, "Ambiguous #{type}: could be #{found.map(&:url).join(', ')}"
@@ -18,85 +19,681 @@ def match type, collection, needle, *values
18
19
  found.first
19
20
  end
20
21
 
22
+ def sql_format value
23
+ case value
24
+ when Numeric; value.to_s
25
+ else; "'" + value.gsub(/'/, "''") + "'"
26
+ end
27
+ end
28
+
29
+ def id_from_url url
30
+ URI.parse(url).path.split('/').last.to_i
31
+ end
32
+
33
+ def select_task project, task_name
34
+ tasks = api.tasks(project: project)
35
+ if task_name.nil?
36
+ raise ArgumentError, "Expecting only one task in project #{project.name}, found #{tasks.size}" if not tasks.one?
37
+ tasks.first
38
+ else
39
+ match :task, tasks, task_name, :name
40
+ end
41
+ end
42
+
21
43
  module Freeagent
44
+ class Contacts < Thor
45
+ no_commands do
46
+ def sql contact
47
+ <<~SQL
48
+ INSERT OR REPLACE INTO contacts (id, first_name, last_name, organisation_name) VALUES (#{[
49
+ id_from_url(contact.url),
50
+ contact.first_name,
51
+ contact.last_name,
52
+ contact.organisation_name,
53
+ ].map(&method(:sql_format)).join(', ')});
54
+ SQL
55
+ end
56
+ end
57
+
58
+ SQL_SCHEMA = <<~SQL
59
+ CREATE TABLE IF NOT EXISTS contacts (
60
+ id INTEGER PRIMARY KEY,
61
+ first_name TEXT,
62
+ last_name TEXT,
63
+ organisation_name TEXT
64
+ );
65
+ SQL
66
+ end
67
+
22
68
  class Timeslips < Thor
23
69
  no_commands do
24
70
  def api
25
71
  @api ||= API.new
26
72
  end
73
+
74
+ def users
75
+ @users ||= Hash.new {|cache, url| cache[url] = api.user url.split('/').last }
76
+ end
77
+
78
+ def projects
79
+ @projects ||= Hash.new {|cache, url| cache[url] = api.project url.split('/').last }
80
+ end
81
+
82
+ def tasks
83
+ @tasks ||= Hash.new {|cache, url| cache[url] = api.task url.split('/').last }
84
+ end
85
+
86
+ def human timeslip
87
+ [
88
+ Date::parse(timeslip.dated_on).strftime('%a %d %b %Y'),
89
+ users[timeslip.user].email,
90
+ projects[timeslip.project].name,
91
+ tasks[timeslip.task].name,
92
+ timeslip.hours,
93
+ timeslip.comment
94
+ ].join("\t")
95
+ end
27
96
  end
28
97
 
29
- desc 'list USER PROJECT TASK FROM TO', "List timeslips"
30
- def list user, project, task, from, to
31
- from = Date::parse from
32
- to = Date::parse to
33
- user = match :user, api.users, user, :first_name, :last_name, :email
34
- project = match :project, api.projects, project, :name
35
- task = match :task, api.tasks(project), task, :name
98
+ desc 'list [-U USER] [-P PROJECT [-T TASK]] [-f FROM] [-t TO]', "List timeslips"
99
+ method_option :user, aliases: '-U', type: :string, default: nil, desc: 'Name or email of a user'
100
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: 'Name of a project'
101
+ method_option :task, aliases: '-T', type: :string, default: nil, desc: 'Name of a task within the project'
102
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list timeslips from'
103
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list timeslips to'
104
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
105
+ def list
106
+ raise ArgumentError, '--project must be specified if --task if used' if options[:project].nil? && !options[:task].nil?
107
+
108
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
109
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
110
+ user = options[:user].nil? ? nil : match(:user, api.users, options[:user], :first_name, :last_name, :email)
111
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
112
+ task = options[:task].nil? ? nil : match(:task, api.tasks(project: project), options[:task], :name)
113
+
114
+ users[user.url] = user unless user.nil?
115
+ projects[project.url] = project unless project.nil?
116
+ tasks[task.url] = task unless task.nil?
36
117
 
37
118
  api.timeslips(user: user, project: project, task: task, from: from, to: to).each do |timeslip|
38
- puts [Date::parse(timeslip.dated_on).strftime('%a %d %b %Y'), user.email, project.name, task.name, timeslip.hours, timeslip.comment].join("\t")
119
+ puts case options[:format].downcase
120
+ when 'human'; human(timeslip)
121
+ when 'json'; timeslip.to_json
122
+ else raise ArgumentError, "unknown output format #{options[:format]}"
123
+ end
39
124
  end
40
125
  end
41
126
 
42
- desc 'create USER PROJECT TASK [FROM [TO]]', "Create timeslips"
127
+ desc 'create USER PROJECT [TASK [FROM [TO]]]', "Create timeslips"
43
128
  method_option :hours, aliases: '-h', type: :numeric, default: 8, desc: "Number of hours per day"
44
129
  method_option :weekends, aliases: '-w', type: :boolean, default: false, desc: "Create timeslips on Saturdays and Sundays"
45
130
  method_option :comment, aliases: '-c', type: :string, default: nil, desc: "Comment to add to any created timeslips"
46
- def create user, project, task, from = nil, to = nil
131
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
132
+ def create user, project, task = nil, from = nil, to = nil
47
133
  from = from.nil? ? Date::today : Date::parse(from)
48
134
  to = to.nil? ? from : Date::parse(to)
49
135
  user = match :user, api.users, user, :first_name, :last_name, :email
50
136
  project = match :project, api.projects, project, :name
51
- task = match :task, api.tasks(project), task, :name
137
+ task = select_task project, task
138
+
139
+ users[user.url] = user unless user.nil?
140
+ projects[project.url] = project unless project.nil?
141
+ tasks[task.url] = task unless task.nil?
52
142
 
53
- puts "Creating timeslips for #{user.first_name} #{user.last_name} for task '#{task.name}' in project '#{project.name}' between #{from} and #{to} inclusive"
54
143
  timeslips = from.step(to).map do |date|
55
144
  next if (date.saturday? || date.sunday?) && !options[:weekends]
56
- {'task' => task.url, 'project' => project.url, 'user' => user.url, 'dated_on' => date.to_s, 'hours' => options[:hours], 'comment' => options[:comment]}
145
+ SnakyHash::StringKeyed.new(task: task.url, project: project.url, user: user.url, dated_on: date.to_s, hours: options[:hours], comment: options[:comment])
57
146
  end.reject(&:nil?)
58
147
  api.batch_create_timeslips timeslips
148
+ timeslips.each do |timeslip|
149
+ puts case options[:format].downcase
150
+ when 'human'; human(timeslip)
151
+ when 'json'; timeslip.to_json
152
+ else raise ArgumentError, "unknown output format #{options[:format]}"
153
+ end
154
+ end
59
155
  end
60
156
 
61
- desc 'fill USER PROJECT TASK [FROM [TO]]', 'Fill remaining hours with new timeslips'
157
+ desc 'fill USER PROJECT [TASK [FROM [TO]]]', 'Fill remaining hours with new timeslips'
62
158
  method_option :hours, aliases: '-h', type: :numeric, default: 8, desc: "Number of hours per day"
63
159
  method_option :weekends, aliases: '-w', type: :boolean, default: false, desc: "Create timeslips on Saturdays and Sundays"
64
160
  method_option :comment, aliases: '-c', type: :string, default: nil, desc: "Comment to add to any created timeslips"
65
- def fill user, project, task, from = nil, to = nil
161
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
162
+ def fill user, project, task = nil, from = nil, to = nil
66
163
  from = from.nil? ? Date::today : Date::parse(from)
67
164
  to = to.nil? ? from : Date::parse(to)
68
165
  user = match :user, api.users, user, :first_name, :last_name, :email
69
166
  project = match :project, api.projects, project, :name
70
- task = match :task, api.tasks(project), task, :name
167
+ task = select_task project, task
168
+
169
+ users[user.url] = user unless user.nil?
170
+ projects[project.url] = project unless project.nil?
171
+ tasks[task.url] = task unless task.nil?
71
172
 
72
173
  existing = api.timeslips(user: user, from: from, to: to).reduce(Hash.new) do |hash, timeslip|
73
174
  date = Date::parse timeslip.dated_on
74
175
  hash.update({date => (hash[date] || 0) + timeslip.hours.to_f})
75
176
  end
76
177
 
77
- puts "Filling timeslips for #{user.first_name} #{user.last_name} up to #{options[:hours]}hrs using task '#{task.name}' in project '#{project.name}' between #{from} and #{to} inclusive"
78
178
  timeslips = from.step(to).map do |date|
79
179
  next if (date.saturday? || date.sunday?) && !options[:weekends]
80
180
  existing_hours = existing[date] || 0
81
181
  adding_hours = options[:hours] - existing_hours
82
182
  next if adding_hours <= 0
83
183
 
84
- {'task' => task.url, 'project' => project.url, 'user' => user.url, 'dated_on' => date.to_s, 'hours' => adding_hours.to_s, 'comment' => options[:comment]}
184
+ SnakyHash::StringKeyed.new(task: task.url, project: project.url, user: user.url, dated_on: date.to_s, hours: adding_hours.to_s, comment: options[:comment])
85
185
  end.reject(&:nil?)
86
186
  api.batch_create_timeslips timeslips
187
+ timeslips.each do |timeslip|
188
+ puts case options[:format].downcase
189
+ when 'human'; human(timeslip)
190
+ when 'json'; timeslip.to_json
191
+ else raise ArgumentError, "unknown output format #{options[:format]}"
192
+ end
193
+ end
194
+ end
195
+
196
+ desc 'import [-U USER] [-P PROJECT [-T TASK]]', 'Import timeslips'
197
+ method_option :user, aliases: '-U', type: :string, default: nil, desc: 'Name or email of a user to override in input data'
198
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: 'Name of a project to override in input data'
199
+ method_option :task, aliases: '-T', type: :string, default: nil, desc: 'Name of a task within the project to override in input data'
200
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
201
+ def import
202
+ raise ArgumentError, '--project must be specified if --task if used' if options[:project].nil? && !options[:task].nil?
203
+
204
+ user = options[:user].nil? ? nil : match(:user, api.users, options[:user], :first_name, :last_name, :email)
205
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
206
+ task = options[:task].nil? ? nil : match(:task, api.tasks(project: project), options[:task], :name)
207
+
208
+ users[user.url] = user unless user.nil?
209
+ projects[project.url] = project unless project.nil?
210
+ tasks[task.url] = task unless task.nil?
211
+
212
+ api.batch_create_timeslips(JSON.parse(STDIN.read, object_class: SnakyHash::SymbolKeyed).map do |timeslip|
213
+ timeslip.user = user.url unless user.nil?
214
+ timeslip.project = project.url unless project.nil?
215
+ timeslip.task = task.url unless task.nil?
216
+
217
+ puts case options[:format].downcase
218
+ when 'human'; human(timeslip)
219
+ when 'json'; timeslip.to_json
220
+ else raise ArgumentError, "unknown output format #{options[:format]}"
221
+ end
222
+
223
+ timeslip
224
+ end)
87
225
  end
88
226
 
89
227
  desc 'delete USER PROJECT TASK [FROM [TO]]', 'Delete timeslips'
228
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
90
229
  def delete user, project, task, from = nil, to = nil
91
230
  from = from.nil? ? Date::today : Date::parse(from)
92
231
  to = to.nil? ? from : Date::parse(to)
93
232
  user = match :user, api.users, user, :first_name, :last_name, :email
94
233
  project = match :project, api.projects, project, :name
95
- task = match :task, api.tasks(project), task, :name
234
+ task = match :task, api.tasks(project: project), task, :name
235
+
236
+ users[user.url] = user unless user.nil?
237
+ projects[project.url] = project unless project.nil?
238
+ tasks[task.url] = task unless task.nil?
96
239
 
97
- puts "Deleting timeslips for #{user.first_name} #{user.last_name} for task '#{task.name}' in project '#{project.name}' between #{from} and #{to} inclusive"
98
240
  api.timeslips(user: user, project: project, task: task, from: from, to: to).each do |timeslip|
99
241
  api.delete_timeslip(timeslip)
242
+ puts case options[:format].downcase
243
+ when 'human'; human(timeslip)
244
+ when 'json'; timeslip.to_json
245
+ else raise ArgumentError, "unknown output format #{options[:format]}"
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ class Projects < Thor
252
+ no_commands do
253
+ def api
254
+ @api ||= API.new
255
+ end
256
+
257
+ def contacts
258
+ @contacts ||= Hash.new {|cache, url| cache[url] = api.contact url.split('/').last }
259
+ end
260
+
261
+ def human project
262
+ [
263
+ project.name,
264
+ project.contact_name,
265
+ project.status,
266
+ project.starts_on,
267
+ project.ends_on,
268
+ project.budget,
269
+ project.notes && project.notes.map(&:note).join('; '),
270
+ ].join("\t")
271
+ end
272
+
273
+ def sql project
274
+ <<~SQL
275
+ #{Contacts.new.sql(contacts[project.contact])}
276
+ INSERT OR REPLACE INTO projects (id, contact_id, name, status, starts_on, ends_on, budget, budget_units) VALUES (#{[
277
+ id_from_url(project.url),
278
+ id_from_url(project.contact),
279
+ project.name,
280
+ project.status,
281
+ project.starts_on,
282
+ project.ends_on,
283
+ project.budget.to_f,
284
+ project.budget_units,
285
+ ].map(&method(:sql_format)).join(', ')});
286
+ SQL
287
+ end
288
+ end
289
+
290
+ SQL_SCHEMA = <<~SQL
291
+ CREATE TABLE IF NOT EXISTS projects (
292
+ id INTEGER PRIMARY KEY,
293
+ contact_id INTEGER,
294
+ name TEXT,
295
+ status TEXT,
296
+ starts_on TEXT,
297
+ ends_on TEXT,
298
+ budget REAL,
299
+ budget_units TEXT,
300
+ FOREIGN KEY (contact_id) REFERENCES contacts(id)
301
+ );
302
+ SQL
303
+
304
+ desc 'list [-C CONTACT] [NAME [NAME ...]]', 'List projects'
305
+ method_option :contact, aliases: '-C', type: :string, default: nil, desc: "Limit to a contact, identified by name or email"
306
+ method_option :notes, type: :boolean, default: true, desc: 'Include notes in output'
307
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
308
+ def list *names
309
+ contact = options[:contact].nil? ? nil : match(:contact, api.contacts, options[:contact], :first_name, :last_name, :organisation_name, :email, :billing_email)
310
+ contacts[contact.url] = contact unless contact.nil?
311
+
312
+ api.projects(contact: contact).select do |project|
313
+ names.empty? || names.include?(project.name)
314
+ end.each do |project|
315
+ project.contact = contacts[project.contact]
316
+ if options[:notes]
317
+ project.notes = api.notes(project: project.url)
318
+ project.contact.notes = api.notes(contact: project.contact.url)
319
+ end
320
+
321
+ puts case options[:format].downcase
322
+ when 'human'; human(project)
323
+ when 'json'; project.to_json
324
+ else raise ArgumentError, "unknown output format #{options[:format]}"
325
+ end
326
+ end
327
+ end
328
+ end
329
+
330
+ class Tasks < Thor
331
+ no_commands do
332
+ def api
333
+ @api ||= API.new
334
+ end
335
+
336
+ def projects
337
+ @projects ||= Hash.new {|cache, url| cache[url] = api.project url.split('/').last }
338
+ end
339
+
340
+ def human task
341
+ [
342
+ task.name,
343
+ projects[task.project].name,
344
+ task.status,
345
+ task.is_billable ? "#{task.billing_rate} #{task.currency} / #{task.billing_period}" : 'Unbillable'
346
+ ].join("\t")
347
+ end
348
+ end
349
+
350
+ STATUSES = %i[ active completed hidden ]
351
+
352
+ desc 'list [-P PROJECT] [-S STATUS]', 'List tasks'
353
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: "Limit to a project, identified by name"
354
+ method_option :status, aliases: '-S', type: :string, default: nil, desc: "Match a status, one of: #{STATUSES.join(', ')}"
355
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
356
+ def list
357
+ status = options[:status].nil? ? nil : match(:status, STATUSES, options[:status], :to_s)
358
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
359
+
360
+ projects[project.url] = project unless project.nil?
361
+ api.tasks(project: project.url, view: status).each do |task|
362
+ puts case options[:format].downcase
363
+ when 'human'; human(task)
364
+ when 'json'; task.to_json
365
+ else raise ArgumentError, "unknown output format #{options[:format]}"
366
+ end
367
+ end
368
+ end
369
+
370
+ desc 'update STATUS [-P PROJECT] [-T TASK]', 'Update the status of a task'
371
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: 'Name of a project'
372
+ method_option :task, aliases: '-T', type: :string, default: nil, desc: 'Name of a task'
373
+ def update project, task, status
374
+ status = match(:status, STATUSES, status, :to_s)
375
+ project = match(:project, api.projects, project, :name)
376
+ task = match(:task, api.tasks(project: project), task, :name)
377
+
378
+ projects[project.url] = project unless project.nil?
379
+
380
+ api.tasks
381
+ task.status = status
382
+ api.update_task(task)
383
+
384
+ puts case options[:format].downcase
385
+ when 'human'; human(task)
386
+ when 'json'; task.to_json
387
+ else raise ArgumentError, "unknown output format #{options[:format]}"
388
+ end
389
+ end
390
+
391
+ desc 'delete PROJECT TASK', 'Delete a task'
392
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
393
+ def delete project, task
394
+ project = match :project, api.projects, project, :name
395
+ task = match :task, api.tasks(project: project), task, :name
396
+
397
+ projects[project.url] = project unless project.nil?
398
+
399
+ api.delete_task(task)
400
+ puts case options[:format].downcase
401
+ when 'human'; human(task)
402
+ when 'json'; task.to_json
403
+ else raise ArgumentError, "unknown output format #{options[:format]}"
404
+ end
405
+ end
406
+ end
407
+
408
+ class Invoices < Thor
409
+ no_commands do
410
+ def api
411
+ @api ||= API.new
412
+ end
413
+
414
+ def contacts
415
+ @contacts ||= Hash.new {|cache, url| cache[url] = api.contact url.split('/').last }
416
+ end
417
+
418
+ def projects
419
+ @projects ||= Hash.new {|cache, url| cache[url] = api.project url.split('/').last }
420
+ end
421
+
422
+ def human invoice
423
+ [
424
+ Date::parse(invoice.dated_on).strftime('%a %d %b %Y'),
425
+ Date::parse(invoice.due_on).strftime('%a %d %b %Y'),
426
+ invoice.contact_name,
427
+ projects[invoice.project].name,
428
+ invoice.reference,
429
+ invoice.total_value,
430
+ invoice.net_value,
431
+ ].join("\t")
432
+ end
433
+
434
+ def human_item item
435
+ [
436
+ item.description,
437
+ item.quantity,
438
+ item.item_type,
439
+ item.price,
440
+ ].join("\t")
441
+ end
442
+ end
443
+
444
+ STATUSES = %i[ all recent_open_or_overdue open overdue open_or_overdue draft paid scheduled_to_email thank_you_emails reminder_emails ]
445
+
446
+ desc 'list [-C CONTACT] [-P PROJECT] [-S STATUS] [-f FROM] [-t TO]', "List invoices"
447
+ method_option :contact, aliases: '-C', type: :string, default: nil, desc: "Limit to a contact, identified by name or email"
448
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: "Limit to a project, identified by name"
449
+ method_option :status, aliases: '-S', type: :string, default: nil, desc: "Invoices that match a status, one of: #{STATUSES.join(', ')}"
450
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list invoices from'
451
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list invoices to'
452
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
453
+ def list
454
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
455
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
456
+ status = options[:status].nil? ? nil : match(:status, STATUSES, options[:status], :to_s)
457
+ contact = options[:contact].nil? ? nil : match(:contact, api.contacts, options[:contact], :first_name, :last_name, :organisation_name, :email, :billing_email)
458
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
459
+
460
+ contacts[contact.url] = contact unless contact.nil?
461
+ projects[project.url] = project unless project.nil?
462
+ api.invoices(contact: contact, project: project, view: status).select do |invoice|
463
+ from.nil? || Date::parse(invoice.dated_on) >= from
464
+ end.each do |invoice|
465
+ puts case options[:format].downcase
466
+ when 'human'; human(invoice)
467
+ when 'json'; invoice.to_json
468
+ else raise ArgumentError, "unknown output format #{options[:format]}"
469
+ end
470
+ end
471
+ end
472
+
473
+ desc 'items [-C CONTACT] [-P PROJECT] [-S STATUS] [-f FROM] [-t TO]', 'List invoice items'
474
+ method_option :contact, aliases: '-C', type: :string, default: nil, desc: "Limit to a contact, identified by name or email"
475
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: "Limit to a project, identified by name"
476
+ method_option :status, aliases: '-S', type: :string, default: nil, desc: "Invoices that match a status, one of: #{STATUSES.join(', ')}"
477
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list invoices from'
478
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list invoices to'
479
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human or json'
480
+ def items
481
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
482
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
483
+ status = options[:status].nil? ? nil : match(:status, statuses, options[:status], :to_s)
484
+ contact = options[:contact].nil? ? nil : match(:contact, api.contacts, options[:contact], :first_name, :last_name, :organisation_name, :email, :billing_email)
485
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
486
+
487
+ contacts[contact.url] = contact unless contact.nil?
488
+ projects[project.url] = project unless project.nil?
489
+ list_call(contact: contact, project: project, view: status, nested_invoice_items: true).select do |invoice|
490
+ from.nil? || Date::parse(invoice.dated_on) >= from
491
+ end.flat_map(&:invoice_items).each do |item|
492
+ puts case options[:format].downcase
493
+ when 'human'; human_item(item)
494
+ when 'json'; item.to_json
495
+ else raise ArgumentError, "unknown output format #{options[:format]}"
496
+ end
497
+ end
498
+ end
499
+ end
500
+
501
+ class Bills < Thor
502
+ no_commands do
503
+ def api
504
+ @api ||= API.new
505
+ end
506
+
507
+ def contacts
508
+ @contacts ||= Hash.new {|cache, url| cache[url] = api.contact url.split('/').last }
509
+ end
510
+
511
+ def projects
512
+ @projects ||= Hash.new {|cache, url| cache[url] = api.project url.split('/').last }
513
+ end
514
+
515
+ def human bill
516
+ [
517
+ Date::parse(bill.dated_on).strftime('%a %d %b %Y'),
518
+ Date::parse(bill.due_on).strftime('%a %d %b %Y'),
519
+ bill.contact_name,
520
+ projects[bill.project].name,
521
+ bill.reference,
522
+ bill.total_value,
523
+ bill.net_value,
524
+ ].join("\t")
525
+ end
526
+
527
+ def sql bill
528
+ contact_class = Contacts.new
529
+ project_class = Projects.new
530
+ <<~SQL
531
+ #{contact_class.sql(contacts[bill.contact])}
532
+ #{project_class.sql(projects[bill.project])}
533
+ INSERT OR REPLACE INTO bills (id, dated_on, due_on, contact_id, project_id, reference, total_value, net_value) VALUES (#{[
534
+ id_from_url(bill.url),
535
+ bill.dated_on,
536
+ bill.due_on,
537
+ id_from_url(bill.contact),
538
+ id_from_url(bill.project),
539
+ bill.reference,
540
+ bill.total_value.to_f,
541
+ bill.net_value.to_f,
542
+ ].map(&method(:sql_format)).join(", ")});
543
+ SQL
544
+ end
545
+
546
+ def human_item item
547
+ [
548
+ item.description,
549
+ item.quantity,
550
+ item.unit,
551
+ item.total_value,
552
+ item.total_value_ex_tax,
553
+ ].join("\t")
554
+ end
555
+ end
556
+
557
+ SQL_SCHEMA = <<~SQL
558
+ CREATE TABLE IF NOT EXISTS bills (
559
+ id INTEGER PRIMARY KEY,
560
+ dated_on TEXT,
561
+ due_on TEXT,
562
+ contact_id INTEGER,
563
+ project_id INTEGER,
564
+ reference TEXT,
565
+ total_value REAL,
566
+ net_value REAL,
567
+ FOREIGN KEY (contact_id) REFERENCES contacts(id),
568
+ FOREIGN KEY (project_id) REFERENCES projects(id)
569
+ );
570
+ SQL
571
+
572
+ STATUSES = Invoices::STATUSES - %i[ recent_open_or_overdue scheduled_to_email thank_you_emails ] + %i[ open_or_overdue_payments open_or_overdue_refunds recurring hire_purchase cis ]
573
+
574
+ desc 'list [-C CONTACT] [-P PROJECT] [-S STATUS] [-f FROM] [-t TO]', "List bills"
575
+ method_option :contact, aliases: '-C', type: :string, default: nil, desc: "Limit to a contact, identified by name or email"
576
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: "Limit to a project, identified by name"
577
+ method_option :status, aliases: '-S', type: :string, default: nil, desc: "Bills that match a status, one of: #{STATUSES.join(', ')}"
578
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list bills from'
579
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list bills to'
580
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human, json or sql'
581
+ def list
582
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
583
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
584
+ status = options[:status].nil? ? nil : match(:status, STATUSES, options[:status], :to_s)
585
+ contact = options[:contact].nil? ? nil : match(:contact, api.contacts, options[:contact], :first_name, :last_name, :organisation_name, :email, :billing_email)
586
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
587
+
588
+ contacts[contact.url] = contact unless contact.nil?
589
+ projects[project.url] = project unless project.nil?
590
+
591
+ if options[:format].downcase == 'sql'
592
+ puts Contacts::SQL_SCHEMA
593
+ puts Projects::SQL_SCHEMA
594
+ puts SQL_SCHEMA
595
+ end
596
+
597
+ api.bills(contact: contact, project: project, view: status, nested_bill_items: true).select do |bill|
598
+ from.nil? || Date::parse(bill.dated_on) >= from
599
+ end.each do |bill|
600
+ puts case options[:format].downcase
601
+ when 'human'; human(bill)
602
+ when 'json'; bill.to_json
603
+ when 'sql'; sql(bill)
604
+ else raise ArgumentError, "unknown output format #{options[:format]}"
605
+ end
606
+ end
607
+ end
608
+
609
+ desc 'items [-C CONTACT] [-P PROJECT] [-S STATUS] [-f FROM] [-t TO]', 'List bill items'
610
+ method_option :contact, aliases: '-C', type: :string, default: nil, desc: "Limit to a contact, identified by name or email"
611
+ method_option :project, aliases: '-P', type: :string, default: nil, desc: "Limit to a project, identified by name"
612
+ method_option :status, aliases: '-S', type: :string, default: nil, desc: "Bills that match a status, one of: #{STATUSES.join(', ')}"
613
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list bills from'
614
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list bills to'
615
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human, json or sql'
616
+ def items
617
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
618
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
619
+ status = options[:status].nil? ? nil : match(:status, statuses, options[:status], :to_s)
620
+ contact = options[:contact].nil? ? nil : match(:contact, api.contacts, options[:contact], :first_name, :last_name, :organisation_name, :email, :billing_email)
621
+ project = options[:project].nil? ? nil : match(:project, api.projects, options[:project], :name)
622
+
623
+ contacts[contact.url] = contact unless contact.nil?
624
+ projects[project.url] = project unless project.nil?
625
+ api.bills(contact: contact, project: project, view: status, nested_bill_items: true).select do |bill|
626
+ from.nil? || Date::parse(bill.dated_on) >= from
627
+ end.flat_map(&:bill_items).each do |item|
628
+ puts case options[:format].downcase
629
+ when 'human'; human_item(item)
630
+ when 'json'; item.to_json
631
+ else raise ArgumentError, "unknown output format #{options[:format]}"
632
+ end
633
+ end
634
+ end
635
+ end
636
+
637
+ class Transactions < Thor
638
+ no_commands do
639
+ def api
640
+ @api ||= API.new
641
+ end
642
+
643
+ def human txn
644
+ [
645
+ Date::parse(txn.dated_on).strftime('%a %d %b %Y'),
646
+ txn.nominal_code,
647
+ txn.category_name,
648
+ txn.description,
649
+ txn.debit_value,
650
+ ].join("\t")
651
+ end
652
+
653
+ def sql txn
654
+ <<~SQL
655
+ INSERT OR REPLACE INTO transactions (id, dated_on, nominal_code, category_name, description, debit_value) VALUES (#{[
656
+ id_from_url(txn.url),
657
+ txn.dated_on,
658
+ txn.nominal_code,
659
+ txn.category_name,
660
+ txn.description,
661
+ txn.debit_value.to_f,
662
+ ].map(&method(:sql_format)).join(",")});
663
+ SQL
664
+ end
665
+ end
666
+
667
+ SQL_SCHEMA = <<~SQL
668
+ CREATE TABLE IF NOT EXISTS transactions (
669
+ id INTEGER PRIMARY KEY,
670
+ dated_on TEXT,
671
+ nominal_code TEXT,
672
+ category_name TEXT,
673
+ description TEXT,
674
+ debit_value REAL
675
+ );
676
+ SQL
677
+
678
+ desc 'list [-C CATEGORY] [-f FROM] [-t TO]', 'List transactions'
679
+ method_option :category, aliases: '-C', type: :string, default: nil, desc: 'Limit to a category, identified by name or code'
680
+ method_option :from, aliases: '-f', type: :string, default: nil, desc: 'Date to list transactions from'
681
+ method_option :to, aliases: '-t', type: :string, default: nil, desc: 'Date to list transactions to'
682
+ method_option :format, type: :string, default: 'human', desc: 'Output format, one of human, json or sql'
683
+ def list
684
+ category = options[:category].nil? ? nil : match(:category, api.categories, options[:category], :description, :nominal_code).nominal_code
685
+ from = options[:from].nil? ? nil : Date::parse(options[:from])
686
+ to = options[:to].nil? ? nil : Date::parse(options[:to])
687
+
688
+ puts SQL_SCHEMA if options[:format].downcase == 'sql'
689
+
690
+ api.transactions(category_code: category, from: from, to: to).each do |txn|
691
+ puts case options[:format].downcase
692
+ when 'human'; human(txn)
693
+ when 'json'; txn.to_json
694
+ when 'sql'; sql(txn)
695
+ else raise ArgumentError, "unknown output format #{options[:format]}"
696
+ end
100
697
  end
101
698
  end
102
699
  end
@@ -104,6 +701,21 @@ module Freeagent
104
701
  class Command < Thor
105
702
  desc "timeslips", "Create, read, update and delete timeslips"
106
703
  subcommand "timeslips", Timeslips
704
+
705
+ desc "projects", "Create, read, update and delete projects"
706
+ subcommand "projects", Projects
707
+
708
+ desc "tasks", "Create, read, update and delete tasks"
709
+ subcommand "tasks", Tasks
710
+
711
+ desc "invoices", "Read invoices"
712
+ subcommand "invoices", Invoices
713
+
714
+ desc "bills", "Read bills"
715
+ subcommand "bills", Bills
716
+
717
+ desc "transactions", "Read transactions"
718
+ subcommand "transactions", Transactions
107
719
  end
108
720
  end
109
721
 
data/lib/freeagent.rb CHANGED
@@ -63,7 +63,7 @@ module Freeagent
63
63
  def get *parts, **params
64
64
  relatives = parts.map(&:to_s).join('/')
65
65
  uri = URI.parse(relatives)
66
- puts "GET #{uri}"
66
+ STDERR.puts "GET #{uri}#{params.any? ? '?' + URI.encode_www_form(params) : ''}"
67
67
  res = @token.get(uri, params: params, headers: {'Accept': 'application/json'})
68
68
  data = res.parsed
69
69
  if res.headers.include? 'Link'
@@ -79,6 +79,8 @@ module Freeagent
79
79
  STDERR.puts "Rate limited; sleeping for #{retry_after}"
80
80
  sleep retry_after
81
81
  retry
82
+ else
83
+ raise err
82
84
  end
83
85
  end
84
86
 
@@ -97,15 +99,22 @@ module Freeagent
97
99
  data = parts.pop
98
100
  relatives = parts.map(&:to_s).join('/')
99
101
  uri = URI.parse(relatives)
100
- puts "POST #{uri}"
102
+ STDERR.puts "POST #{uri}"
101
103
  body = data.to_json
102
104
  @token.post(uri, body: body, params: params, headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}).parsed
103
105
  end
104
106
 
107
+ def put *parts, **params
108
+ relatives = parts.map(&:to_s).join('/')
109
+ uri = URI.parse(relatives)
110
+ STDERR.puts "PUT #{uri}"
111
+ @token.put(uri, params: params, headers: {'Accept': 'application/json'})
112
+ end
113
+
105
114
  def delete *parts, **params
106
115
  relatives = parts.map(&:to_s).join('/')
107
116
  uri = URI.parse(relatives)
108
- puts "DELETE #{uri}"
117
+ STDERR.puts "DELETE #{uri}"
109
118
  @token.delete(uri, params: params, headers: {'Accept': 'application/json'})
110
119
  end
111
120
 
@@ -126,12 +135,39 @@ module Freeagent
126
135
  get('payroll_profiles', year).profiles
127
136
  end
128
137
 
129
- def projects
130
- get_pages('projects').flat_map(&:projects)
138
+ def project id
139
+ get('projects', id).project
140
+ end
141
+
142
+ def projects contact: nil
143
+ params = [
144
+ [:contact, contact && contact.url]
145
+ ].reject {|(k ,v)| v.nil?}.to_h
146
+ get_pages('projects', **params).flat_map(&:projects)
147
+ end
148
+
149
+ def task id
150
+ get('tasks', id).task
151
+ end
152
+
153
+ def tasks project: nil, view: nil, updated_since: nil
154
+ params = [
155
+ [:project, project&.to_s],
156
+ [:view, view&.to_s],
157
+ [:updated_since, updated_since&.to_s],
158
+ ].reject {|k, v| v.nil? }.to_h
159
+ get_pages('tasks', **params).flat_map(&:tasks)
160
+ end
161
+
162
+ def update_task status: nil
163
+ attribs = [
164
+ [:status, status&.to_s],
165
+ ].reject {|k, v| v.nil? }.to_h
166
+ put(task.url, task: attribs)
131
167
  end
132
168
 
133
- def tasks project
134
- get_pages('tasks', project: project.url).flat_map(&:tasks)
169
+ def delete_task task
170
+ delete(task.url, {})
135
171
  end
136
172
 
137
173
  def timeslips user: nil, project: nil, task: nil, from: nil, to: nil
@@ -156,7 +192,7 @@ module Freeagent
156
192
  end
157
193
 
158
194
  def batch_create_timeslips timeslips
159
- post('timeslips', {'timeslips' => timeslips})
195
+ post('timeslips', {'timeslips' => timeslips.to_a})
160
196
  end
161
197
 
162
198
  def delete_timeslip timeslip
@@ -164,6 +200,39 @@ module Freeagent
164
200
  delete('timeslips', id)
165
201
  end
166
202
 
203
+ def invoices contact: nil, project: nil, view: nil, updated_since: nil, nested_invoice_items: false, sort: nil
204
+ params = [
205
+ [:contact, contact && contact.url],
206
+ [:project, project && project.url],
207
+ [:view, view && view.to_s],
208
+ [:updated_since, updated_since && updated_since.to_s],
209
+ [:nested_invoice_items, nested_invoice_items && nested_invoice_items.to_s],
210
+ [:sort, sort && sort.to_s],
211
+ ].reject {|k, v| v.nil? }.to_h
212
+ get_pages('invoices', **params).flat_map(&:invoices)
213
+ end
214
+
215
+ def invoice id
216
+ get('invoices', id).invoice
217
+ end
218
+
219
+ def bills contact: nil, project: nil, nested_bill_items: false, view: nil, from: nil, to: nil, updated_since: nil
220
+ params = [
221
+ [:contact, contact && contact.url],
222
+ [:project, project && project.url],
223
+ [:view, view && view.to_s],
224
+ [:from_date, from && from.to_s],
225
+ [:to_date, to && to.to_s],
226
+ [:updated_since, updated_since && updated_since.to_s],
227
+ [:nested_bill_items, nested_bill_items && nested_bill_items.to_s],
228
+ ].reject {|k, v| v.nil? }.to_h
229
+ get_pages('bills', **params).flat_map(&:bills)
230
+ end
231
+
232
+ def bill id
233
+ get('bills', id).bill
234
+ end
235
+
167
236
  def users
168
237
  get_pages('users').flat_map(&:users)
169
238
  end
@@ -172,8 +241,49 @@ module Freeagent
172
241
  get('users', id).user
173
242
  end
174
243
 
244
+ def contacts view: nil, updated_since: nil, sort: nil
245
+ params = [
246
+ [:view, view && view.to_s],
247
+ [:updated_since, updated_since && updated_since.to_s],
248
+ [:sort, sort && sort.to_s],
249
+ ].reject {|k, v| v.nil? }.to_h
250
+ get_pages('contacts', **params).flat_map(&:contacts)
251
+ end
252
+
253
+ def contact id
254
+ get('contacts', id).contact
255
+ end
256
+
175
257
  def profile year, user
176
258
  get('payroll_profiles', year, user: user)
177
259
  end
260
+
261
+ def notes contact: nil, project: nil
262
+ params = [
263
+ [:contact, contact && contact.to_s],
264
+ [:project, project && project.to_s],
265
+ ].reject {|k, v| v.nil? }.to_h
266
+ get_pages('notes', **params).flat_map(&:notes)
267
+ end
268
+
269
+ def transactions category_code: nil, from: nil, to: nil
270
+ params = [
271
+ [:from_date, from && from.to_s],
272
+ [:to_date, to && to.to_s],
273
+ [:nominal_code, category_code && category_code.to_s],
274
+ ].reject {|k, v| v.nil? }.to_h
275
+ get_pages('accounting', 'transactions', **params).flat_map(&:transactions)
276
+ end
277
+
278
+ def categories
279
+ get_pages('categories', sub_accounts: true).flat_map do |response|
280
+ [
281
+ *(response.admin_expense_categories || []),
282
+ *(response.cost_of_sales_categories || []),
283
+ *(response.income_categories || []),
284
+ *(response.general_categories || []),
285
+ ]
286
+ end
287
+ end
178
288
  end
179
289
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freeagent-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Worthington
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-02 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: oauth2
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - ">"
67
67
  - !ruby/object:Gem::Version
68
68
  version: 2.1.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: snaky_hash
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: rake
71
85
  requirement: !ruby/object:Gem::Requirement