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.
data/lib/fb/cli.rb DELETED
@@ -1,1060 +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["started_at"] || "?"
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
- puts "\nTotal: #{total.round(2)}h"
315
- end
316
-
317
- # --- clients ---
318
-
319
- desc "clients", "List all clients"
320
- def clients
321
- Auth.valid_access_token
322
- clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
323
-
324
- if clients.empty?
325
- puts "No clients found."
326
- return
327
- end
328
-
329
- if options[:format] == "json"
330
- puts JSON.pretty_generate(clients)
331
- return
332
- end
333
-
334
- rows = clients.map do |c|
335
- name = c["organization"]
336
- name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
337
- email = c["email"] || "-"
338
- org = c["organization"] || "-"
339
- [name, email, org]
340
- end
341
-
342
- print_table(["Name", "Email", "Organization"], rows)
343
- end
344
-
345
- # --- projects ---
346
-
347
- desc "projects", "List all projects"
348
- method_option :client, type: :string, desc: "Filter by client name"
349
- def projects
350
- Auth.valid_access_token
351
- maps = Spinner.spin("Resolving names") { Api.build_name_maps }
352
-
353
- projects = if options[:client]
354
- client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
355
- abort("Client not found: #{options[:client]}") unless client_id
356
- Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
357
- else
358
- Spinner.spin("Fetching projects") { Api.fetch_projects }
359
- end
360
-
361
- if projects.empty?
362
- puts "No projects found."
363
- return
364
- end
365
-
366
- if options[:format] == "json"
367
- puts JSON.pretty_generate(projects)
368
- return
369
- end
370
-
371
- rows = projects.map do |p|
372
- client_name = maps[:clients][p["client_id"].to_s] || "-"
373
- [p["title"], client_name, p["active"] ? "active" : "inactive"]
374
- end
375
-
376
- print_table(["Title", "Client", "Status"], rows)
377
- end
378
-
379
- # --- services ---
380
-
381
- desc "services", "List all services"
382
- def services
383
- Auth.valid_access_token
384
- services = Spinner.spin("Fetching services") { Api.fetch_services }
385
-
386
- if services.empty?
387
- puts "No services found."
388
- return
389
- end
390
-
391
- if options[:format] == "json"
392
- puts JSON.pretty_generate(services)
393
- return
394
- end
395
-
396
- rows = services.map do |s|
397
- billable = s["billable"] ? "yes" : "no"
398
- [s["name"], billable]
399
- end
400
-
401
- print_table(["Name", "Billable"], rows)
402
- end
403
-
404
- # --- status ---
405
-
406
- desc "status", "Show hours summary for today, this week, and this month"
407
- def status
408
- Auth.valid_access_token
409
- today = Date.today
410
- week_start = today - ((today.wday - 1) % 7)
411
- month_start = Date.new(today.year, today.month, 1)
412
-
413
- entries = Spinner.spin("Fetching time entries") do
414
- Api.fetch_time_entries(started_from: month_start.to_s, started_to: today.to_s)
415
- end
416
- maps = Spinner.spin("Resolving names") { Api.build_name_maps }
417
-
418
- today_entries = entries.select { |e| e["started_at"] == today.to_s }
419
- week_entries = entries.select { |e| d = e["started_at"]; d && d >= week_start.to_s && d <= today.to_s }
420
- month_entries = entries
421
-
422
- if options[:format] == "json"
423
- puts JSON.pretty_generate({
424
- "today" => build_status_data(today.to_s, today.to_s, today_entries, maps),
425
- "this_week" => build_status_data(week_start.to_s, today.to_s, week_entries, maps),
426
- "this_month" => build_status_data(month_start.to_s, today.to_s, month_entries, maps)
427
- })
428
- return
429
- end
430
-
431
- print_status_section("Today (#{today})", today_entries, maps)
432
- print_status_section("This Week (#{week_start} to #{today})", week_entries, maps)
433
- print_status_section("This Month (#{month_start} to #{today})", month_entries, maps)
434
- end
435
-
436
- # --- delete ---
437
-
438
- desc "delete", "Delete a time entry"
439
- method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
440
- method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
441
- def delete
442
- Auth.valid_access_token
443
-
444
- if options[:id]
445
- entry_id = options[:id]
446
- else
447
- abort("Missing required flag: --id") unless interactive?
448
- entry_id = pick_entry_interactive("delete")
449
- end
450
-
451
- unless options[:yes]
452
- print "Delete entry #{entry_id}? (y/N): "
453
- answer = $stdin.gets&.strip&.downcase
454
- abort("Cancelled.") unless answer == "y"
455
- end
456
-
457
- Spinner.spin("Deleting time entry") { Api.delete_time_entry(entry_id) }
458
-
459
- if options[:format] == "json"
460
- puts JSON.pretty_generate({ "id" => entry_id, "deleted" => true })
461
- else
462
- puts "Time entry #{entry_id} deleted."
463
- end
464
- end
465
-
466
- # --- edit ---
467
-
468
- desc "edit", "Edit a time entry"
469
- method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
470
- method_option :duration, type: :numeric, desc: "New duration in hours"
471
- method_option :note, type: :string, desc: "New note"
472
- method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
473
- method_option :client, type: :string, desc: "New client name"
474
- method_option :project, type: :string, desc: "New project name"
475
- method_option :service, type: :string, desc: "New service name"
476
- method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
477
- def edit
478
- Auth.valid_access_token
479
-
480
- if options[:id]
481
- entry_id = options[:id]
482
- else
483
- abort("Missing required flag: --id") unless interactive?
484
- entry_id = pick_entry_interactive("edit")
485
- end
486
-
487
- entry = Spinner.spin("Fetching time entry") { Api.fetch_time_entry(entry_id) }
488
- abort("Time entry not found.") unless entry
489
-
490
- maps = Spinner.spin("Resolving names") { Api.build_name_maps }
491
- has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service]
492
- scripted = has_edit_flags || !interactive?
493
-
494
- fields = build_edit_fields(entry, maps, scripted)
495
-
496
- current_client = maps[:clients][entry["client_id"].to_s] || entry["client_id"].to_s
497
- current_project = maps[:projects][entry["project_id"].to_s] || "-"
498
- current_hours = (entry["duration"].to_i / 3600.0).round(2)
499
- new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
500
-
501
- unless options[:format] == "json"
502
- puts "\n--- Edit Summary ---"
503
- puts " Date: #{fields["started_at"] || entry["started_at"]}"
504
- puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
505
- puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
506
- puts " Duration: #{new_hours}h"
507
- puts " Note: #{fields["note"] || entry["note"]}"
508
- puts "--------------------\n\n"
509
- end
510
-
511
- unless options[:yes]
512
- print "Save changes? (Y/n): "
513
- answer = $stdin.gets&.strip&.downcase
514
- abort("Cancelled.") if answer == "n"
515
- end
516
-
517
- result = Spinner.spin("Updating time entry") { Api.update_time_entry(entry_id, fields) }
518
-
519
- if options[:format] == "json"
520
- puts JSON.pretty_generate(result)
521
- else
522
- puts "Time entry #{entry_id} updated."
523
- end
524
- end
525
-
526
- # --- cache ---
527
-
528
- desc "cache SUBCOMMAND", "Manage cached data (refresh, clear, status)"
529
- def cache(subcommand = "status")
530
- case subcommand
531
- when "refresh"
532
- Auth.valid_access_token
533
- Spinner.spin("Refreshing cache") do
534
- Api.fetch_clients(force: true)
535
- Api.fetch_projects(force: true)
536
- Api.fetch_services(force: true)
537
- Api.build_name_maps
538
- end
539
- puts "Cache refreshed."
540
- when "clear"
541
- if File.exist?(Auth.cache_path)
542
- File.delete(Auth.cache_path)
543
- puts "Cache cleared."
544
- else
545
- puts "No cache file found."
546
- end
547
- when "status"
548
- cache = Auth.load_cache
549
- if options[:format] == "json"
550
- if cache["updated_at"]
551
- age = Time.now.to_i - cache["updated_at"]
552
- puts JSON.pretty_generate({
553
- "updated_at" => cache["updated_at"],
554
- "age_seconds" => age,
555
- "fresh" => age < 600,
556
- "clients" => (cache["clients_data"] || []).length,
557
- "projects" => (cache["projects_data"] || []).length,
558
- "services" => (cache["services"] || cache["services_data"] || {}).length
559
- })
560
- else
561
- puts JSON.pretty_generate({ "fresh" => false, "clients" => 0, "projects" => 0, "services" => 0 })
562
- end
563
- return
564
- end
565
-
566
- if cache["updated_at"]
567
- age = Time.now.to_i - cache["updated_at"]
568
- updated = Time.at(cache["updated_at"]).strftime("%Y-%m-%d %H:%M:%S")
569
- fresh = age < 600
570
- puts "Cache updated: #{updated}"
571
- puts "Age: #{age}s (#{fresh ? "fresh" : "stale"})"
572
- puts "Clients: #{(cache["clients_data"] || []).length}"
573
- puts "Projects: #{(cache["projects_data"] || []).length}"
574
- puts "Services: #{(cache["services"] || cache["services_data"] || {}).length}"
575
- else
576
- puts "No cache data."
577
- end
578
- else
579
- abort("Unknown cache subcommand: #{subcommand}. Use: refresh, clear, status")
580
- end
581
- end
582
-
583
- # --- help ---
584
-
585
- desc "help [COMMAND]", "Describe available commands or one specific command"
586
- def help(command = nil)
587
- if options[:format] == "json"
588
- puts JSON.pretty_generate(help_json)
589
- return
590
- end
591
- super
592
- end
593
-
594
- private
595
-
596
- def interactive?
597
- return false if options[:no_interactive]
598
- return true if options[:interactive]
599
- $stdin.tty?
600
- end
601
-
602
- def select_client(defaults)
603
- clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
604
-
605
- if options[:client]
606
- match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
607
- abort("Client not found: #{options[:client]}") unless match
608
- return match
609
- end
610
-
611
- abort("No clients found.") if clients.empty?
612
-
613
- unless interactive?
614
- # Non-interactive: auto-select if single, abort with list if multiple
615
- default_client = clients.find { |c| c["id"].to_i == defaults["client_id"].to_i }
616
- return default_client if default_client
617
- return clients.first if clients.length == 1
618
- names = clients.map { |c| display_name(c) }.join(", ")
619
- abort("Multiple clients found. Use --client to specify: #{names}")
620
- end
621
-
622
- puts "\nClients:\n\n"
623
- clients.each_with_index do |c, i|
624
- name = display_name(c)
625
- default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
626
- puts " #{i + 1}. #{name}#{default_marker}"
627
- end
628
-
629
- default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
630
- prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
631
- print prompt
632
- input = $stdin.gets&.strip
633
-
634
- idx = if input.nil? || input.empty?
635
- default_idx || 0
636
- else
637
- input.to_i - 1
638
- end
639
-
640
- abort("Invalid selection.") if idx < 0 || idx >= clients.length
641
- clients[idx]
642
- end
643
-
644
- def select_project(client_id, defaults)
645
- projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
646
-
647
- if options[:project]
648
- match = projects.find { |p| p["title"].downcase == options[:project].downcase }
649
- abort("Project not found: #{options[:project]}") unless match
650
- return match
651
- end
652
-
653
- return nil if projects.empty?
654
-
655
- unless interactive?
656
- # Non-interactive: auto-select if single, return nil if multiple (optional)
657
- default_project = projects.find { |p| p["id"].to_i == defaults["project_id"].to_i }
658
- return default_project if default_project
659
- return projects.first if projects.length == 1
660
- return nil
661
- end
662
-
663
- puts "\nProjects:\n\n"
664
- projects.each_with_index do |p, i|
665
- default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
666
- puts " #{i + 1}. #{p["title"]}#{default_marker}"
667
- end
668
-
669
- default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
670
- prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
671
- print prompt
672
- input = $stdin.gets&.strip
673
-
674
- if input.nil? || input.empty?
675
- return default_idx ? projects[default_idx] : nil
676
- end
677
-
678
- idx = input.to_i - 1
679
- return nil if idx < 0 || idx >= projects.length
680
- projects[idx]
681
- end
682
-
683
- def select_service(defaults, project = nil)
684
- # Use project-scoped services if available, fall back to global
685
- services = if project && project["services"] && !project["services"].empty?
686
- project["services"]
687
- else
688
- Spinner.spin("Fetching services") { Api.fetch_services }
689
- end
690
-
691
- if options[:service]
692
- match = services.find { |s| s["name"].downcase == options[:service].downcase }
693
- abort("Service not found: #{options[:service]}") unless match
694
- return match
695
- end
696
-
697
- unless interactive?
698
- # Non-interactive: auto-select if single, use default if set, otherwise skip
699
- default_service = services.find { |s| s["id"].to_i == defaults["service_id"].to_i }
700
- return default_service if default_service
701
- return services.first if services.length == 1
702
- return nil
703
- end
704
-
705
- return nil if services.empty?
706
-
707
- puts "\nServices:\n\n"
708
- services.each_with_index do |s, i|
709
- default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
710
- puts " #{i + 1}. #{s["name"]}#{default_marker}"
711
- end
712
-
713
- default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
714
- prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
715
- print prompt
716
- input = $stdin.gets&.strip
717
-
718
- if input.nil? || input.empty?
719
- return default_idx ? services[default_idx] : nil
720
- end
721
-
722
- idx = input.to_i - 1
723
- return nil if idx < 0 || idx >= services.length
724
- services[idx]
725
- end
726
-
727
- def pick_date
728
- return options[:date] if options[:date]
729
-
730
- today = Date.today.to_s
731
- return today unless interactive?
732
-
733
- print "\nDate [#{today}]: "
734
- input = $stdin.gets&.strip
735
- (input.nil? || input.empty?) ? today : input
736
- end
737
-
738
- def pick_duration
739
- return options[:duration] if options[:duration]
740
-
741
- abort("Missing required flag: --duration") unless interactive?
742
-
743
- print "\nDuration (hours): "
744
- input = $stdin.gets&.strip
745
- abort("Duration is required.") if input.nil? || input.empty?
746
- input.to_f
747
- end
748
-
749
- def pick_note
750
- return options[:note] if options[:note]
751
-
752
- abort("Missing required flag: --note") unless interactive?
753
-
754
- print "\nNote: "
755
- input = $stdin.gets&.strip
756
- abort("Note is required.") if input.nil? || input.empty?
757
- input
758
- end
759
-
760
- def print_table(headers, rows, wrap_col: nil)
761
- widths = headers.each_with_index.map do |h, i|
762
- [h.length, *rows.map { |r| r[i].to_s.length }].max
763
- end
764
-
765
- # Word-wrap a specific column if it would exceed terminal width
766
- if wrap_col
767
- term_width = IO.console&.winsize&.last || ENV["COLUMNS"]&.to_i || 120
768
- fixed_width = widths.each_with_index.sum { |w, i| i == wrap_col ? 0 : w } + (widths.length - 1) * 2
769
- max_wrap = term_width - fixed_width
770
- max_wrap = [max_wrap, 20].max
771
- widths[wrap_col] = [widths[wrap_col], max_wrap].min
772
- end
773
-
774
- fmt = widths.map { |w| "%-#{w}s" }.join(" ")
775
- puts fmt % headers
776
- puts widths.map { |w| "-" * w }.join(" ")
777
-
778
- rows.each do |r|
779
- if wrap_col && r[wrap_col].to_s.length > widths[wrap_col]
780
- lines = word_wrap(r[wrap_col].to_s, widths[wrap_col])
781
- padded = widths.each_with_index.map { |w, i| i == wrap_col ? "" : " " * w }
782
- pad_fmt = padded.each_with_index.map { |p, i| i == wrap_col ? "%s" : "%-#{widths[i]}s" }.join(" ")
783
- lines.each_with_index do |line, li|
784
- if li == 0
785
- row = r.dup
786
- row[wrap_col] = line
787
- puts fmt % row
788
- else
789
- blank = padded.dup
790
- blank[wrap_col] = line
791
- puts pad_fmt % blank
792
- end
793
- end
794
- else
795
- puts fmt % r
796
- end
797
- end
798
- end
799
-
800
- def word_wrap(text, width)
801
- lines = []
802
- remaining = text
803
- while remaining.length > width
804
- break_at = remaining.rindex(" ", width) || width
805
- lines << remaining[0...break_at]
806
- remaining = remaining[break_at..].lstrip
807
- end
808
- lines << remaining unless remaining.empty?
809
- lines
810
- end
811
-
812
- def print_status_section(title, entries, maps)
813
- puts "\n#{title}"
814
- if entries.empty?
815
- puts " No entries."
816
- return
817
- end
818
-
819
- grouped = {}
820
- entries.each do |e|
821
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
822
- project = maps[:projects][e["project_id"].to_s] || "-"
823
- key = "#{client} / #{project}"
824
- grouped[key] ||= 0.0
825
- grouped[key] += e["duration"].to_i / 3600.0
826
- end
827
-
828
- grouped.each do |key, hours|
829
- puts " #{key}: #{hours.round(2)}h"
830
- end
831
-
832
- total = entries.sum { |e| e["duration"].to_i } / 3600.0
833
- puts " Total: #{total.round(2)}h"
834
- end
835
-
836
- def build_status_data(from, to, entries, maps)
837
- entry_data = entries.map do |e|
838
- {
839
- "id" => e["id"],
840
- "client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
841
- "project" => maps[:projects][e["project_id"].to_s] || "-",
842
- "duration" => e["duration"],
843
- "hours" => (e["duration"].to_i / 3600.0).round(2),
844
- "note" => e["note"],
845
- "started_at" => e["started_at"]
846
- }
847
- end
848
- total = entries.sum { |e| e["duration"].to_i } / 3600.0
849
- { "from" => from, "to" => to, "entries" => entry_data, "total_hours" => total.round(2) }
850
- end
851
-
852
- def pick_entry_interactive(action)
853
- today = Date.today.to_s
854
- entries = Spinner.spin("Fetching today's entries") do
855
- Api.fetch_time_entries(started_from: today, started_to: today)
856
- end
857
- abort("No entries found for today.") if entries.empty?
858
-
859
- maps = Spinner.spin("Resolving names") { Api.build_name_maps }
860
-
861
- puts "\nToday's entries:\n\n"
862
- entries.each_with_index do |e, i|
863
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
864
- hours = (e["duration"].to_i / 3600.0).round(2)
865
- note = (e["note"] || "").slice(0, 40)
866
- puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
867
- end
868
-
869
- print "\nSelect entry to #{action} (1-#{entries.length}): "
870
- input = $stdin.gets&.strip
871
- abort("Cancelled.") if input.nil? || input.empty?
872
-
873
- idx = input.to_i - 1
874
- abort("Invalid selection.") if idx < 0 || idx >= entries.length
875
- entries[idx]["id"]
876
- end
877
-
878
- def build_edit_fields(entry, maps, scripted)
879
- # FreshBooks API replaces the entry — always include all current fields
880
- fields = {
881
- "started_at" => entry["started_at"],
882
- "is_logged" => entry["is_logged"] || true,
883
- "duration" => entry["duration"],
884
- "note" => entry["note"],
885
- "client_id" => entry["client_id"],
886
- "project_id" => entry["project_id"],
887
- "service_id" => entry["service_id"]
888
- }
889
-
890
- if scripted
891
- fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
892
- fields["note"] = options[:note] if options[:note]
893
- fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
894
-
895
- if options[:client]
896
- client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
897
- abort("Client not found: #{options[:client]}") unless client_id
898
- fields["client_id"] = client_id.to_i
899
- end
900
-
901
- if options[:project]
902
- project_id = maps[:projects].find { |_id, name| name.downcase == options[:project].downcase }&.first
903
- abort("Project not found: #{options[:project]}") unless project_id
904
- fields["project_id"] = project_id.to_i
905
- end
906
-
907
- if options[:service]
908
- service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
909
- abort("Service not found: #{options[:service]}") unless service_id
910
- fields["service_id"] = service_id.to_i
911
- end
912
- else
913
- current_hours = (entry["duration"].to_i / 3600.0).round(2)
914
- print "\nDuration (hours) [#{current_hours}]: "
915
- input = $stdin.gets&.strip
916
- fields["duration"] = (input.to_f * 3600).to_i unless input.nil? || input.empty?
917
-
918
- current_note = entry["note"] || ""
919
- print "Note [#{current_note}]: "
920
- input = $stdin.gets&.strip
921
- fields["note"] = input unless input.nil? || input.empty?
922
-
923
- current_date = entry["started_at"] || ""
924
- print "Date [#{current_date}]: "
925
- input = $stdin.gets&.strip
926
- fields["started_at"] = input unless input.nil? || input.empty?
927
- end
928
-
929
- fields
930
- end
931
-
932
- def normalize_datetime(date_str)
933
- return date_str if date_str.include?("T")
934
- "#{date_str}T00:00:00Z"
935
- end
936
-
937
- def display_name(client)
938
- name = client["organization"]
939
- (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
940
- end
941
-
942
- def help_json
943
- {
944
- name: "fb",
945
- description: "FreshBooks time tracking CLI",
946
- required_scopes: Auth::REQUIRED_SCOPES,
947
- global_flags: {
948
- "--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
949
- "--format json" => "Output format: json (available on all commands)"
950
- },
951
- commands: {
952
- auth: {
953
- description: "Authenticate with FreshBooks via OAuth2",
954
- usage: "fb auth [SUBCOMMAND]",
955
- interactive: "Interactive when no subcommand; subcommands are non-interactive",
956
- subcommands: {
957
- "setup" => "Save OAuth credentials: fb auth setup --client-id ID --client-secret SECRET",
958
- "url" => "Print the OAuth authorization URL",
959
- "callback" => "Exchange OAuth code: fb auth callback REDIRECT_URL",
960
- "status" => "Show current auth state (config, tokens, business)"
961
- },
962
- flags: {
963
- "--client-id" => "OAuth client ID (for setup subcommand)",
964
- "--client-secret" => "OAuth client secret (for setup subcommand)"
965
- }
966
- },
967
- business: {
968
- description: "List or select a business",
969
- usage: "fb business [--select ID]",
970
- interactive: "Interactive with --select (no value); non-interactive with --select ID",
971
- flags: {
972
- "--select ID" => "Set active business by ID",
973
- "--select" => "Interactive business picker (no value)"
974
- }
975
- },
976
- log: {
977
- description: "Log a time entry",
978
- usage: "fb log",
979
- interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
980
- flags: {
981
- "--client" => "Client name (required non-interactive if multiple clients, auto-selected if single)",
982
- "--project" => "Project name (optional, auto-selected if single)",
983
- "--service" => "Service name (optional)",
984
- "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
985
- "--note" => "Work description (required non-interactive)",
986
- "--date" => "Date YYYY-MM-DD (defaults to today)",
987
- "--yes" => "Skip confirmation prompt"
988
- }
989
- },
990
- entries: {
991
- description: "List time entries (defaults to current month)",
992
- usage: "fb entries",
993
- flags: {
994
- "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
995
- "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
996
- "--month" => "Month (1-12, defaults to current)",
997
- "--year" => "Year (defaults to current)"
998
- }
999
- },
1000
- clients: {
1001
- description: "List all clients",
1002
- usage: "fb clients"
1003
- },
1004
- projects: {
1005
- description: "List all projects",
1006
- usage: "fb projects",
1007
- flags: {
1008
- "--client" => "Filter by client name"
1009
- }
1010
- },
1011
- services: {
1012
- description: "List all services",
1013
- usage: "fb services"
1014
- },
1015
- status: {
1016
- description: "Show hours summary for today, this week, and this month",
1017
- usage: "fb status"
1018
- },
1019
- delete: {
1020
- description: "Delete a time entry",
1021
- usage: "fb delete --id ID --yes",
1022
- interactive: "Interactive picker when no --id; requires --id when non-interactive",
1023
- flags: {
1024
- "--id" => "Time entry ID (required non-interactive)",
1025
- "--yes" => "Skip confirmation prompt"
1026
- }
1027
- },
1028
- edit: {
1029
- description: "Edit a time entry",
1030
- usage: "fb edit --id ID [--duration H] [--note TEXT] --yes",
1031
- interactive: "Interactive picker and field editor when no --id; requires --id when non-interactive",
1032
- flags: {
1033
- "--id" => "Time entry ID (required non-interactive)",
1034
- "--duration" => "New duration in hours",
1035
- "--note" => "New note",
1036
- "--date" => "New date (YYYY-MM-DD)",
1037
- "--client" => "New client name",
1038
- "--project" => "New project name",
1039
- "--service" => "New service name",
1040
- "--yes" => "Skip confirmation prompt"
1041
- }
1042
- },
1043
- cache: {
1044
- description: "Manage cached data",
1045
- usage: "fb cache [refresh|clear|status]",
1046
- subcommands: {
1047
- "refresh" => "Force-refresh all cached data",
1048
- "clear" => "Delete cache file",
1049
- "status" => "Show cache age and staleness"
1050
- }
1051
- },
1052
- help: {
1053
- description: "Show help information",
1054
- usage: "fb help [COMMAND]"
1055
- }
1056
- }
1057
- }
1058
- end
1059
- end
1060
- end