freshbooks-cli 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84f4c3c33549e8e6f23749a6597fe394d6d94b248c54453810c4bd198b3e4294
4
- data.tar.gz: 76d88cb16bf1d4b9298869c4b0a9db068157cbd6e068c851e27a834516b5baa8
3
+ metadata.gz: a37704cb4e7d203009ad3e85149f128509f11fd1e78198890c27687017e8f43d
4
+ data.tar.gz: 654e5bfc31deb194428128f8c2eb308c744c8c877426546f6400887b244c209c
5
5
  SHA512:
6
- metadata.gz: 0cba9865b5c7b5e1e2e2b11590be7044803ca4afbb0a8eeba1d3136a84fc5ebaadfd542bf531d3d37073274936f30794dad006348a09bc4fc424a79ef993fc73
7
- data.tar.gz: 8d11babdd9b8f3406a664d13f89cca6c5fd915b8154044bfe9dddb3fea2b620ac99d8c0b774a8ffa81c5d32c6e9dfb4521ffaa728dc1f3b68d122e645c21de3d
6
+ metadata.gz: 3bb70e134009c39efe0a912ff507d0a6c79f7613dda511f3dc9fa968467ddfc5df69faa78f85667cca66d7d4a019188d0968ac6ba94f2eac046f4923e835c995
7
+ data.tar.gz: d641d16f38405cad36806eac8b346c9e6aa228cb46a1365cb1dd8f7ddf2f22181a531def080dc8d77fec3a1447db57ac904a2a45c3756d8ec4c4d9e48ed87c6b
@@ -217,19 +217,21 @@ module FreshBooks
217
217
  method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
218
218
  method_option :note, type: :string, desc: "Work description"
219
219
  method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
220
+ method_option :internal, type: :boolean, default: false, desc: "Log to an internal project with no client"
220
221
  method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
221
222
  def log
222
223
  Auth.valid_access_token
223
224
  defaults = Auth.load_defaults
224
225
 
225
- client = select_client(defaults)
226
- project = select_project(client["id"], defaults)
226
+ context = resolve_log_context(defaults)
227
+ client = context[:client]
228
+ project = context[:project]
227
229
  service = select_service(defaults, project)
228
230
  date = pick_date
229
231
  duration_hours = pick_duration
230
232
  note = pick_note
231
233
 
232
- client_name = display_name(client)
234
+ client_name = client ? display_name(client) : "Internal"
233
235
 
234
236
  unless options[:format] == "json"
235
237
  puts "\n--- Time Entry Summary ---"
@@ -252,9 +254,9 @@ module FreshBooks
252
254
  "is_logged" => true,
253
255
  "duration" => (duration_hours * 3600).to_i,
254
256
  "note" => note,
255
- "started_at" => normalize_datetime(date),
256
- "client_id" => client["id"]
257
+ "started_at" => normalize_datetime(date)
257
258
  }
259
+ entry["client_id"] = client["id"] if client
258
260
  entry["project_id"] = project["id"] if project
259
261
  entry["service_id"] = service["id"] if service
260
262
 
@@ -266,7 +268,8 @@ module FreshBooks
266
268
  puts "Time entry created!"
267
269
  end
268
270
 
269
- new_defaults = { "client_id" => client["id"] }
271
+ new_defaults = {}
272
+ new_defaults["client_id"] = client["id"] if client
270
273
  new_defaults["project_id"] = project["id"] if project
271
274
  new_defaults["service_id"] = service["id"] if service
272
275
  Auth.save_defaults(new_defaults)
@@ -328,7 +331,7 @@ module FreshBooks
328
331
 
329
332
  rows = entries.map do |e|
330
333
  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
334
+ client = display_client_name(e["client_id"], maps)
332
335
  project = maps[:projects][e["project_id"].to_s] || "-"
333
336
  service = maps[:services][e["service_id"].to_s] || "-"
334
337
  note = e["note"] || ""
@@ -341,7 +344,7 @@ module FreshBooks
341
344
  total = entries.sum { |e| e["duration"].to_i } / 3600.0
342
345
 
343
346
  # Per-client breakdown
