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