freshbooks-cli 0.3.2 → 0.4.0
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/bin/fb +2 -2
- data/lib/freshbooks/api.rb +325 -0
- data/lib/freshbooks/auth.rb +484 -0
- data/lib/freshbooks/cli.rb +1106 -0
- data/lib/freshbooks/spinner.rb +50 -0
- data/lib/freshbooks/version.rb +7 -0
- data/lib/freshbooks.rb +7 -0
- metadata +22 -8
- data/lib/fb/api.rb +0 -301
- data/lib/fb/auth.rb +0 -379
- data/lib/fb/cli.rb +0 -1060
- data/lib/fb/spinner.rb +0 -48
- data/lib/fb/version.rb +0 -5
- data/lib/fb.rb +0 -7
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "date"
|
|
6
|
+
require "io/console"
|
|
7
|
+
require "stringio"
|
|
8
|
+
|
|
9
|
+
module FreshBooks
|
|
10
|
+
module CLI
|
|
11
|
+
class Commands < Thor
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
|
|
17
|
+
class_option :interactive, type: :boolean, default: false, desc: "Force interactive mode even when not a TTY"
|
|
18
|
+
class_option :format, type: :string, desc: "Output format: table (default) or json"
|
|
19
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Simulate command without making network calls"
|
|
20
|
+
|
|
21
|
+
no_commands do
|
|
22
|
+
def invoke_command(command, *args)
|
|
23
|
+
Spinner.interactive = interactive?
|
|
24
|
+
return super unless options[:dry_run]
|
|
25
|
+
|
|
26
|
+
Thread.current[:fb_dry_run] = true
|
|
27
|
+
$stderr.puts "[DRY RUN] No changes will be made."
|
|
28
|
+
|
|
29
|
+
if options[:format] == "json"
|
|
30
|
+
original_stdout = $stdout
|
|
31
|
+
buffer = StringIO.new
|
|
32
|
+
$stdout = buffer
|
|
33
|
+
begin
|
|
34
|
+
super
|
|
35
|
+
ensure
|
|
36
|
+
$stdout = original_stdout
|
|
37
|
+
end
|
|
38
|
+
begin
|
|
39
|
+
data = JSON.parse(buffer.string)
|
|
40
|
+
existing_dry_run = data.is_a?(Hash) ? (data["_dry_run"] || {}) : {}
|
|
41
|
+
meta = { "_dry_run" => existing_dry_run.merge("simulated" => true) }
|
|
42
|
+
wrapped = data.is_a?(Array) ? { "_dry_run" => { "simulated" => true } }.merge("data" => data) : data.merge(meta)
|
|
43
|
+
puts JSON.pretty_generate(wrapped)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
print buffer.string
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[:fb_dry_run] = false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- version ---
|
|
56
|
+
|
|
57
|
+
desc "version", "Print the current version"
|
|
58
|
+
def version
|
|
59
|
+
puts "freshbooks-cli #{VERSION}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- auth ---
|
|
63
|
+
|
|
64
|
+
desc "auth [SUBCOMMAND] [ARGS]", "Authenticate with FreshBooks via OAuth2 (subcommands: setup, url, callback, status)"
|
|
65
|
+
def auth(subcommand = nil, *args)
|
|
66
|
+
case subcommand
|
|
67
|
+
when "setup"
|
|
68
|
+
config = Auth.setup_config_from_args
|
|
69
|
+
if options[:format] == "json"
|
|
70
|
+
puts JSON.pretty_generate({ "config_path" => Auth.config_path, "status" => "saved" })
|
|
71
|
+
else
|
|
72
|
+
puts "Config saved to #{Auth.config_path}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
when "url"
|
|
76
|
+
config = Auth.load_config
|
|
77
|
+
abort("No config found. Run: fb auth setup (set FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET first)") unless config
|
|
78
|
+
url = Auth.authorize_url(config)
|
|
79
|
+
if options[:format] == "json"
|
|
80
|
+
puts JSON.pretty_generate({ "url" => url })
|
|
81
|
+
else
|
|
82
|
+
puts url
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
when "callback"
|
|
86
|
+
config = Auth.load_config
|
|
87
|
+
abort("No config found. Run: fb auth setup (set FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET first)") unless config
|
|
88
|
+
redirect_url = args.first
|
|
89
|
+
abort("Usage: fb auth callback REDIRECT_URL") unless redirect_url
|
|
90
|
+
code = Auth.extract_code_from_url(redirect_url)
|
|
91
|
+
abort("Could not find 'code' parameter in the URL.") unless code
|
|
92
|
+
tokens = Auth.exchange_code(config, code)
|
|
93
|
+
|
|
94
|
+
# Auto-discover businesses
|
|
95
|
+
businesses = Auth.fetch_businesses(tokens["access_token"])
|
|
96
|
+
if businesses.length == 1
|
|
97
|
+
Auth.select_business(config, businesses.first.dig("business", "id"), businesses)
|
|
98
|
+
biz = businesses.first["business"]
|
|
99
|
+
if options[:format] == "json"
|
|
100
|
+
puts JSON.pretty_generate({ "status" => "authenticated", "business" => biz })
|
|
101
|
+
else
|
|
102
|
+
puts "Business auto-selected: #{biz["name"]} (#{biz["id"]})"
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
if options[:format] == "json"
|
|
106
|
+
biz_list = businesses.map { |m| m["business"] }
|
|
107
|
+
puts JSON.pretty_generate({ "status" => "authenticated", "businesses" => biz_list, "business_selected" => false })
|
|
108
|
+
else
|
|
109
|
+
puts "Authenticated! Multiple businesses found — select one with: fb business --select ID"
|
|
110
|
+
businesses.each do |m|
|
|
111
|
+
biz = m["business"]
|
|
112
|
+
puts " #{biz["name"]} (ID: #{biz["id"]})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
when "status"
|
|
118
|
+
status_data = Auth.auth_status
|
|
119
|
+
if options[:format] == "json"
|
|
120
|
+
puts JSON.pretty_generate(status_data)
|
|
121
|
+
else
|
|
122
|
+
puts "Config: #{status_data["config_exists"] ? "found" : "missing"} (#{status_data["config_path"]})"
|
|
123
|
+
puts "Tokens: #{status_data["tokens_exist"] ? "found" : "missing"}"
|
|
124
|
+
if status_data["tokens_exist"]
|
|
125
|
+
puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
|
|
126
|
+
end
|
|
127
|
+
puts "Business ID: #{status_data["business_id"] || "not set"}"
|
|
128
|
+
puts "Account ID: #{status_data["account_id"] || "not set"}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
else
|
|
132
|
+
unless interactive?
|
|
133
|
+
abort("Use auth subcommands for non-interactive auth: fb auth setup, fb auth url, fb auth callback, fb auth status")
|
|
134
|
+
end
|
|
135
|
+
config = Auth.require_config
|
|
136
|
+
tokens = Auth.authorize(config)
|
|
137
|
+
Auth.discover_business(tokens["access_token"], config)
|
|
138
|
+
puts "\nReady to go! Try: fb entries"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# --- business ---
|
|
143
|
+
|
|
144
|
+
desc "business", "List or select a business"
|
|
145
|
+
method_option :select, type: :string, desc: "Set active business by ID (omit value for interactive picker)"
|
|
146
|
+
def business
|
|
147
|
+
Auth.valid_access_token
|
|
148
|
+
config = Auth.load_config
|
|
149
|
+
tokens = Auth.load_tokens
|
|
150
|
+
businesses = Auth.fetch_businesses(tokens["access_token"])
|
|
151
|
+
|
|
152
|
+
if businesses.empty?
|
|
153
|
+
abort("No business memberships found on your FreshBooks account.")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if options[:select]
|
|
157
|
+
Auth.select_business(config, options[:select], businesses)
|
|
158
|
+
selected = businesses.find { |m| m.dig("business", "id").to_s == options[:select].to_s }
|
|
159
|
+
biz = selected["business"]
|
|
160
|
+
if options[:format] == "json"
|
|
161
|
+
puts JSON.pretty_generate(biz)
|
|
162
|
+
else
|
|
163
|
+
puts "Active business: #{biz["name"]} (#{biz["id"]})"
|
|
164
|
+
end
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if options.key?("select") && options[:select].nil?
|
|
169
|
+
# --select with no value: interactive picker
|
|
170
|
+
unless interactive?
|
|
171
|
+
abort("Non-interactive: use --select ID. Available businesses:\n" +
|
|
172
|
+
businesses.map { |m| " #{m.dig("business", "name")} (ID: #{m.dig("business", "id")})" }.join("\n"))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
puts "\nBusinesses:\n\n"
|
|
176
|
+
businesses.each_with_index do |m, i|
|
|
177
|
+
biz = m["business"]
|
|
178
|
+
active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
|
|
179
|
+
puts " #{i + 1}. #{biz["name"]} (#{biz["id"]})#{active}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
print "\nSelect business (1-#{businesses.length}): "
|
|
183
|
+
input = $stdin.gets&.strip
|
|
184
|
+
abort("Cancelled.") if input.nil? || input.empty?
|
|
185
|
+
|
|
186
|
+
idx = input.to_i - 1
|
|
187
|
+
abort("Invalid selection.") if idx < 0 || idx >= businesses.length
|
|
188
|
+
|
|
189
|
+
selected_biz = businesses[idx]
|
|
190
|
+
Auth.select_business(config, selected_biz.dig("business", "id"), businesses)
|
|
191
|
+
puts "Active business: #{selected_biz.dig("business", "name")} (#{selected_biz.dig("business", "id")})"
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Default: list businesses
|
|
196
|
+
if options[:format] == "json"
|
|
197
|
+
biz_list = businesses.map do |m|
|
|
198
|
+
biz = m["business"]
|
|
199
|
+
biz.merge("active" => biz["id"].to_s == config["business_id"].to_s)
|
|
200
|
+
end
|
|
201
|
+
puts JSON.pretty_generate(biz_list)
|
|
202
|
+
else
|
|
203
|
+
businesses.each do |m|
|
|
204
|
+
biz = m["business"]
|
|
205
|
+
active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
|
|
206
|
+
puts "#{biz["name"]} (ID: #{biz["id"]})#{active}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# --- log ---
|
|
212
|
+
|
|
213
|
+
desc "log", "Log a time entry"
|
|
214
|
+
method_option :client, type: :string, desc: "Pre-select client by name"
|
|
215
|
+
method_option :project, type: :string, desc: "Pre-select project by name"
|
|
216
|
+
method_option :service, type: :string, desc: "Pre-select service by name"
|
|
217
|
+
method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
|
|
218
|
+
method_option :note, type: :string, desc: "Work description"
|
|
219
|
+
method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
|
|
220
|
+
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
221
|
+
def log
|
|
222
|
+
Auth.valid_access_token
|
|
223
|
+
defaults = Auth.load_defaults
|
|
224
|
+
|
|
225
|
+
client = select_client(defaults)
|
|
226
|
+
project = select_project(client["id"], defaults)
|
|
227
|
+
service = select_service(defaults, project)
|
|
228
|
+
date = pick_date
|
|
229
|
+
duration_hours = pick_duration
|
|
230
|
+
note = pick_note
|
|
231
|
+
|
|
232
|
+
client_name = display_name(client)
|
|
233
|
+
|
|
234
|
+
unless options[:format] == "json"
|
|
235
|
+
puts "\n--- Time Entry Summary ---"
|
|
236
|
+
puts " Client: #{client_name}"
|
|
237
|
+
puts " Project: #{project ? project["title"] : "(none)"}"
|
|
238
|
+
puts " Service: #{service ? service["name"] : "(none)"}"
|
|
239
|
+
puts " Date: #{date}"
|
|
240
|
+
puts " Duration: #{duration_hours}h"
|
|
241
|
+
puts " Note: #{note}"
|
|
242
|
+
puts "--------------------------\n\n"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
unless options[:yes]
|
|
246
|
+
print "Submit? (Y/n): "
|
|
247
|
+
answer = $stdin.gets&.strip&.downcase
|
|
248
|
+
abort("Cancelled.") if answer == "n"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
entry = {
|
|
252
|
+
"is_logged" => true,
|
|
253
|
+
"duration" => (duration_hours * 3600).to_i,
|
|
254
|
+
"note" => note,
|
|
255
|
+
"started_at" => normalize_datetime(date),
|
|
256
|
+
"client_id" => client["id"]
|
|
257
|
+
}
|
|
258
|
+
entry["project_id"] = project["id"] if project
|
|
259
|
+
entry["service_id"] = service["id"] if service
|
|
260
|
+
|
|
261
|
+
result = Api.create_time_entry(entry)
|
|
262
|
+
|
|
263
|
+
if options[:format] == "json"
|
|
264
|
+
puts JSON.pretty_generate(result)
|
|
265
|
+
else
|
|
266
|
+
puts "Time entry created!"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
new_defaults = { "client_id" => client["id"] }
|
|
270
|
+
new_defaults["project_id"] = project["id"] if project
|
|
271
|
+
new_defaults["service_id"] = service["id"] if service
|
|
272
|
+
Auth.save_defaults(new_defaults)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --- entries ---
|
|
276
|
+
|
|
277
|
+
desc "entries", "List time entries (defaults to current month)"
|
|
278
|
+
method_option :month, type: :numeric, desc: "Month (1-12)"
|
|
279
|
+
method_option :year, type: :numeric, desc: "Year"
|
|
280
|
+
method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
|
|
281
|
+
method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
|
|
282
|
+
def entries
|
|
283
|
+
Auth.valid_access_token
|
|
284
|
+
|
|
285
|
+
today = Date.today
|
|
286
|
+
|
|
287
|
+
if options[:from] || options[:to]
|
|
288
|
+
first_day = options[:from] ? Date.parse(options[:from]) : nil
|
|
289
|
+
last_day = options[:to] ? Date.parse(options[:to]) : nil
|
|
290
|
+
else
|
|
291
|
+
month = options[:month] || today.month
|
|
292
|
+
year = options[:year] || today.year
|
|
293
|
+
first_day = Date.new(year, month, 1)
|
|
294
|
+
last_day = Date.new(year, month, -1)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
label = if first_day && last_day
|
|
298
|
+
"#{first_day} to #{last_day}"
|
|
299
|
+
elsif first_day
|
|
300
|
+
"from #{first_day} onwards"
|
|
301
|
+
elsif last_day
|
|
302
|
+
"up to #{last_day}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
entries = Spinner.spin("Fetching time entries#{label ? " (#{label})" : ""}") do
|
|
306
|
+
Api.fetch_time_entries(
|
|
307
|
+
started_from: first_day&.to_s,
|
|
308
|
+
started_to: last_day&.to_s
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
if entries.empty?
|
|
313
|
+
if options[:format] == "json"
|
|
314
|
+
puts "[]"
|
|
315
|
+
else
|
|
316
|
+
puts "No time entries#{label ? " #{label}" : ""}."
|
|
317
|
+
end
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if options[:format] == "json"
|
|
322
|
+
puts JSON.pretty_generate(entries)
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
327
|
+
entries.sort_by! { |e| e["started_at"] || "" }
|
|
328
|
+
|
|
329
|
+
rows = entries.map do |e|
|
|
330
|
+
date = (e["local_started_at"] || e["started_at"] || "?").slice(0, 10)
|
|
331
|
+
client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
|
|
332
|
+
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
333
|
+
service = maps[:services][e["service_id"].to_s] || "-"
|
|
334
|
+
note = e["note"] || ""
|
|
335
|
+
hours = (e["duration"].to_i / 3600.0).round(2)
|
|
336
|
+
[e["id"].to_s, date, client, project, service, note, "#{hours}h"]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows, wrap_col: 5)
|
|
340
|
+
|
|
341
|
+
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
342
|
+
|
|
343
|
+
# Per-client breakdown
|
|
344
|
+
by_client = entries.group_by { |e| maps[:clients][e["client_id"].to_s] || e["client_id"].to_s }
|
|
345
|
+
if by_client.length > 1
|
|
346
|
+
puts "\nBy client:"
|
|
347
|
+
by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
|
|
348
|
+
puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Per-service breakdown
|
|
353
|
+
by_service = entries.group_by { |e| maps[:services][e["service_id"].to_s] || "-" }
|
|
354
|
+
if by_service.length > 1
|
|
355
|
+
puts "\nBy service:"
|
|
356
|
+
by_service.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
|
|
357
|
+
puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
puts "\nTotal: #{total.round(2)}h"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# --- clients ---
|
|
365
|
+
|
|
366
|
+
desc "clients", "List all clients"
|
|
367
|
+
def clients
|
|
368
|
+
Auth.valid_access_token
|
|
369
|
+
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
370
|
+
|
|
371
|
+
if clients.empty?
|
|
372
|
+
puts "No clients found."
|
|
373
|
+
return
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if options[:format] == "json"
|
|
377
|
+
puts JSON.pretty_generate(clients)
|
|
378
|
+
return
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
rows = clients.map do |c|
|
|
382
|
+
name = c["organization"]
|
|
383
|
+
name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
|
|
384
|
+
email = c["email"] || "-"
|
|
385
|
+
org = c["organization"] || "-"
|
|
386
|
+
[name, email, org]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
print_table(["Name", "Email", "Organization"], rows)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# --- projects ---
|
|
393
|
+
|
|
394
|
+
desc "projects", "List all projects"
|
|
395
|
+
method_option :client, type: :string, desc: "Filter by client name"
|
|
396
|
+
def projects
|
|
397
|
+
Auth.valid_access_token
|
|
398
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
399
|
+
|
|
400
|
+
projects = if options[:client]
|
|
401
|
+
client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
|
|
402
|
+
abort("Client not found: #{options[:client]}") unless client_id
|
|
403
|
+
Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
|
|
404
|
+
else
|
|
405
|
+
Spinner.spin("Fetching projects") { Api.fetch_projects }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
if projects.empty?
|
|
409
|
+
puts "No projects found."
|
|
410
|
+
return
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
if options[:format] == "json"
|
|
414
|
+
puts JSON.pretty_generate(projects)
|
|
415
|
+
return
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
rows = projects.map do |p|
|
|
419
|
+
client_name = maps[:clients][p["client_id"].to_s] || "-"
|
|
420
|
+
[p["title"], client_name, p["active"] ? "active" : "inactive"]
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
print_table(["Title", "Client", "Status"], rows)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# --- services ---
|
|
427
|
+
|
|
428
|
+
desc "services", "List all services"
|
|
429
|
+
def services
|
|
430
|
+
Auth.valid_access_token
|
|
431
|
+
services = Spinner.spin("Fetching services") { Api.fetch_services }
|
|
432
|
+
|
|
433
|
+
if services.empty?
|
|
434
|
+
puts "No services found."
|
|
435
|
+
return
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
if options[:format] == "json"
|
|
439
|
+
puts JSON.pretty_generate(services)
|
|
440
|
+
return
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
rows = services.map do |s|
|
|
444
|
+
billable = s["billable"] ? "yes" : "no"
|
|
445
|
+
[s["name"], billable]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
print_table(["Name", "Billable"], rows)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# --- status ---
|
|
452
|
+
|
|
453
|
+
desc "status", "Show hours summary for today, this week, and this month"
|
|
454
|
+
def status
|
|
455
|
+
Auth.valid_access_token
|
|
456
|
+
today = Date.today
|
|
457
|
+
week_start = today - ((today.wday - 1) % 7)
|
|
458
|
+
month_start = Date.new(today.year, today.month, 1)
|
|
459
|
+
|
|
460
|
+
entries = Spinner.spin("Fetching time entries") do
|
|
461
|
+
Api.fetch_time_entries(started_from: month_start.to_s, started_to: today.to_s)
|
|
462
|
+
end
|
|
463
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
464
|
+
|
|
465
|
+
today_entries = entries.select { |e| e["started_at"] == today.to_s }
|
|
466
|
+
week_entries = entries.select { |e| d = e["started_at"]; d && d >= week_start.to_s && d <= today.to_s }
|
|
467
|
+
month_entries = entries
|
|
468
|
+
|
|
469
|
+
if options[:format] == "json"
|
|
470
|
+
puts JSON.pretty_generate({
|
|
471
|
+
"today" => build_status_data(today.to_s, today.to_s, today_entries, maps),
|
|
472
|
+
"this_week" => build_status_data(week_start.to_s, today.to_s, week_entries, maps),
|
|
473
|
+
"this_month" => build_status_data(month_start.to_s, today.to_s, month_entries, maps)
|
|
474
|
+
})
|
|
475
|
+
return
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
print_status_section("Today (#{today})", today_entries, maps)
|
|
479
|
+
print_status_section("This Week (#{week_start} to #{today})", week_entries, maps)
|
|
480
|
+
print_status_section("This Month (#{month_start} to #{today})", month_entries, maps)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# --- delete ---
|
|
484
|
+
|
|
485
|
+
desc "delete", "Delete a time entry"
|
|
486
|
+
method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
|
|
487
|
+
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
488
|
+
def delete
|
|
489
|
+
Auth.valid_access_token
|
|
490
|
+
|
|
491
|
+
if options[:id]
|
|
492
|
+
entry_id = options[:id]
|
|
493
|
+
else
|
|
494
|
+
abort("Missing required flag: --id") unless interactive?
|
|
495
|
+
entry_id = pick_entry_interactive("delete")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
unless options[:yes]
|
|
499
|
+
print "Delete entry #{entry_id}? (y/N): "
|
|
500
|
+
answer = $stdin.gets&.strip&.downcase
|
|
501
|
+
abort("Cancelled.") unless answer == "y"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
Spinner.spin("Deleting time entry") { Api.delete_time_entry(entry_id) }
|
|
505
|
+
|
|
506
|
+
if options[:format] == "json"
|
|
507
|
+
puts JSON.pretty_generate({ "id" => entry_id, "deleted" => true })
|
|
508
|
+
else
|
|
509
|
+
puts "Time entry #{entry_id} deleted."
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# --- edit ---
|
|
514
|
+
|
|
515
|
+
desc "edit", "Edit a time entry"
|
|
516
|
+
method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
|
|
517
|
+
method_option :duration, type: :numeric, desc: "New duration in hours"
|
|
518
|
+
method_option :note, type: :string, desc: "New note"
|
|
519
|
+
method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
|
|
520
|
+
method_option :client, type: :string, desc: "New client name"
|
|
521
|
+
method_option :project, type: :string, desc: "New project name"
|
|
522
|
+
method_option :service, type: :string, desc: "New service name"
|
|
523
|
+
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
524
|
+
def edit
|
|
525
|
+
Auth.valid_access_token
|
|
526
|
+
|
|
527
|
+
if options[:id]
|
|
528
|
+
entry_id = options[:id]
|
|
529
|
+
else
|
|
530
|
+
abort("Missing required flag: --id") unless interactive?
|
|
531
|
+
entry_id = pick_entry_interactive("edit")
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
entry = Spinner.spin("Fetching time entry") { Api.fetch_time_entry(entry_id) }
|
|
535
|
+
abort("Time entry not found.") unless entry
|
|
536
|
+
|
|
537
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
538
|
+
has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service]
|
|
539
|
+
scripted = has_edit_flags || !interactive?
|
|
540
|
+
|
|
541
|
+
fields = build_edit_fields(entry, maps, scripted)
|
|
542
|
+
|
|
543
|
+
current_client = maps[:clients][entry["client_id"].to_s] || entry["client_id"].to_s
|
|
544
|
+
current_project = maps[:projects][entry["project_id"].to_s] || "-"
|
|
545
|
+
current_hours = (entry["duration"].to_i / 3600.0).round(2)
|
|
546
|
+
new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
|
|
547
|
+
|
|
548
|
+
unless options[:format] == "json"
|
|
549
|
+
puts "\n--- Edit Summary ---"
|
|
550
|
+
puts " Date: #{fields["started_at"] || entry["started_at"]}"
|
|
551
|
+
puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
|
|
552
|
+
puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
|
|
553
|
+
puts " Duration: #{new_hours}h"
|
|
554
|
+
puts " Note: #{fields["note"] || entry["note"]}"
|
|
555
|
+
puts "--------------------\n\n"
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
unless options[:yes]
|
|
559
|
+
print "Save changes? (Y/n): "
|
|
560
|
+
answer = $stdin.gets&.strip&.downcase
|
|
561
|
+
abort("Cancelled.") if answer == "n"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
result = Spinner.spin("Updating time entry") { Api.update_time_entry(entry_id, fields) }
|
|
565
|
+
|
|
566
|
+
if options[:format] == "json"
|
|
567
|
+
puts JSON.pretty_generate(result)
|
|
568
|
+
else
|
|
569
|
+
puts "Time entry #{entry_id} updated."
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# --- cache ---
|
|
574
|
+
|
|
575
|
+
desc "cache SUBCOMMAND", "Manage cached data (refresh, clear, status)"
|
|
576
|
+
def cache(subcommand = "status")
|
|
577
|
+
case subcommand
|
|
578
|
+
when "refresh"
|
|
579
|
+
Auth.valid_access_token
|
|
580
|
+
Spinner.spin("Refreshing cache") do
|
|
581
|
+
Api.fetch_clients(force: true)
|
|
582
|
+
Api.fetch_projects(force: true)
|
|
583
|
+
Api.fetch_services(force: true)
|
|
584
|
+
Api.build_name_maps
|
|
585
|
+
end
|
|
586
|
+
puts "Cache refreshed."
|
|
587
|
+
when "clear"
|
|
588
|
+
if File.exist?(Auth.cache_path)
|
|
589
|
+
File.delete(Auth.cache_path)
|
|
590
|
+
puts "Cache cleared."
|
|
591
|
+
else
|
|
592
|
+
puts "No cache file found."
|
|
593
|
+
end
|
|
594
|
+
when "status"
|
|
595
|
+
cache = Auth.load_cache
|
|
596
|
+
if options[:format] == "json"
|
|
597
|
+
if cache["updated_at"]
|
|
598
|
+
age = Time.now.to_i - cache["updated_at"]
|
|
599
|
+
puts JSON.pretty_generate({
|
|
600
|
+
"updated_at" => cache["updated_at"],
|
|
601
|
+
"age_seconds" => age,
|
|
602
|
+
"fresh" => age < 600,
|
|
603
|
+
"clients" => (cache["clients_data"] || []).length,
|
|
604
|
+
"projects" => (cache["projects_data"] || []).length,
|
|
605
|
+
"services" => (cache["services"] || cache["services_data"] || {}).length
|
|
606
|
+
})
|
|
607
|
+
else
|
|
608
|
+
puts JSON.pretty_generate({ "fresh" => false, "clients" => 0, "projects" => 0, "services" => 0 })
|
|
609
|
+
end
|
|
610
|
+
return
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
if cache["updated_at"]
|
|
614
|
+
age = Time.now.to_i - cache["updated_at"]
|
|
615
|
+
updated = Time.at(cache["updated_at"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
616
|
+
fresh = age < 600
|
|
617
|
+
puts "Cache updated: #{updated}"
|
|
618
|
+
puts "Age: #{age}s (#{fresh ? "fresh" : "stale"})"
|
|
619
|
+
puts "Clients: #{(cache["clients_data"] || []).length}"
|
|
620
|
+
puts "Projects: #{(cache["projects_data"] || []).length}"
|
|
621
|
+
puts "Services: #{(cache["services"] || cache["services_data"] || {}).length}"
|
|
622
|
+
else
|
|
623
|
+
puts "No cache data."
|
|
624
|
+
end
|
|
625
|
+
else
|
|
626
|
+
abort("Unknown cache subcommand: #{subcommand}. Use: refresh, clear, status")
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# --- help ---
|
|
631
|
+
|
|
632
|
+
desc "help [COMMAND]", "Describe available commands or one specific command"
|
|
633
|
+
def help(command = nil)
|
|
634
|
+
if options[:format] == "json"
|
|
635
|
+
puts JSON.pretty_generate(help_json)
|
|
636
|
+
return
|
|
637
|
+
end
|
|
638
|
+
super
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
private
|
|
642
|
+
|
|
643
|
+
def interactive?
|
|
644
|
+
return false if options[:no_interactive]
|
|
645
|
+
return true if options[:interactive]
|
|
646
|
+
$stdin.tty?
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def select_client(defaults)
|
|
650
|
+
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
651
|
+
|
|
652
|
+
if options[:client]
|
|
653
|
+
match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
|
|
654
|
+
abort("Client not found: #{options[:client]}") unless match
|
|
655
|
+
return match
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
abort("No clients found.") if clients.empty?
|
|
659
|
+
|
|
660
|
+
unless interactive?
|
|
661
|
+
# Non-interactive: auto-select if single, abort with list if multiple
|
|
662
|
+
default_client = clients.find { |c| c["id"].to_i == defaults["client_id"].to_i }
|
|
663
|
+
return default_client if default_client
|
|
664
|
+
return clients.first if clients.length == 1
|
|
665
|
+
names = clients.map { |c| display_name(c) }.join(", ")
|
|
666
|
+
abort("Multiple clients found. Use --client to specify: #{names}")
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
puts "\nClients:\n\n"
|
|
670
|
+
clients.each_with_index do |c, i|
|
|
671
|
+
name = display_name(c)
|
|
672
|
+
default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
|
|
673
|
+
puts " #{i + 1}. #{name}#{default_marker}"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
|
|
677
|
+
prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
|
|
678
|
+
print prompt
|
|
679
|
+
input = $stdin.gets&.strip
|
|
680
|
+
|
|
681
|
+
idx = if input.nil? || input.empty?
|
|
682
|
+
default_idx || 0
|
|
683
|
+
else
|
|
684
|
+
input.to_i - 1
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
abort("Invalid selection.") if idx < 0 || idx >= clients.length
|
|
688
|
+
clients[idx]
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def select_project(client_id, defaults)
|
|
692
|
+
projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
|
|
693
|
+
|
|
694
|
+
if options[:project]
|
|
695
|
+
match = projects.find { |p| p["title"].downcase == options[:project].downcase }
|
|
696
|
+
abort("Project not found: #{options[:project]}") unless match
|
|
697
|
+
return match
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
return nil if projects.empty?
|
|
701
|
+
|
|
702
|
+
unless interactive?
|
|
703
|
+
# Non-interactive: auto-select if single, return nil if multiple (optional)
|
|
704
|
+
default_project = projects.find { |p| p["id"].to_i == defaults["project_id"].to_i }
|
|
705
|
+
return default_project if default_project
|
|
706
|
+
return projects.first if projects.length == 1
|
|
707
|
+
return nil
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
puts "\nProjects:\n\n"
|
|
711
|
+
projects.each_with_index do |p, i|
|
|
712
|
+
default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
|
|
713
|
+
puts " #{i + 1}. #{p["title"]}#{default_marker}"
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
|
|
717
|
+
prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
|
|
718
|
+
print prompt
|
|
719
|
+
input = $stdin.gets&.strip
|
|
720
|
+
|
|
721
|
+
if input.nil? || input.empty?
|
|
722
|
+
return default_idx ? projects[default_idx] : nil
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
idx = input.to_i - 1
|
|
726
|
+
return nil if idx < 0 || idx >= projects.length
|
|
727
|
+
projects[idx]
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def select_service(defaults, project = nil)
|
|
731
|
+
# Use project-scoped services if available, fall back to global
|
|
732
|
+
services = if project && project["services"] && !project["services"].empty?
|
|
733
|
+
project["services"]
|
|
734
|
+
else
|
|
735
|
+
Spinner.spin("Fetching services") { Api.fetch_services }
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
if options[:service]
|
|
739
|
+
match = services.find { |s| s["name"].downcase == options[:service].downcase }
|
|
740
|
+
abort("Service not found: #{options[:service]}") unless match
|
|
741
|
+
return match
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
unless interactive?
|
|
745
|
+
# Non-interactive: auto-select if single, use default if set, otherwise skip
|
|
746
|
+
default_service = services.find { |s| s["id"].to_i == defaults["service_id"].to_i }
|
|
747
|
+
return default_service if default_service
|
|
748
|
+
return services.first if services.length == 1
|
|
749
|
+
return nil
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
return nil if services.empty?
|
|
753
|
+
|
|
754
|
+
puts "\nServices:\n\n"
|
|
755
|
+
services.each_with_index do |s, i|
|
|
756
|
+
default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
|
|
757
|
+
puts " #{i + 1}. #{s["name"]}#{default_marker}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
|
|
761
|
+
prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
|
|
762
|
+
print prompt
|
|
763
|
+
input = $stdin.gets&.strip
|
|
764
|
+
|
|
765
|
+
if input.nil? || input.empty?
|
|
766
|
+
return default_idx ? services[default_idx] : nil
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
idx = input.to_i - 1
|
|
770
|
+
return nil if idx < 0 || idx >= services.length
|
|
771
|
+
services[idx]
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def pick_date
|
|
775
|
+
return options[:date] if options[:date]
|
|
776
|
+
|
|
777
|
+
today = Date.today.to_s
|
|
778
|
+
return today unless interactive?
|
|
779
|
+
|
|
780
|
+
print "\nDate [#{today}]: "
|
|
781
|
+
input = $stdin.gets&.strip
|
|
782
|
+
(input.nil? || input.empty?) ? today : input
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def pick_duration
|
|
786
|
+
return options[:duration] if options[:duration]
|
|
787
|
+
|
|
788
|
+
abort("Missing required flag: --duration") unless interactive?
|
|
789
|
+
|
|
790
|
+
print "\nDuration (hours): "
|
|
791
|
+
input = $stdin.gets&.strip
|
|
792
|
+
abort("Duration is required.") if input.nil? || input.empty?
|
|
793
|
+
input.to_f
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def pick_note
|
|
797
|
+
return options[:note] if options[:note]
|
|
798
|
+
|
|
799
|
+
abort("Missing required flag: --note") unless interactive?
|
|
800
|
+
|
|
801
|
+
print "\nNote: "
|
|
802
|
+
input = $stdin.gets&.strip
|
|
803
|
+
abort("Note is required.") if input.nil? || input.empty?
|
|
804
|
+
input
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def print_table(headers, rows, wrap_col: nil)
|
|
808
|
+
widths = headers.each_with_index.map do |h, i|
|
|
809
|
+
[h.length, *rows.map { |r| r[i].to_s.length }].max
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Word-wrap a specific column if it would exceed terminal width
|
|
813
|
+
if wrap_col
|
|
814
|
+
term_width = IO.console&.winsize&.last || ENV["COLUMNS"]&.to_i || 120
|
|
815
|
+
fixed_width = widths.each_with_index.sum { |w, i| i == wrap_col ? 0 : w } + (widths.length - 1) * 2
|
|
816
|
+
max_wrap = term_width - fixed_width
|
|
817
|
+
max_wrap = [max_wrap, 20].max
|
|
818
|
+
widths[wrap_col] = [widths[wrap_col], max_wrap].min
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
fmt = widths.map { |w| "%-#{w}s" }.join(" ")
|
|
822
|
+
puts fmt % headers
|
|
823
|
+
puts widths.map { |w| "-" * w }.join(" ")
|
|
824
|
+
|
|
825
|
+
rows.each do |r|
|
|
826
|
+
if wrap_col && r[wrap_col].to_s.length > widths[wrap_col]
|
|
827
|
+
lines = word_wrap(r[wrap_col].to_s, widths[wrap_col])
|
|
828
|
+
padded = widths.each_with_index.map { |w, i| i == wrap_col ? "" : " " * w }
|
|
829
|
+
pad_fmt = padded.each_with_index.map { |p, i| i == wrap_col ? "%s" : "%-#{widths[i]}s" }.join(" ")
|
|
830
|
+
lines.each_with_index do |line, li|
|
|
831
|
+
if li == 0
|
|
832
|
+
row = r.dup
|
|
833
|
+
row[wrap_col] = line
|
|
834
|
+
puts fmt % row
|
|
835
|
+
else
|
|
836
|
+
blank = padded.dup
|
|
837
|
+
blank[wrap_col] = line
|
|
838
|
+
puts pad_fmt % blank
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
else
|
|
842
|
+
puts fmt % r
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def word_wrap(text, width)
|
|
848
|
+
lines = []
|
|
849
|
+
remaining = text
|
|
850
|
+
while remaining.length > width
|
|
851
|
+
break_at = remaining.rindex(" ", width) || width
|
|
852
|
+
lines << remaining[0...break_at]
|
|
853
|
+
remaining = remaining[break_at..].lstrip
|
|
854
|
+
end
|
|
855
|
+
lines << remaining unless remaining.empty?
|
|
856
|
+
lines
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def print_status_section(title, entries, maps)
|
|
860
|
+
puts "\n#{title}"
|
|
861
|
+
if entries.empty?
|
|
862
|
+
puts " No entries."
|
|
863
|
+
return
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
grouped = {}
|
|
867
|
+
entries.each do |e|
|
|
868
|
+
client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
|
|
869
|
+
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
870
|
+
key = "#{client} / #{project}"
|
|
871
|
+
grouped[key] ||= 0.0
|
|
872
|
+
grouped[key] += e["duration"].to_i / 3600.0
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
grouped.each do |key, hours|
|
|
876
|
+
puts " #{key}: #{hours.round(2)}h"
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
880
|
+
puts " Total: #{total.round(2)}h"
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def build_status_data(from, to, entries, maps)
|
|
884
|
+
entry_data = entries.map do |e|
|
|
885
|
+
{
|
|
886
|
+
"id" => e["id"],
|
|
887
|
+
"client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
|
|
888
|
+
"project" => maps[:projects][e["project_id"].to_s] || "-",
|
|
889
|
+
"duration" => e["duration"],
|
|
890
|
+
"hours" => (e["duration"].to_i / 3600.0).round(2),
|
|
891
|
+
"note" => e["note"],
|
|
892
|
+
"started_at" => e["started_at"]
|
|
893
|
+
}
|
|
894
|
+
end
|
|
895
|
+
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
896
|
+
{ "from" => from, "to" => to, "entries" => entry_data, "total_hours" => total.round(2) }
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def pick_entry_interactive(action)
|
|
900
|
+
today = Date.today.to_s
|
|
901
|
+
entries = Spinner.spin("Fetching today's entries") do
|
|
902
|
+
Api.fetch_time_entries(started_from: today, started_to: today)
|
|
903
|
+
end
|
|
904
|
+
abort("No entries found for today.") if entries.empty?
|
|
905
|
+
|
|
906
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
907
|
+
|
|
908
|
+
puts "\nToday's entries:\n\n"
|
|
909
|
+
entries.each_with_index do |e, i|
|
|
910
|
+
client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
|
|
911
|
+
hours = (e["duration"].to_i / 3600.0).round(2)
|
|
912
|
+
note = (e["note"] || "").slice(0, 40)
|
|
913
|
+
puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
print "\nSelect entry to #{action} (1-#{entries.length}): "
|
|
917
|
+
input = $stdin.gets&.strip
|
|
918
|
+
abort("Cancelled.") if input.nil? || input.empty?
|
|
919
|
+
|
|
920
|
+
idx = input.to_i - 1
|
|
921
|
+
abort("Invalid selection.") if idx < 0 || idx >= entries.length
|
|
922
|
+
entries[idx]["id"]
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
def build_edit_fields(entry, maps, scripted)
|
|
926
|
+
# FreshBooks API replaces the entry — always include all current fields
|
|
927
|
+
fields = {
|
|
928
|
+
"started_at" => entry["started_at"],
|
|
929
|
+
"is_logged" => entry["is_logged"] || true,
|
|
930
|
+
"duration" => entry["duration"],
|
|
931
|
+
"note" => entry["note"],
|
|
932
|
+
"client_id" => entry["client_id"],
|
|
933
|
+
"project_id" => entry["project_id"],
|
|
934
|
+
"service_id" => entry["service_id"]
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if scripted
|
|
938
|
+
fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
|
|
939
|
+
fields["note"] = options[:note] if options[:note]
|
|
940
|
+
fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
|
|
941
|
+
|
|
942
|
+
if options[:client]
|
|
943
|
+
client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
|
|
944
|
+
abort("Client not found: #{options[:client]}") unless client_id
|
|
945
|
+
fields["client_id"] = client_id.to_i
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
if options[:project]
|
|
949
|
+
project_id = maps[:projects].find { |_id, name| name.downcase == options[:project].downcase }&.first
|
|
950
|
+
abort("Project not found: #{options[:project]}") unless project_id
|
|
951
|
+
fields["project_id"] = project_id.to_i
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
if options[:service]
|
|
955
|
+
service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
|
|
956
|
+
abort("Service not found: #{options[:service]}") unless service_id
|
|
957
|
+
fields["service_id"] = service_id.to_i
|
|
958
|
+
end
|
|
959
|
+
else
|
|
960
|
+
current_hours = (entry["duration"].to_i / 3600.0).round(2)
|
|
961
|
+
print "\nDuration (hours) [#{current_hours}]: "
|
|
962
|
+
input = $stdin.gets&.strip
|
|
963
|
+
fields["duration"] = (input.to_f * 3600).to_i unless input.nil? || input.empty?
|
|
964
|
+
|
|
965
|
+
current_note = entry["note"] || ""
|
|
966
|
+
print "Note [#{current_note}]: "
|
|
967
|
+
input = $stdin.gets&.strip
|
|
968
|
+
fields["note"] = input unless input.nil? || input.empty?
|
|
969
|
+
|
|
970
|
+
current_date = entry["started_at"] || ""
|
|
971
|
+
print "Date [#{current_date}]: "
|
|
972
|
+
input = $stdin.gets&.strip
|
|
973
|
+
fields["started_at"] = input unless input.nil? || input.empty?
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
fields
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def normalize_datetime(date_str)
|
|
980
|
+
return date_str if date_str.include?("T")
|
|
981
|
+
"#{date_str}T00:00:00Z"
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def display_name(client)
|
|
985
|
+
name = client["organization"]
|
|
986
|
+
(name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def help_json
|
|
990
|
+
{
|
|
991
|
+
name: "fb",
|
|
992
|
+
description: "FreshBooks time tracking CLI",
|
|
993
|
+
required_scopes: Auth::REQUIRED_SCOPES,
|
|
994
|
+
global_flags: {
|
|
995
|
+
"--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
|
|
996
|
+
"--format json" => "Output format: json (available on all commands)",
|
|
997
|
+
"--dry-run" => "Simulate command without making network calls (writes skipped)"
|
|
998
|
+
},
|
|
999
|
+
commands: {
|
|
1000
|
+
auth: {
|
|
1001
|
+
description: "Authenticate with FreshBooks via OAuth2",
|
|
1002
|
+
usage: "fb auth [SUBCOMMAND]",
|
|
1003
|
+
interactive: "Interactive when no subcommand; subcommands are non-interactive",
|
|
1004
|
+
subcommands: {
|
|
1005
|
+
"setup" => "Save OAuth credentials from env vars: FRESHBOOKS_CLIENT_ID, FRESHBOOKS_CLIENT_SECRET (or ~/.fb/.env)",
|
|
1006
|
+
"url" => "Print the OAuth authorization URL",
|
|
1007
|
+
"callback" => "Exchange OAuth code: fb auth callback REDIRECT_URL",
|
|
1008
|
+
"status" => "Show current auth state (config, tokens, business)"
|
|
1009
|
+
},
|
|
1010
|
+
flags: {}
|
|
1011
|
+
},
|
|
1012
|
+
business: {
|
|
1013
|
+
description: "List or select a business",
|
|
1014
|
+
usage: "fb business [--select ID]",
|
|
1015
|
+
interactive: "Interactive with --select (no value); non-interactive with --select ID",
|
|
1016
|
+
flags: {
|
|
1017
|
+
"--select ID" => "Set active business by ID",
|
|
1018
|
+
"--select" => "Interactive business picker (no value)"
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
log: {
|
|
1022
|
+
description: "Log a time entry",
|
|
1023
|
+
usage: "fb log",
|
|
1024
|
+
interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
|
|
1025
|
+
flags: {
|
|
1026
|
+
"--client" => "Client name (required non-interactive if multiple clients, auto-selected if single)",
|
|
1027
|
+
"--project" => "Project name (optional, auto-selected if single)",
|
|
1028
|
+
"--service" => "Service name (optional)",
|
|
1029
|
+
"--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
|
|
1030
|
+
"--note" => "Work description (required non-interactive)",
|
|
1031
|
+
"--date" => "Date YYYY-MM-DD (defaults to today)",
|
|
1032
|
+
"--yes" => "Skip confirmation prompt"
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
entries: {
|
|
1036
|
+
description: "List time entries (defaults to current month)",
|
|
1037
|
+
usage: "fb entries",
|
|
1038
|
+
flags: {
|
|
1039
|
+
"--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
|
|
1040
|
+
"--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
|
|
1041
|
+
"--month" => "Month (1-12, defaults to current)",
|
|
1042
|
+
"--year" => "Year (defaults to current)"
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
clients: {
|
|
1046
|
+
description: "List all clients",
|
|
1047
|
+
usage: "fb clients"
|
|
1048
|
+
},
|
|
1049
|
+
projects: {
|
|
1050
|
+
description: "List all projects",
|
|
1051
|
+
usage: "fb projects",
|
|
1052
|
+
flags: {
|
|
1053
|
+
"--client" => "Filter by client name"
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
services: {
|
|
1057
|
+
description: "List all services",
|
|
1058
|
+
usage: "fb services"
|
|
1059
|
+
},
|
|
1060
|
+
status: {
|
|
1061
|
+
description: "Show hours summary for today, this week, and this month",
|
|
1062
|
+
usage: "fb status"
|
|
1063
|
+
},
|
|
1064
|
+
delete: {
|
|
1065
|
+
description: "Delete a time entry",
|
|
1066
|
+
usage: "fb delete --id ID --yes",
|
|
1067
|
+
interactive: "Interactive picker when no --id; requires --id when non-interactive",
|
|
1068
|
+
flags: {
|
|
1069
|
+
"--id" => "Time entry ID (required non-interactive)",
|
|
1070
|
+
"--yes" => "Skip confirmation prompt"
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
edit: {
|
|
1074
|
+
description: "Edit a time entry",
|
|
1075
|
+
usage: "fb edit --id ID [--duration H] [--note TEXT] --yes",
|
|
1076
|
+
interactive: "Interactive picker and field editor when no --id; requires --id when non-interactive",
|
|
1077
|
+
flags: {
|
|
1078
|
+
"--id" => "Time entry ID (required non-interactive)",
|
|
1079
|
+
"--duration" => "New duration in hours",
|
|
1080
|
+
"--note" => "New note",
|
|
1081
|
+
"--date" => "New date (YYYY-MM-DD)",
|
|
1082
|
+
"--client" => "New client name",
|
|
1083
|
+
"--project" => "New project name",
|
|
1084
|
+
"--service" => "New service name",
|
|
1085
|
+
"--yes" => "Skip confirmation prompt"
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
cache: {
|
|
1089
|
+
description: "Manage cached data",
|
|
1090
|
+
usage: "fb cache [refresh|clear|status]",
|
|
1091
|
+
subcommands: {
|
|
1092
|
+
"refresh" => "Force-refresh all cached data",
|
|
1093
|
+
"clear" => "Delete cache file",
|
|
1094
|
+
"status" => "Show cache age and staleness"
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
help: {
|
|
1098
|
+
description: "Show help information",
|
|
1099
|
+
usage: "fb help [COMMAND]"
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
end
|
|
1104
|
+
end # closes class Commands
|
|
1105
|
+
end # closes module CLI
|
|
1106
|
+
end # closes module FreshBooks
|