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