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.
- checksums.yaml +4 -4
- data/Rakefile +1 -1
- data/exe/fa +633 -21
- data/lib/freeagent.rb +118 -8
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2703612db678ca26cedc911b865604fec14eb13221ec8f359518da6b5b53a8e9
|
|
4
|
+
data.tar.gz: 26fc95d6335bd2173009fffb59f10dc5412108cd9543f553e4f8d234950c4fa7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78f9c14112b395a5a011a5e5caa82ce4838531f59ec09a9e2955b0b59fc8e1722ce645f8db4b6fc28922d154a881afb85c0b0a38cdfb754559f64c326acd716d
|
|
7
|
+
data.tar.gz: 29192da5d9ef6905a7aa1f6342b7a925e35506f6342d8821331021076a979b4a8137a8df99eae482edf40ca87e53617a28a4c128506df9e0256d00b5a7b71aac
|
data/Rakefile
CHANGED
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 [
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
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
|
|
134
|
-
|
|
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.
|
|
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:
|
|
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
|