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