freshbooks-cli 0.4.0 → 0.5.1
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/api.rb +0 -4
- data/lib/freshbooks/auth.rb +66 -16
- data/lib/freshbooks/cli.rb +161 -32
- 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: 72c774411761de2ede345e21a6961b2dace9debd2adf089b948c6cdd2447ac9b
|
|
4
|
+
data.tar.gz: a289b2ae8edb32a9759fc35b96165dc261254594a8711291e412a3ad1c69ece5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88c4848ab50045afa539f92fee68585b0bcdc61719cb99723b3758eba772ace18f810b1c81d8f024173604c4161ab0b4ada6b247a32d69ba3005c572ed341b79
|
|
7
|
+
data.tar.gz: 21304aa42d250e86ccf868342158a7d8f1135a7344bdc1407a4395de1bd7cccdf72f55dba9d4a3bb0cab22f73a7223c524788b96b537fd264ad9402c23d7afb4
|
data/lib/freshbooks/api.rb
CHANGED
|
@@ -41,8 +41,6 @@ module FreshBooks
|
|
|
41
41
|
# --- Paginated fetch ---
|
|
42
42
|
|
|
43
43
|
def fetch_all_pages(url, result_key, params: {})
|
|
44
|
-
return [] if Thread.current[:fb_dry_run]
|
|
45
|
-
|
|
46
44
|
page = 1
|
|
47
45
|
all_items = []
|
|
48
46
|
|
|
@@ -132,8 +130,6 @@ module FreshBooks
|
|
|
132
130
|
# --- Services ---
|
|
133
131
|
|
|
134
132
|
def fetch_services(force: false)
|
|
135
|
-
return (Auth.load_cache["services_data"] || []) if Thread.current[:fb_dry_run]
|
|
136
|
-
|
|
137
133
|
unless force
|
|
138
134
|
cached = cached_data("services_data")
|
|
139
135
|
return cached if cached
|
data/lib/freshbooks/auth.rb
CHANGED
|
@@ -9,6 +9,8 @@ require "dotenv"
|
|
|
9
9
|
module FreshBooks
|
|
10
10
|
module CLI
|
|
11
11
|
class Auth
|
|
12
|
+
class TokenRefreshError < StandardError; end
|
|
13
|
+
|
|
12
14
|
TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
|
|
13
15
|
AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
|
|
14
16
|
ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
|
|
@@ -60,6 +62,10 @@ module FreshBooks
|
|
|
60
62
|
File.join(data_dir, "tokens.json")
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
def tokens_lock_path
|
|
66
|
+
File.join(data_dir, "tokens.json.lock")
|
|
67
|
+
end
|
|
68
|
+
|
|
63
69
|
def defaults_path
|
|
64
70
|
File.join(data_dir, "defaults.json")
|
|
65
71
|
end
|
|
@@ -200,14 +206,28 @@ module FreshBooks
|
|
|
200
206
|
def auth_status
|
|
201
207
|
config = load_config
|
|
202
208
|
tokens = load_tokens
|
|
209
|
+
refresh_error = nil
|
|
210
|
+
|
|
211
|
+
if config && tokens && token_expired?(tokens)
|
|
212
|
+
begin
|
|
213
|
+
tokens = refresh_token_with_lock(config, tokens, abort_on_failure: false)
|
|
214
|
+
rescue TokenRefreshError => e
|
|
215
|
+
refresh_error = e.message
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
tokens_expired = tokens ? token_expired?(tokens) : nil
|
|
220
|
+
requires_reauth = config.nil? || tokens.nil? || tokens_expired == true
|
|
203
221
|
{
|
|
204
222
|
"config_exists" => !config.nil?,
|
|
205
223
|
"config_path" => config_path,
|
|
206
224
|
"tokens_exist" => !tokens.nil?,
|
|
207
|
-
"tokens_expired" =>
|
|
225
|
+
"tokens_expired" => tokens_expired,
|
|
226
|
+
"requires_reauth" => requires_reauth,
|
|
227
|
+
"refresh_error" => refresh_error,
|
|
208
228
|
"business_id" => config&.dig("business_id"),
|
|
209
229
|
"account_id" => config&.dig("account_id")
|
|
210
|
-
}
|
|
230
|
+
}.compact
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
def fetch_businesses(access_token)
|
|
@@ -258,7 +278,15 @@ module FreshBooks
|
|
|
258
278
|
|
|
259
279
|
def save_tokens(tokens)
|
|
260
280
|
ensure_data_dir
|
|
261
|
-
|
|
281
|
+
tmp_path = "#{tokens_path}.#{$$}.tmp"
|
|
282
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
283
|
+
file.write(JSON.pretty_generate(tokens) + "\n")
|
|
284
|
+
file.flush
|
|
285
|
+
file.fsync
|
|
286
|
+
end
|
|
287
|
+
File.rename(tmp_path, tokens_path)
|
|
288
|
+
ensure
|
|
289
|
+
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
262
290
|
end
|
|
263
291
|
|
|
264
292
|
def token_expired?(tokens)
|
|
@@ -268,22 +296,30 @@ module FreshBooks
|
|
|
268
296
|
Time.now.to_i >= (created + expires_in - 60)
|
|
269
297
|
end
|
|
270
298
|
|
|
271
|
-
def refresh_token!(config, tokens)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
def refresh_token!(config, tokens, abort_on_failure: true)
|
|
300
|
+
begin
|
|
301
|
+
response = HTTParty.post(TOKEN_URL, {
|
|
302
|
+
headers: { "Content-Type" => "application/json" },
|
|
303
|
+
body: {
|
|
304
|
+
grant_type: "refresh_token",
|
|
305
|
+
client_id: config["client_id"],
|
|
306
|
+
client_secret: config["client_secret"],
|
|
307
|
+
redirect_uri: REDIRECT_URI,
|
|
308
|
+
refresh_token: tokens["refresh_token"]
|
|
309
|
+
}.to_json
|
|
310
|
+
})
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
message = "Token refresh failed: #{e.class}: #{e.message}\nPlease re-run: fb auth"
|
|
313
|
+
raise if abort_on_failure
|
|
314
|
+
raise TokenRefreshError, message
|
|
315
|
+
end
|
|
282
316
|
|
|
283
317
|
unless response.success?
|
|
284
318
|
body = response.parsed_response
|
|
285
319
|
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
286
|
-
|
|
320
|
+
message = "Token refresh failed: #{msg}\nPlease re-run: fb auth"
|
|
321
|
+
abort(message) if abort_on_failure
|
|
322
|
+
raise TokenRefreshError, message
|
|
287
323
|
end
|
|
288
324
|
|
|
289
325
|
data = response.parsed_response
|
|
@@ -297,6 +333,20 @@ module FreshBooks
|
|
|
297
333
|
new_tokens
|
|
298
334
|
end
|
|
299
335
|
|
|
336
|
+
def refresh_token_with_lock(config, tokens, abort_on_failure: true)
|
|
337
|
+
ensure_data_dir
|
|
338
|
+
File.open(tokens_lock_path, File::RDWR | File::CREAT, 0o600) do |lock_file|
|
|
339
|
+
lock_file.flock(File::LOCK_EX)
|
|
340
|
+
|
|
341
|
+
latest_tokens = load_tokens || tokens
|
|
342
|
+
return latest_tokens if latest_tokens && !token_expired?(latest_tokens)
|
|
343
|
+
|
|
344
|
+
refresh_token!(config, latest_tokens || tokens, abort_on_failure: abort_on_failure)
|
|
345
|
+
ensure
|
|
346
|
+
lock_file.flock(File::LOCK_UN)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
300
350
|
def valid_access_token
|
|
301
351
|
if Thread.current[:fb_dry_run]
|
|
302
352
|
tokens = load_tokens
|
|
@@ -315,7 +365,7 @@ module FreshBooks
|
|
|
315
365
|
|
|
316
366
|
if token_expired?(tokens)
|
|
317
367
|
puts "Token expired, refreshing..."
|
|
318
|
-
tokens =
|
|
368
|
+
tokens = refresh_token_with_lock(config, tokens)
|
|
319
369
|
end
|
|
320
370
|
|
|
321
371
|
tokens["access_token"]
|
data/lib/freshbooks/cli.rb
CHANGED
|
@@ -13,10 +13,48 @@ module FreshBooks
|
|
|
13
13
|
true
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def self.dispatch(command, given_args, given_opts, config)
|
|
17
|
+
requested_command = command || given_args.first
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
rescue Thor::UnknownArgumentError => e
|
|
21
|
+
if requested_command.to_s == "log"
|
|
22
|
+
unknown_option = e.unknown.first
|
|
23
|
+
raise Thor::InvocationError, log_parse_error_message(unknown_option, e.message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.handle_argument_error(command, error, args, arity)
|
|
30
|
+
unknown_option = args.find { |arg| arg.to_s.start_with?("--") }
|
|
31
|
+
if command.name == "log" && unknown_option
|
|
32
|
+
raise Thor::InvocationError, log_parse_error_message(unknown_option, error.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.log_parse_error_message(option, fallback)
|
|
39
|
+
suggestion = { "--hours" => "--duration", "--notes" => "--note" }[option]
|
|
40
|
+
lines = ["Unknown option: #{option || fallback}"]
|
|
41
|
+
lines << "Did you mean: #{suggestion}" if suggestion
|
|
42
|
+
lines.concat([
|
|
43
|
+
"",
|
|
44
|
+
"fb log requires:",
|
|
45
|
+
" --duration HOURS",
|
|
46
|
+
" --note TEXT",
|
|
47
|
+
"",
|
|
48
|
+
"Example:",
|
|
49
|
+
' fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes'
|
|
50
|
+
])
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
16
54
|
class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
|
|
17
55
|
class_option :interactive, type: :boolean, default: false, desc: "Force interactive mode even when not a TTY"
|
|
18
56
|
class_option :format, type: :string, desc: "Output format: table (default) or json"
|
|
19
|
-
class_option :dry_run, type: :boolean, default: false, desc: "Simulate
|
|
57
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Simulate writes while allowing read lookups"
|
|
20
58
|
|
|
21
59
|
no_commands do
|
|
22
60
|
def invoke_command(command, *args)
|
|
@@ -124,6 +162,7 @@ module FreshBooks
|
|
|
124
162
|
if status_data["tokens_exist"]
|
|
125
163
|
puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
|
|
126
164
|
end
|
|
165
|
+
puts "Requires re-auth: #{status_data["requires_reauth"] ? "yes" : "no"}"
|
|
127
166
|
puts "Business ID: #{status_data["business_id"] || "not set"}"
|
|
128
167
|
puts "Account ID: #{status_data["account_id"] || "not set"}"
|
|
129
168
|
end
|
|
@@ -210,26 +249,44 @@ module FreshBooks
|
|
|
210
249
|
|
|
211
250
|
# --- log ---
|
|
212
251
|
|
|
213
|
-
desc "log
|
|
252
|
+
desc "log [--client NAME] [--project NAME] [--service NAME] --duration HOURS --note TEXT [--date YYYY-MM-DD] [--internal] [--yes]",
|
|
253
|
+
"Log a time entry"
|
|
254
|
+
long_desc <<~DESC
|
|
255
|
+
Log a FreshBooks time entry.
|
|
256
|
+
|
|
257
|
+
Non-interactive usage requires --duration and --note. Use --client when multiple clients exist, or --internal with --project for internal work.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes
|
|
261
|
+
fb log --internal --project "Admin" --hours 1 --notes "Planning" --yes
|
|
262
|
+
DESC
|
|
214
263
|
method_option :client, type: :string, desc: "Pre-select client by name"
|
|
215
264
|
method_option :project, type: :string, desc: "Pre-select project by name"
|
|
216
265
|
method_option :service, type: :string, desc: "Pre-select service by name"
|
|
217
|
-
method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
|
|
218
|
-
method_option :note, type: :string, desc: "Work description"
|
|
266
|
+
method_option :duration, type: :numeric, aliases: "--hours", desc: "Duration in hours (e.g. 2.5)"
|
|
267
|
+
method_option :note, type: :string, aliases: "--notes", desc: "Work description"
|
|
219
268
|
method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
|
|
269
|
+
method_option :internal, type: :boolean, default: false, desc: "Log to an internal project with no client"
|
|
220
270
|
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
271
|
+
method_option :help, type: :boolean, desc: "Show command help"
|
|
221
272
|
def log
|
|
273
|
+
if options[:help]
|
|
274
|
+
self.class.command_help(shell, "log")
|
|
275
|
+
return
|
|
276
|
+
end
|
|
277
|
+
|
|
222
278
|
Auth.valid_access_token
|
|
223
279
|
defaults = Auth.load_defaults
|
|
224
280
|
|
|
225
|
-
|
|
226
|
-
|
|
281
|
+
context = resolve_log_context(defaults)
|
|
282
|
+
client = context[:client]
|
|
283
|
+
project = context[:project]
|
|
227
284
|
service = select_service(defaults, project)
|
|
228
285
|
date = pick_date
|
|
229
286
|
duration_hours = pick_duration
|
|
230
287
|
note = pick_note
|
|
231
288
|
|
|
232
|
-
client_name = display_name(client)
|
|
289
|
+
client_name = client ? display_name(client) : "Internal"
|
|
233
290
|
|
|
234
291
|
unless options[:format] == "json"
|
|
235
292
|
puts "\n--- Time Entry Summary ---"
|
|
@@ -252,9 +309,9 @@ module FreshBooks
|
|
|
252
309
|
"is_logged" => true,
|
|
253
310
|
"duration" => (duration_hours * 3600).to_i,
|
|
254
311
|
"note" => note,
|
|
255
|
-
"started_at" => normalize_datetime(date)
|
|
256
|
-
"client_id" => client["id"]
|
|
312
|
+
"started_at" => normalize_datetime(date)
|
|
257
313
|
}
|
|
314
|
+
entry["client_id"] = client["id"] if client
|
|
258
315
|
entry["project_id"] = project["id"] if project
|
|
259
316
|
entry["service_id"] = service["id"] if service
|
|
260
317
|
|
|
@@ -266,7 +323,8 @@ module FreshBooks
|
|
|
266
323
|
puts "Time entry created!"
|
|
267
324
|
end
|
|
268
325
|
|
|
269
|
-
new_defaults = {
|
|
326
|
+
new_defaults = {}
|
|
327
|
+
new_defaults["client_id"] = client["id"] if client
|
|
270
328
|
new_defaults["project_id"] = project["id"] if project
|
|
271
329
|
new_defaults["service_id"] = service["id"] if service
|
|
272
330
|
Auth.save_defaults(new_defaults)
|
|
@@ -328,7 +386,7 @@ module FreshBooks
|
|
|
328
386
|
|
|
329
387
|
rows = entries.map do |e|
|
|
330
388
|
date = (e["local_started_at"] || e["started_at"] || "?").slice(0, 10)
|
|
331
|
-
client =
|
|
389
|
+
client = display_client_name(e["client_id"], maps)
|
|
332
390
|
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
333
391
|
service = maps[:services][e["service_id"].to_s] || "-"
|
|
334
392
|
note = e["note"] || ""
|
|
@@ -341,7 +399,7 @@ module FreshBooks
|
|
|
341
399
|
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
342
400
|
|
|
343
401
|
# Per-client breakdown
|
|
344
|
-
by_client = entries.group_by { |e|
|
|
402
|
+
by_client = entries.group_by { |e| display_client_name(e["client_id"], maps) }
|
|
345
403
|
if by_client.length > 1
|
|
346
404
|
puts "\nBy client:"
|
|
347
405
|
by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
|
|
@@ -416,7 +474,7 @@ module FreshBooks
|
|
|
416
474
|
end
|
|
417
475
|
|
|
418
476
|
rows = projects.map do |p|
|
|
419
|
-
client_name =
|
|
477
|
+
client_name = display_project_client_name(p, maps)
|
|
420
478
|
[p["title"], client_name, p["active"] ? "active" : "inactive"]
|
|
421
479
|
end
|
|
422
480
|
|
|
@@ -514,12 +572,13 @@ module FreshBooks
|
|
|
514
572
|
|
|
515
573
|
desc "edit", "Edit a time entry"
|
|
516
574
|
method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
|
|
517
|
-
method_option :duration, type: :numeric, desc: "New duration in hours"
|
|
518
|
-
method_option :note, type: :string, desc: "New note"
|
|
575
|
+
method_option :duration, type: :numeric, aliases: "--hours", desc: "New duration in hours"
|
|
576
|
+
method_option :note, type: :string, aliases: "--notes", desc: "New note"
|
|
519
577
|
method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
|
|
520
578
|
method_option :client, type: :string, desc: "New client name"
|
|
521
579
|
method_option :project, type: :string, desc: "New project name"
|
|
522
580
|
method_option :service, type: :string, desc: "New service name"
|
|
581
|
+
method_option :internal, type: :boolean, default: false, desc: "Move entry to an internal project with no client"
|
|
523
582
|
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
524
583
|
def edit
|
|
525
584
|
Auth.valid_access_token
|
|
@@ -535,7 +594,7 @@ module FreshBooks
|
|
|
535
594
|
abort("Time entry not found.") unless entry
|
|
536
595
|
|
|
537
596
|
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]
|
|
597
|
+
has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service] || options[:internal]
|
|
539
598
|
scripted = has_edit_flags || !interactive?
|
|
540
599
|
|
|
541
600
|
fields = build_edit_fields(entry, maps, scripted)
|
|
@@ -544,11 +603,18 @@ module FreshBooks
|
|
|
544
603
|
current_project = maps[:projects][entry["project_id"].to_s] || "-"
|
|
545
604
|
current_hours = (entry["duration"].to_i / 3600.0).round(2)
|
|
546
605
|
new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
|
|
606
|
+
summary_client = if fields.key?("client_id")
|
|
607
|
+
fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : "Internal"
|
|
608
|
+
elsif fields["project_id"] != entry["project_id"]
|
|
609
|
+
"Internal"
|
|
610
|
+
else
|
|
611
|
+
current_client
|
|
612
|
+
end
|
|
547
613
|
|
|
548
614
|
unless options[:format] == "json"
|
|
549
615
|
puts "\n--- Edit Summary ---"
|
|
550
616
|
puts " Date: #{fields["started_at"] || entry["started_at"]}"
|
|
551
|
-
puts " Client: #{
|
|
617
|
+
puts " Client: #{summary_client}"
|
|
552
618
|
puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
|
|
553
619
|
puts " Duration: #{new_hours}h"
|
|
554
620
|
puts " Note: #{fields["note"] || entry["note"]}"
|
|
@@ -646,6 +712,63 @@ module FreshBooks
|
|
|
646
712
|
$stdin.tty?
|
|
647
713
|
end
|
|
648
714
|
|
|
715
|
+
def resolve_log_context(defaults)
|
|
716
|
+
validate_internal_log_options!
|
|
717
|
+
|
|
718
|
+
if options[:project] && (!options[:client] || options[:internal])
|
|
719
|
+
project = resolve_project_by_name(options[:project], force: true)
|
|
720
|
+
ensure_project_matches_internal_option!(project)
|
|
721
|
+
client = project_internal?(project) ? nil : resolve_client_for_project(project)
|
|
722
|
+
return { client: client, project: project }
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
client = select_client(defaults)
|
|
726
|
+
project = select_project(client["id"], defaults)
|
|
727
|
+
{ client: client, project: project }
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def validate_internal_log_options!
|
|
731
|
+
abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
|
|
732
|
+
abort("--internal requires --project.") if options[:internal] && !options[:project]
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def validate_internal_edit_options!
|
|
736
|
+
abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
|
|
737
|
+
abort("--internal requires --project.") if options[:internal] && !options[:project]
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def resolve_project_by_name(name, force: false)
|
|
741
|
+
projects = Spinner.spin("Fetching projects") { Api.fetch_projects(force: force) }
|
|
742
|
+
match = projects.find { |project| project["title"].downcase == name.downcase }
|
|
743
|
+
abort("Project not found: #{name}") unless match
|
|
744
|
+
match
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def project_internal?(project)
|
|
748
|
+
project["internal"] || project["client_id"].nil?
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def display_client_name(client_id, maps)
|
|
752
|
+
return "Internal" if client_id.nil?
|
|
753
|
+
maps[:clients][client_id.to_s] || client_id.to_s
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def display_project_client_name(project, maps)
|
|
757
|
+
return "Internal" if project_internal?(project)
|
|
758
|
+
maps[:clients][project["client_id"].to_s] || "-"
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def ensure_project_matches_internal_option!(project)
|
|
762
|
+
abort("Project #{project["title"]} is not internal.") if options[:internal] && !project_internal?(project)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def resolve_client_for_project(project)
|
|
766
|
+
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
767
|
+
match = clients.find { |client| client["id"].to_i == project["client_id"].to_i }
|
|
768
|
+
abort("Client not found for project: #{project["title"]}") unless match
|
|
769
|
+
match
|
|
770
|
+
end
|
|
771
|
+
|
|
649
772
|
def select_client(defaults)
|
|
650
773
|
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
651
774
|
|
|
@@ -865,7 +988,7 @@ module FreshBooks
|
|
|
865
988
|
|
|
866
989
|
grouped = {}
|
|
867
990
|
entries.each do |e|
|
|
868
|
-
client =
|
|
991
|
+
client = display_client_name(e["client_id"], maps)
|
|
869
992
|
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
870
993
|
key = "#{client} / #{project}"
|
|
871
994
|
grouped[key] ||= 0.0
|
|
@@ -884,7 +1007,7 @@ module FreshBooks
|
|
|
884
1007
|
entry_data = entries.map do |e|
|
|
885
1008
|
{
|
|
886
1009
|
"id" => e["id"],
|
|
887
|
-
"client" =>
|
|
1010
|
+
"client" => display_client_name(e["client_id"], maps),
|
|
888
1011
|
"project" => maps[:projects][e["project_id"].to_s] || "-",
|
|
889
1012
|
"duration" => e["duration"],
|
|
890
1013
|
"hours" => (e["duration"].to_i / 3600.0).round(2),
|
|
@@ -907,7 +1030,7 @@ module FreshBooks
|
|
|
907
1030
|
|
|
908
1031
|
puts "\nToday's entries:\n\n"
|
|
909
1032
|
entries.each_with_index do |e, i|
|
|
910
|
-
client =
|
|
1033
|
+
client = display_client_name(e["client_id"], maps)
|
|
911
1034
|
hours = (e["duration"].to_i / 3600.0).round(2)
|
|
912
1035
|
note = (e["note"] || "").slice(0, 40)
|
|
913
1036
|
puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
|
|
@@ -935,22 +1058,26 @@ module FreshBooks
|
|
|
935
1058
|
}
|
|
936
1059
|
|
|
937
1060
|
if scripted
|
|
1061
|
+
validate_internal_edit_options!
|
|
938
1062
|
fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
|
|
939
1063
|
fields["note"] = options[:note] if options[:note]
|
|
940
1064
|
fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
|
|
941
1065
|
|
|
942
|
-
if options[:
|
|
1066
|
+
if options[:project]
|
|
1067
|
+
project = resolve_project_by_name(options[:project], force: true)
|
|
1068
|
+
ensure_project_matches_internal_option!(project)
|
|
1069
|
+
fields["project_id"] = project["id"]
|
|
1070
|
+
if project_internal?(project)
|
|
1071
|
+
fields.delete("client_id")
|
|
1072
|
+
else
|
|
1073
|
+
fields["client_id"] = project["client_id"].to_i
|
|
1074
|
+
end
|
|
1075
|
+
elsif options[:client]
|
|
943
1076
|
client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
|
|
944
1077
|
abort("Client not found: #{options[:client]}") unless client_id
|
|
945
1078
|
fields["client_id"] = client_id.to_i
|
|
946
1079
|
end
|
|
947
1080
|
|
|
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
1081
|
if options[:service]
|
|
955
1082
|
service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
|
|
956
1083
|
abort("Service not found: #{options[:service]}") unless service_id
|
|
@@ -994,7 +1121,7 @@ module FreshBooks
|
|
|
994
1121
|
global_flags: {
|
|
995
1122
|
"--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
|
|
996
1123
|
"--format json" => "Output format: json (available on all commands)",
|
|
997
|
-
"--dry-run" => "Simulate
|
|
1124
|
+
"--dry-run" => "Simulate writes while allowing read lookups"
|
|
998
1125
|
},
|
|
999
1126
|
commands: {
|
|
1000
1127
|
auth: {
|
|
@@ -1023,12 +1150,13 @@ module FreshBooks
|
|
|
1023
1150
|
usage: "fb log",
|
|
1024
1151
|
interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
|
|
1025
1152
|
flags: {
|
|
1026
|
-
"--client" => "Client name (required
|
|
1027
|
-
"--project" => "Project name (
|
|
1028
|
-
"--service" => "Service name (optional)",
|
|
1153
|
+
"--client" => "Client name (required only for client-backed resolution when multiple clients exist)",
|
|
1154
|
+
"--project" => "Project name (can be internal; internal projects may be used without --client)",
|
|
1155
|
+
"--service" => "Service name (project-scoped; optional if single/default)",
|
|
1029
1156
|
"--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
|
|
1030
1157
|
"--note" => "Work description (required non-interactive)",
|
|
1031
1158
|
"--date" => "Date YYYY-MM-DD (defaults to today)",
|
|
1159
|
+
"--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
|
|
1032
1160
|
"--yes" => "Skip confirmation prompt"
|
|
1033
1161
|
}
|
|
1034
1162
|
},
|
|
@@ -1080,8 +1208,9 @@ module FreshBooks
|
|
|
1080
1208
|
"--note" => "New note",
|
|
1081
1209
|
"--date" => "New date (YYYY-MM-DD)",
|
|
1082
1210
|
"--client" => "New client name",
|
|
1083
|
-
"--project" => "New project name",
|
|
1211
|
+
"--project" => "New project name (internal projects omit client_id)",
|
|
1084
1212
|
"--service" => "New service name",
|
|
1213
|
+
"--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
|
|
1085
1214
|
"--yes" => "Skip confirmation prompt"
|
|
1086
1215
|
}
|
|
1087
1216
|
},
|
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- parasquid
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|