344
- by_client = entries.group_by { |e| maps[:clients][e["client_id"].to_s] || e["client_id"].to_s }
347
+ by_client = entries.group_by { |e| display_client_name(e["client_id"], maps) }
345
348
  if by_client.length > 1
346
349
  puts "\nBy client:"
347
350
  by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
@@ -416,7 +419,7 @@ module FreshBooks
416
419
  end
417
420
 
418
421
  rows = projects.map do |p|
419
- client_name = maps[:clients][p["client_id"].to_s] || "-"
422
+ client_name = display_project_client_name(p, maps)
420
423
  [p["title"], client_name, p["active"] ? "active" : "inactive"]
421
424
  end
422
425
 
@@ -520,6 +523,7 @@ module FreshBooks
520
523
  method_option :client, type: :string, desc: "New client name"
521
524
  method_option :project, type: :string, desc: "New project name"
522
525
  method_option :service, type: :string, desc: "New service name"
526
+ method_option :internal, type: :boolean, default: false, desc: "Move entry to an internal project with no client"
523
527
  method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
524
528
  def edit
525
529
  Auth.valid_access_token
@@ -535,7 +539,7 @@ module FreshBooks
535
539
  abort("Time entry not found.") unless entry
536
540
 
537
541
  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]
542
+ has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service] || options[:internal]
539
543
  scripted = has_edit_flags || !interactive?
540
544
 
541
545
  fields = build_edit_fields(entry, maps, scripted)
@@ -544,11 +548,18 @@ module FreshBooks
544
548
  current_project = maps[:projects][entry["project_id"].to_s] || "-"
545
549
  current_hours = (entry["duration"].to_i / 3600.0).round(2)
546
550
  new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
551
+ summary_client = if fields.key?("client_id")
552
+ fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : "Internal"
553
+ elsif fields["project_id"] != entry["project_id"]
554
+ "Internal"
555
+ else
556
+ current_client
557
+ end
547
558
 
548
559
  unless options[:format] == "json"
549
560
  puts "\n--- Edit Summary ---"
550
561
  puts " Date: #{fields["started_at"] || entry["started_at"]}"
551
- puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
562
+ puts " Client: #{summary_client}"
552
563
  puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
553
564
  puts " Duration: #{new_hours}h"
554
565
  puts " Note: #{fields["note"] || entry["note"]}"
@@ -646,6 +657,63 @@ module FreshBooks
646
657
  $stdin.tty?
647
658
  end
648
659
 
660
+ def resolve_log_context(defaults)
661
+ validate_internal_log_options!
662
+
663
+ if options[:project] && (!options[:client] || options[:internal])
664
+ project = resolve_project_by_name(options[:project], force: true)
665
+ ensure_project_matches_internal_option!(project)
666
+ client = project_internal?(project) ? nil : resolve_client_for_project(project)
667
+ return { client: client, project: project }
668
+ end
669
+
670
+ client = select_client(defaults)
671
+ project = select_project(client["id"], defaults)
672
+ { client: client, project: project }
673
+ end
674
+
675
+ def validate_internal_log_options!
676
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
677
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
678
+ end
679
+
680
+ def validate_internal_edit_options!
681
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
682
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
683
+ end
684
+
685
+ def resolve_project_by_name(name, force: false)
686
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects(force: force) }
687
+ match = projects.find { |project| project["title"].downcase == name.downcase }
688
+ abort("Project not found: #{name}") unless match
689
+ match
690
+ end
691
+
692
+ def project_internal?(project)
693
+ project["internal"] || project["client_id"].nil?
694
+ end
695
+
696
+ def display_client_name(client_id, maps)
697
+ return "Internal" if client_id.nil?
698
+ maps[:clients][client_id.to_s] || client_id.to_s
699
+ end
700
+
701
+ def display_project_client_name(project, maps)
702
+ return "Internal" if project_internal?(project)
703
+ maps[:clients][project["client_id"].to_s] || "-"
704
+ end
705
+
706
+ def ensure_project_matches_internal_option!(project)
707
+ abort("Project #{project["title"]} is not internal.") if options[:internal] && !project_internal?(project)
708
+ end
709
+
710
+ def resolve_client_for_project(project)
711
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
712
+ match = clients.find { |client| client["id"].to_i == project["client_id"].to_i }
713
+ abort("Client not found for project: #{project["title"]}") unless match
714
+ match
715
+ end
716
+
649
717
  def select_client(defaults)
