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 +4 -4
- data/lib/freshbooks/cli.rb +99 -25
- data/lib/freshbooks/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a37704cb4e7d203009ad3e85149f128509f11fd1e78198890c27687017e8f43d
|
|
4
|
+
data.tar.gz: 654e5bfc31deb194428128f8c2eb308c744c8c877426546f6400887b244c209c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bb70e134009c39efe0a912ff507d0a6c79f7613dda511f3dc9fa968467ddfc5df69faa78f85667cca66d7d4a019188d0968ac6ba94f2eac046f4923e835c995
|
|
7
|
+
data.tar.gz: d641d16f38405cad36806eac8b346c9e6aa228cb46a1365cb1dd8f7ddf2f22181a531def080dc8d77fec3a1447db57ac904a2a45c3756d8ec4c4d9e48ed87c6b
|
data/lib/freshbooks/cli.rb
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
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 = {
|
|
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 =
|
|
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|
|
|
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 =
|
|
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: #{
|
|
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 =
|
|
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" =>
|
|
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 =
|
|
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[:
|
|
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
|
|
1027
|
-
"--project" => "Project name (
|
|
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
|
},
|
data/lib/freshbooks/version.rb
CHANGED
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
|
+
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-
|
|
11
|
+
date: 2026-04-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|