freshbooks-cli 0.2.0 → 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 +725 -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,14 +10,165 @@ 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
|
+
|
|
16
|
+
# --- version ---
|
|
17
|
+
|
|
18
|
+
desc "version", "Print the current version"
|
|
19
|
+
def version
|
|
20
|
+
puts "freshbooks-cli #{VERSION}"
|
|
21
|
+
end
|
|
22
|
+
|
|
13
23
|
# --- auth ---
|
|
14
24
|
|
|
15
|
-
desc "auth", "Authenticate with FreshBooks via OAuth2"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
172
|
end
|
|
22
173
|
|
|
23
174
|
# --- log ---
|
|
@@ -33,25 +184,26 @@ module FB
|
|
|
33
184
|
def log
|
|
34
185
|
Auth.valid_access_token
|
|
35
186
|
defaults = Auth.load_defaults
|
|
36
|
-
interactive = !(options[:client] && options[:duration] && options[:note])
|
|
37
187
|
|
|
38
|
-
client = select_client(defaults
|
|
39
|
-
project = select_project(client["id"], defaults
|
|
40
|
-
service = select_service(defaults,
|
|
41
|
-
date = pick_date
|
|
42
|
-
duration_hours = pick_duration
|
|
43
|
-
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
|
|
44
194
|
|
|
45
195
|
client_name = display_name(client)
|
|
46
196
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
55
207
|
|
|
56
208
|
unless options[:yes]
|
|
57
209
|
print "Submit? (Y/n): "
|
|
@@ -63,14 +215,19 @@ module FB
|
|
|
63
215
|
"is_logged" => true,
|
|
64
216
|
"duration" => (duration_hours * 3600).to_i,
|
|
65
217
|
"note" => note,
|
|
66
|
-
"started_at" => date,
|
|
218
|
+
"started_at" => normalize_datetime(date),
|
|
67
219
|
"client_id" => client["id"]
|
|
68
220
|
}
|
|
69
221
|
entry["project_id"] = project["id"] if project
|
|
70
222
|
entry["service_id"] = service["id"] if service
|
|
71
223
|
|
|
72
|
-
Api.create_time_entry(entry)
|
|
73
|
-
|
|
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
|
|
74
231
|
|
|
75
232
|
new_defaults = { "client_id" => client["id"] }
|
|
76
233
|
new_defaults["project_id"] = project["id"] if project
|
|
@@ -85,7 +242,6 @@ module FB
|
|
|
85
242
|
method_option :year, type: :numeric, desc: "Year"
|
|
86
243
|
method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
|
|
87
244
|
method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
|
|
88
|
-
method_option :format, type: :string, default: "table", desc: "Output format: table or json"
|
|
89
245
|
def entries
|
|
90
246
|
Auth.valid_access_token
|
|
91
247
|
|
|
@@ -117,7 +273,11 @@ module FB
|
|
|
117
273
|
end
|
|
118
274
|
|
|
119
275
|
if entries.empty?
|
|
120
|
-
|
|
276
|
+
if options[:format] == "json"
|
|
277
|
+
puts "[]"
|
|
278
|
+
else
|
|
279
|
+
puts "No time entries#{label ? " #{label}" : ""}."
|
|
280
|
+
end
|
|
121
281
|
return
|
|
122
282
|
end
|
|
123
283
|
|
|
@@ -133,29 +293,287 @@ module FB
|
|
|
133
293
|
date = e["started_at"] || "?"
|
|
134
294
|
client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
|
|
135
295
|
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
296
|
+
service = maps[:services][e["service_id"].to_s] || "-"
|
|
136
297
|
note = (e["note"] || "").slice(0, 50)
|
|
137
298
|
hours = (e["duration"].to_i / 3600.0).round(2)
|
|
138
|
-
[date, client, project, note, "#{hours}h"]
|
|
299
|
+
[e["id"].to_s, date, client, project, service, note, "#{hours}h"]
|
|
139
300
|
end
|
|
140
301
|
|
|
141
|
-
|
|
142
|
-
widths = headers.each_with_index.map do |h, i|
|
|
143
|
-
[h.length, *rows.map { |r| r[i].to_s.length }].max
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
fmt = widths.map { |w| "%-#{w}s" }.join(" ")
|
|
147
|
-
puts fmt % headers
|
|
148
|
-
puts widths.map { |w| "-" * w }.join(" ")
|
|
149
|
-
rows.each { |r| puts fmt % r }
|
|
302
|
+
print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows)
|
|
150
303
|
|
|
151
304
|
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
152
305
|
puts "\nTotal: #{total.round(2)}h"
|
|
153
306
|
end
|
|
154
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
|
+
|
|
155
574
|
# --- help ---
|
|
156
575
|
|
|
157
576
|
desc "help [COMMAND]", "Describe available commands or one specific command"
|
|
158
|
-
method_option :format, type: :string, desc: "Output format: text (default) or json"
|
|
159
577
|
def help(command = nil)
|
|
160
578
|
if options[:format] == "json"
|
|
161
579
|
puts JSON.pretty_generate(help_json)
|
|
@@ -166,7 +584,12 @@ module FB
|
|
|
166
584
|
|
|
167
585
|
private
|
|
168
586
|
|
|
169
|
-
def
|
|
587
|
+
def interactive?
|
|
588
|
+
return false if options[:no_interactive]
|
|
589
|
+
$stdin.tty?
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def select_client(defaults)
|
|
170
593
|
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
171
594
|
|
|
172
595
|
if options[:client]
|
|
@@ -177,6 +600,15 @@ module FB
|
|
|
177
600
|
|
|
178
601
|
abort("No clients found.") if clients.empty?
|
|
179
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
|
+
|
|
180
612
|
puts "\nClients:\n\n"
|
|
181
613
|
clients.each_with_index do |c, i|
|
|
182
614
|
name = display_name(c)
|
|
@@ -199,7 +631,7 @@ module FB
|
|
|
199
631
|
clients[idx]
|
|
200
632
|
end
|
|
201
633
|
|
|
202
|
-
def select_project(client_id, defaults
|
|
634
|
+
def select_project(client_id, defaults)
|
|
203
635
|
projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
|
|
204
636
|
|
|
205
637
|
if options[:project]
|
|
@@ -210,6 +642,14 @@ module FB
|
|
|
210
642
|
|
|
211
643
|
return nil if projects.empty?
|
|
212
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
|
+
|
|
213
653
|
puts "\nProjects:\n\n"
|
|
214
654
|
projects.each_with_index do |p, i|
|
|
215
655
|
default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
|
|
@@ -230,17 +670,28 @@ module FB
|
|
|
230
670
|
projects[idx]
|
|
231
671
|
end
|
|
232
672
|
|
|
233
|
-
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
|
+
|
|
234
681
|
if options[:service]
|
|
235
|
-
services = Spinner.spin("Fetching services") { Api.fetch_services }
|
|
236
682
|
match = services.find { |s| s["name"].downcase == options[:service].downcase }
|
|
237
683
|
abort("Service not found: #{options[:service]}") unless match
|
|
238
684
|
return match
|
|
239
685
|
end
|
|
240
686
|
|
|
241
|
-
|
|
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
|
|
242
694
|
|
|
243
|
-
services = Spinner.spin("Fetching services") { Api.fetch_services }
|
|
244
695
|
return nil if services.empty?
|
|
245
696
|
|
|
246
697
|
puts "\nServices:\n\n"
|
|
@@ -263,35 +714,175 @@ module FB
|
|
|
263
714
|
services[idx]
|
|
264
715
|
end
|
|
265
716
|
|
|
266
|
-
def pick_date
|
|
717
|
+
def pick_date
|
|
267
718
|
return options[:date] if options[:date]
|
|
268
719
|
|
|
269
720
|
today = Date.today.to_s
|
|
270
|
-
return today unless interactive
|
|
721
|
+
return today unless interactive?
|
|
271
722
|
|
|
272
723
|
print "\nDate [#{today}]: "
|
|
273
724
|
input = $stdin.gets&.strip
|
|
274
725
|
(input.nil? || input.empty?) ? today : input
|
|
275
726
|
end
|
|
276
727
|
|
|
277
|
-
def pick_duration
|
|
728
|
+
def pick_duration
|
|
278
729
|
return options[:duration] if options[:duration]
|
|
279
730
|
|
|
731
|
+
abort("Missing required flag: --duration") unless interactive?
|
|
732
|
+
|
|
280
733
|
print "\nDuration (hours): "
|
|
281
734
|
input = $stdin.gets&.strip
|
|
282
735
|
abort("Duration is required.") if input.nil? || input.empty?
|
|
283
736
|
input.to_f
|
|
284
737
|
end
|
|
285
738
|
|
|
286
|
-
def pick_note
|
|
739
|
+
def pick_note
|
|
287
740
|
return options[:note] if options[:note]
|
|
288
741
|
|
|
742
|
+
abort("Missing required flag: --note") unless interactive?
|
|
743
|
+
|
|
289
744
|
print "\nNote: "
|
|
290
745
|
input = $stdin.gets&.strip
|
|
291
746
|
abort("Note is required.") if input.nil? || input.empty?
|
|
292
747
|
input
|
|
293
748
|
end
|
|
294
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
|
+
|
|
295
886
|
def display_name(client)
|
|
296
887
|
name = client["organization"]
|
|
297
888
|
(name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
|
|
@@ -302,23 +893,46 @@ module FB
|
|
|
302
893
|
name: "fb",
|
|
303
894
|
description: "FreshBooks time tracking CLI",
|
|
304
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
|
+
},
|
|
305
900
|
commands: {
|
|
306
901
|
auth: {
|
|
307
902
|
description: "Authenticate with FreshBooks via OAuth2",
|
|
308
|
-
usage: "fb auth",
|
|
309
|
-
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
|
+
}
|
|
310
924
|
},
|
|
311
925
|
log: {
|
|
312
|
-
description: "Log a time entry
|
|
926
|
+
description: "Log a time entry",
|
|
313
927
|
usage: "fb log",
|
|
314
|
-
interactive:
|
|
928
|
+
interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
|
|
315
929
|
flags: {
|
|
316
|
-
"--client" => "
|
|
317
|
-
"--project" => "
|
|
318
|
-
"--service" => "
|
|
319
|
-
"--duration" => "Duration in hours
|
|
320
|
-
"--note" => "Work description",
|
|
321
|
-
"--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)",
|
|
322
936
|
"--yes" => "Skip confirmation prompt"
|
|
323
937
|
}
|
|
324
938
|
},
|
|
@@ -329,16 +943,64 @@ module FB
|
|
|
329
943
|
"--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
|
|
330
944
|
"--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
|
|
331
945
|
"--month" => "Month (1-12, defaults to current)",
|
|
332
|
-
"--year" => "Year (defaults to current)"
|
|
333
|
-
"--format" => "Output format: table (default) or json"
|
|
946
|
+
"--year" => "Year (defaults to current)"
|
|
334
947
|
}
|
|
335
948
|
},
|
|
336
|
-
|
|
337
|
-
description: "
|
|
338
|
-
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",
|
|
339
981
|
flags: {
|
|
340
|
-
"--
|
|
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"
|
|
341
999
|
}
|
|
1000
|
+
},
|
|
1001
|
+
help: {
|
|
1002
|
+
description: "Show help information",
|
|
1003
|
+
usage: "fb help [COMMAND]"
|
|
342
1004
|
}
|
|
343
1005
|
}
|
|
344
1006
|
}
|
data/lib/fb/version.rb
CHANGED