650
718
  clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
651
719
 
@@ -865,7 +933,7 @@ module FreshBooks
865
933
 
866
934
  grouped = {}
867
935
  entries.each do |e|
868
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
936
+ client = display_client_name(e["client_id"], maps)
869
937
  project = maps[:projects][e["project_id"].to_s] || "-"
870
938
  key = "#{client} / #{project}"
871
939
  grouped[key] ||= 0.0
@@ -884,7 +952,7 @@ module FreshBooks
884
952
  entry_data = entries.map do |e|
885
953
  {
886
954
  "id" => e["id"],
887
- "client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
955
+ "client" => display_client_name(e["client_id"], maps),
888
956
  "project" => maps[:projects][e["project_id"].to_s] || "-",
889
957
  "duration" => e["duration"],
890
958
  "hours" => (e["duration"].to_i / 3600.0).round(2),
@@ -907,7 +975,7 @@ module FreshBooks
907
975
 
908
976
  puts "\nToday's entries:\n\n"
909
977
  entries.each_with_index do |e, i|
910
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
978
+ client = display_client_name(e["client_id"], maps)
911
979
  hours = (e["duration"].to_i / 3600.0).round(2)
912
980
  note = (e["note"] || "").slice(0, 40)
913
981
  puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
@@ -935,22 +1003,26 @@ module FreshBooks
935
1003
  }
936
1004
 
937
1005
  if scripted
1006
+ validate_internal_edit_options!
938
1007
  fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
939
1008
  fields["note"] = options[:note] if options[:note]
940
1009
  fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
941
1010
 
942
- if options[:client]
1011
+ if options[:project]
1012
+ project = resolve_project_by_name(options[:project], force: true)
1013
+ ensure_project_matches_internal_option!(project)
1014
+ fields["project_id"] = project["id"]
1015
+ if project_internal?(project)
1016
+ fields.delete("client_id")
1017
+ else
1018
+ fields["client_id"] = project["client_id"].to_i
1019
+ end
1020
+ elsif options[:client]
943
1021
  client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
944
1022
  abort("Client not found: #{options[:client]}") unless client_id
945
1023
  fields["client_id"] = client_id.to_i
946
1024
  end
947
1025
 
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
1026
  if options[:service]
955
1027
  service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
956
1028
  abort("Service not found: #{options[:service]}") unless service_id
@@ -1023,12 +1095,13 @@ module FreshBooks
1023
1095
  usage: "fb log",
1024
1096
  interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
1025
1097
  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)",
1098
+ "--client" => "Client name (required only for client-backed resolution when multiple clients exist)",
1099
+ "--project" => "Project name (can be internal; internal projects may be used without --client)",
1100
+ "--service" => "Service name (project-scoped; optional if single/default)",
1029
1101
  "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
1030
1102
  "--note" => "Work description (required non-interactive)",
1031
1103
  "--date" => "Date YYYY-MM-DD (defaults to today)",
1104
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1032
1105
  "--yes" => "Skip confirmation prompt"
1033
1106
  }
1034
1107
  },
@@ -1080,8 +1153,9 @@ module FreshBooks
1080
1153
  "--note" => "New note",
1081
1154
  "--date" => "New date (YYYY-MM-DD)",
1082
1155
  "--client" => "New client name",
1083
- "--project" => "New project name",
1156
+ "--project" => "New project name (internal projects omit client_id)",
1084
1157
  "--service" => "New service name",
1158
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1085
1159
  "--yes" => "Skip confirmation prompt"
1086
1160
  }
1087
1161
  },
@@ -2,6 +2,6 @@
2
2
 
3
3
  module FreshBooks
4
4
  module CLI
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshbooks-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - parasquid
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-10 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor