freshbooks-cli 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d293f888f52e04f38fd9c325292b83ab1373d3587ca615e48e3c428c9eb4c27d
4
+ data.tar.gz: 0b7b59a6bcde4b4511019cb72b3f3518b92b96d3b2db4f53176f31f0ad20d888
5
+ SHA512:
6
+ metadata.gz: b7668df61766607e2d14eafc157772e673d86c8353a7161bdc0452fd3f33c00cbb57b2975d60c62d0ad317fbf22fc934cd060299b4292d010cd192f13aa64746
7
+ data.tar.gz: 8329f4f4d3d76efd1d9d808cce0fff2d018be1791f93725d9df14ed1ca1c106aa80e3ce6f45c2e4d9853fcc1836484d28c3c6a2e25409cb93266e9b717e81f8c
data/bin/fb ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fb"
5
+
6
+ FB::Cli.start(ARGV)
data/lib/fb/api.rb ADDED
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "json"
5
+
6
+ module FB
7
+ class Api
8
+ BASE = "https://api.freshbooks.com"
9
+
10
+ class << self
11
+ def headers
12
+ token = Auth.valid_access_token
13
+ {
14
+ "Authorization" => "Bearer #{token}",
15
+ "Content-Type" => "application/json"
16
+ }
17
+ end
18
+
19
+ def config
20
+ @config = nil
21
+ @config = Auth.require_config
22
+ end
23
+
24
+ def business_id
25
+ c = config
26
+ unless c["business_id"]
27
+ c = Auth.require_business(c)
28
+ end
29
+ c["business_id"]
30
+ end
31
+
32
+ def account_id
33
+ c = config
34
+ unless c["account_id"]
35
+ c = Auth.require_business(c)
36
+ end
37
+ c["account_id"]
38
+ end
39
+
40
+ # --- Paginated fetch ---
41
+
42
+ def fetch_all_pages(url, result_key, params: {})
43
+ page = 1
44
+ all_items = []
45
+
46
+ loop do
47
+ response = HTTParty.get(url, {
48
+ headers: headers,
49
+ query: params.merge(page: page, per_page: 100)
50
+ })
51
+
52
+ unless response.success?
53
+ body = response.parsed_response
54
+ msg = extract_error(body) || response.body
55
+ abort("API error: #{msg}")
56
+ end
57
+
58
+ data = response.parsed_response
59
+ items = dig_results(data, result_key)
60
+ break if items.nil? || items.empty?
61
+
62
+ all_items.concat(items)
63
+
64
+ meta = dig_meta(data)
65
+ break if meta.nil?
66
+ break if page >= meta["pages"].to_i
67
+
68
+ page += 1
69
+ end
70
+
71
+ all_items
72
+ end
73
+
74
+ # --- Clients ---
75
+
76
+ def fetch_clients
77
+ url = "#{BASE}/accounting/account/#{account_id}/users/clients"
78
+ fetch_all_pages(url, "clients")
79
+ end
80
+
81
+ # --- Projects ---
82
+
83
+ def fetch_projects
84
+ url = "#{BASE}/projects/business/#{business_id}/projects"
85
+ fetch_all_pages(url, "projects")
86
+ end
87
+
88
+ def fetch_projects_for_client(client_id)
89
+ all = fetch_projects
90
+ all.select { |p| p["client_id"].to_i == client_id.to_i }
91
+ end
92
+
93
+ # --- Services ---
94
+
95
+ def fetch_services
96
+ url = "#{BASE}/comments/business/#{business_id}/services"
97
+ response = HTTParty.get(url, { headers: headers })
98
+
99
+ unless response.success?
100
+ body = response.parsed_response
101
+ msg = extract_error(body) || response.body
102
+ abort("API error: #{msg}")
103
+ end
104
+
105
+ data = response.parsed_response
106
+ services_hash = data.dig("result", "services") || {}
107
+ services_hash.values
108
+ end
109
+
110
+ # --- Time Entries ---
111
+
112
+ def fetch_time_entries(started_from: nil, started_to: nil)
113
+ url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
114
+ params = {}
115
+ params["started_from"] = "#{started_from}T00:00:00Z" if started_from
116
+ params["started_to"] = "#{started_to}T23:59:59Z" if started_to
117
+ fetch_all_pages(url, "time_entries", params: params)
118
+ end
119
+
120
+ def create_time_entry(entry)
121
+ url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
122
+ body = { time_entry: entry }
123
+
124
+ response = HTTParty.post(url, {
125
+ headers: headers,
126
+ body: body.to_json
127
+ })
128
+
129
+ unless response.success?
130
+ body = response.parsed_response
131
+ msg = extract_error(body) || response.body
132
+ abort("API error: #{msg}")
133
+ end
134
+
135
+ response.parsed_response
136
+ end
137
+
138
+ # --- Name Resolution (for entries display) ---
139
+
140
+ def build_name_maps
141
+ cache = Auth.load_cache
142
+ now = Time.now.to_i
143
+
144
+ if cache["updated_at"] && (now - cache["updated_at"]) < 600
145
+ return {
146
+ clients: (cache["clients"] || {}),
147
+ projects: (cache["projects"] || {})
148
+ }
149
+ end
150
+
151
+ clients = fetch_clients
152
+ projects = fetch_projects
153
+
154
+ client_map = {}
155
+ clients.each do |c|
156
+ name = c["organization"]
157
+ name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
158
+ client_map[c["id"].to_s] = name
159
+ end
160
+
161
+ project_map = {}
162
+ projects.each do |p|
163
+ project_map[p["id"].to_s] = p["title"]
164
+ end
165
+
166
+ cache_data = {
167
+ "updated_at" => now,
168
+ "clients" => client_map,
169
+ "projects" => project_map
170
+ }
171
+ Auth.save_cache(cache_data)
172
+
173
+ { clients: client_map, projects: project_map }
174
+ end
175
+
176
+ private
177
+
178
+ def extract_error(body)
179
+ return nil unless body.is_a?(Hash)
180
+ body["error_description"] ||
181
+ body.dig("response", "errors", 0, "message") ||
182
+ body.dig("error") ||
183
+ body.dig("message")
184
+ end
185
+
186
+ def dig_results(data, key)
187
+ data.dig("result", key) ||
188
+ data.dig("response", "result", key) ||
189
+ data.dig(key)
190
+ end
191
+
192
+ def dig_meta(data)
193
+ data.dig("result", "meta") ||
194
+ data.dig("response", "result", "meta") ||
195
+ data.dig("meta")
196
+ end
197
+ end
198
+ end
199
+ end
data/lib/fb/auth.rb ADDED
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "json"
5
+ require "uri"
6
+ require "fileutils"
7
+
8
+ module FB
9
+ class Auth
10
+ TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
11
+ AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
12
+ ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
13
+ REDIRECT_URI = "https://localhost"
14
+ REQUIRED_SCOPES = %w[
15
+ user:profile:read
16
+ user:clients:read
17
+ user:projects:read
18
+ user:billable_items:read
19
+ user:time_entries:read
20
+ user:time_entries:write
21
+ ].freeze
22
+
23
+ class << self
24
+ def data_dir
25
+ @data_dir ||= File.join(Dir.home, ".fb")
26
+ end
27
+
28
+ def data_dir=(path)
29
+ @data_dir = path
30
+ end
31
+
32
+ def config_path
33
+ File.join(data_dir, "config.json")
34
+ end
35
+
36
+ def tokens_path
37
+ File.join(data_dir, "tokens.json")
38
+ end
39
+
40
+ def defaults_path
41
+ File.join(data_dir, "defaults.json")
42
+ end
43
+
44
+ def cache_path
45
+ File.join(data_dir, "cache.json")
46
+ end
47
+
48
+ def ensure_data_dir
49
+ FileUtils.mkdir_p(data_dir)
50
+ end
51
+
52
+ # --- Config ---
53
+
54
+ def load_config
55
+ return nil unless File.exist?(config_path)
56
+ contents = File.read(config_path).strip
57
+ return nil if contents.empty?
58
+ config = JSON.parse(contents)
59
+ return nil unless config["client_id"] && config["client_secret"]
60
+ config
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ def save_config(config)
66
+ ensure_data_dir
67
+ File.write(config_path, JSON.pretty_generate(config) + "\n")
68
+ end
69
+
70
+ def setup_config
71
+ puts "Welcome to FreshBooks CLI setup!\n\n"
72
+ puts "You need a FreshBooks Developer App. Create one at:"
73
+ puts " https://my.freshbooks.com/#/developer\n\n"
74
+ puts "Set the redirect URI to: #{REDIRECT_URI}\n\n"
75
+ puts "Required scopes:"
76
+ puts " user:profile:read (enabled by default)"
77
+ puts " user:clients:read"
78
+ puts " user:projects:read"
79
+ puts " user:billable_items:read"
80
+ puts " user:time_entries:read"
81
+ puts " user:time_entries:write\n\n"
82
+
83
+ print "Client ID: "
84
+ client_id = $stdin.gets&.strip
85
+ abort("Aborted.") if client_id.nil? || client_id.empty?
86
+
87
+ print "Client Secret: "
88
+ client_secret = $stdin.gets&.strip
89
+ abort("Aborted.") if client_secret.nil? || client_secret.empty?
90
+
91
+ config = { "client_id" => client_id, "client_secret" => client_secret }
92
+ save_config(config)
93
+ puts "\nConfig saved to #{config_path}"
94
+ config
95
+ end
96
+
97
+ def require_config
98
+ config = load_config
99
+ return config if config
100
+
101
+ puts "No config found. Let's set up FreshBooks CLI.\n\n"
102
+ setup_config
103
+ end
104
+
105
+ # --- Tokens ---
106
+
107
+ def load_tokens
108
+ return nil unless File.exist?(tokens_path)
109
+ JSON.parse(File.read(tokens_path))
110
+ end
111
+
112
+ def save_tokens(tokens)
113
+ ensure_data_dir
114
+ File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
115
+ end
116
+
117
+ def token_expired?(tokens)
118
+ return true unless tokens
119
+ created = tokens["created_at"] || 0
120
+ expires_in = tokens["expires_in"] || 0
121
+ Time.now.to_i >= (created + expires_in - 60)
122
+ end
123
+
124
+ def refresh_token!(config, tokens)
125
+ response = HTTParty.post(TOKEN_URL, {
126
+ headers: { "Content-Type" => "application/json" },
127
+ body: {
128
+ grant_type: "refresh_token",
129
+ client_id: config["client_id"],
130
+ client_secret: config["client_secret"],
131
+ redirect_uri: REDIRECT_URI,
132
+ refresh_token: tokens["refresh_token"]
133
+ }.to_json
134
+ })
135
+
136
+ unless response.success?
137
+ body = response.parsed_response
138
+ msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
139
+ abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
140
+ end
141
+
142
+ data = response.parsed_response
143
+ new_tokens = {
144
+ "access_token" => data["access_token"],
145
+ "refresh_token" => data["refresh_token"],
146
+ "expires_in" => data["expires_in"],
147
+ "created_at" => Time.now.to_i
148
+ }
149
+ save_tokens(new_tokens)
150
+ new_tokens
151
+ end
152
+
153
+ def valid_access_token
154
+ config = require_config
155
+ tokens = load_tokens
156
+
157
+ unless tokens
158
+ puts "Not authenticated yet. Starting auth flow...\n\n"
159
+ tokens = authorize(config)
160
+ discover_business(tokens["access_token"], config)
161
+ end
162
+
163
+ if token_expired?(tokens)
164
+ puts "Token expired, refreshing..."
165
+ tokens = refresh_token!(config, tokens)
166
+ end
167
+
168
+ tokens["access_token"]
169
+ end
170
+
171
+ def require_business(config)
172
+ return config if config["business_id"] && config["account_id"]
173
+
174
+ tokens = load_tokens
175
+ unless tokens
176
+ puts "Not authenticated yet. Starting auth flow...\n\n"
177
+ tokens = authorize(config)
178
+ end
179
+
180
+ discover_business(tokens["access_token"], config)
181
+ end
182
+
183
+ # --- OAuth Flow ---
184
+
185
+ def authorize(config)
186
+ url = "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
187
+
188
+ puts "Open this URL in your browser:\n\n"
189
+ puts " #{url}\n\n"
190
+ puts "After authorizing, you'll be redirected to a URL that fails to load."
191
+ puts "Copy the full URL from your browser's address bar and paste it here.\n\n"
192
+
193
+ print "Redirect URL: "
194
+ redirect_url = $stdin.gets&.strip
195
+ abort("Aborted.") if redirect_url.nil? || redirect_url.empty?
196
+
197
+ uri = URI.parse(redirect_url)
198
+ params = URI.decode_www_form(uri.query || "").to_h
199
+ code = params["code"]
200
+
201
+ abort("Could not find 'code' parameter in the URL.") unless code
202
+
203
+ exchange_code(config, code)
204
+ end
205
+
206
+ def exchange_code(config, code)
207
+ response = HTTParty.post(TOKEN_URL, {
208
+ headers: { "Content-Type" => "application/json" },
209
+ body: {
210
+ grant_type: "authorization_code",
211
+ client_id: config["client_id"],
212
+ client_secret: config["client_secret"],
213
+ redirect_uri: REDIRECT_URI,
214
+ code: code
215
+ }.to_json
216
+ })
217
+
218
+ unless response.success?
219
+ body = response.parsed_response
220
+ msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
221
+ abort("Token exchange failed: #{msg}")
222
+ end
223
+
224
+ data = response.parsed_response
225
+ check_scopes(data["scope"])
226
+
227
+ tokens = {
228
+ "access_token" => data["access_token"],
229
+ "refresh_token" => data["refresh_token"],
230
+ "expires_in" => data["expires_in"],
231
+ "created_at" => Time.now.to_i
232
+ }
233
+ save_tokens(tokens)
234
+ puts "Authentication successful!"
235
+ tokens
236
+ end
237
+
238
+ def check_scopes(granted_scope)
239
+ return if granted_scope.nil? # skip check if API doesn't return scopes
240
+
241
+ granted = granted_scope.split(" ")
242
+ missing = REQUIRED_SCOPES - granted
243
+
244
+ return if missing.empty?
245
+
246
+ puts "ERROR: Your FreshBooks app is missing required scopes:\n\n"
247
+ missing.each { |s| puts " - #{s}" }
248
+ puts "\nAdd them at https://my.freshbooks.com/#/developer"
249
+ puts "then re-run: fb auth"
250
+ abort
251
+ end
252
+
253
+ # --- Business Discovery ---
254
+
255
+ def fetch_identity(access_token)
256
+ response = HTTParty.get(ME_URL, {
257
+ headers: { "Authorization" => "Bearer #{access_token}" }
258
+ })
259
+
260
+ unless response.success?
261
+ abort("Failed to fetch user identity: #{response.body}")
262
+ end
263
+
264
+ response.parsed_response["response"]
265
+ end
266
+
267
+ def discover_business(access_token, config)
268
+ identity = fetch_identity(access_token)
269
+ memberships = identity.dig("business_memberships") || []
270
+ businesses = memberships.select { |m| m.dig("business", "account_id") }
271
+
272
+ if businesses.empty?
273
+ abort("No business memberships found on your FreshBooks account.")
274
+ end
275
+
276
+ selected = if businesses.length == 1
277
+ businesses.first
278
+ else
279
+ puts "\nMultiple businesses found:\n\n"
280
+ businesses.each_with_index do |m, i|
281
+ biz = m["business"]
282
+ puts " #{i + 1}. #{biz["name"]} (ID: #{biz["id"]})"
283
+ end
284
+ print "\nSelect a business (1-#{businesses.length}): "
285
+ choice = $stdin.gets&.strip&.to_i || 1
286
+ choice = 1 if choice < 1 || choice > businesses.length
287
+ businesses[choice - 1]
288
+ end
289
+
290
+ biz = selected["business"]
291
+ config["business_id"] = biz["id"]
292
+ config["account_id"] = biz["account_id"]
293
+ save_config(config)
294
+
295
+ puts "Business: #{biz["name"]}"
296
+ puts " business_id: #{biz["id"]}"
297
+ puts " account_id: #{biz["account_id"]}"
298
+ config
299
+ end
300
+
301
+ # --- Defaults ---
302
+
303
+ def load_defaults
304
+ return {} unless File.exist?(defaults_path)
305
+ JSON.parse(File.read(defaults_path))
306
+ rescue JSON::ParserError
307
+ {}
308
+ end
309
+
310
+ def save_defaults(defaults)
311
+ ensure_data_dir
312
+ File.write(defaults_path, JSON.pretty_generate(defaults) + "\n")
313
+ end
314
+
315
+ # --- Cache ---
316
+
317
+ def load_cache
318
+ return {} unless File.exist?(cache_path)
319
+ JSON.parse(File.read(cache_path))
320
+ rescue JSON::ParserError
321
+ {}
322
+ end
323
+
324
+ def save_cache(cache)
325
+ ensure_data_dir
326
+ File.write(cache_path, JSON.pretty_generate(cache) + "\n")
327
+ end
328
+ end
329
+ end
330
+ end
data/lib/fb/cli.rb ADDED
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "date"
6
+
7
+ module FB
8
+ class Cli < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ # --- auth ---
14
+
15
+ desc "auth", "Authenticate with FreshBooks via OAuth2"
16
+ def auth
17
+ config = Auth.require_config
18
+ tokens = Auth.authorize(config)
19
+ Auth.discover_business(tokens["access_token"], config)
20
+ puts "\nReady to go! Try: fb entries"
21
+ end
22
+
23
+ # --- log ---
24
+
25
+ desc "log", "Log a time entry"
26
+ method_option :client, type: :string, desc: "Pre-select client by name"
27
+ method_option :project, type: :string, desc: "Pre-select project by name"
28
+ method_option :service, type: :string, desc: "Pre-select service by name"
29
+ method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
30
+ method_option :note, type: :string, desc: "Work description"
31
+ method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
32
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
33
+ def log
34
+ Auth.require_config
35
+ defaults = Auth.load_defaults
36
+ interactive = !(options[:client] && options[:duration] && options[:note])
37
+
38
+ client = select_client(defaults, interactive)
39
+ project = select_project(client["id"], defaults, interactive)
40
+ service = select_service(defaults, interactive)
41
+ date = pick_date(interactive)
42
+ duration_hours = pick_duration(interactive)
43
+ note = pick_note(interactive)
44
+
45
+ client_name = display_name(client)
46
+
47
+ puts "\n--- Time Entry Summary ---"
48
+ puts " Client: #{client_name}"
49
+ puts " Project: #{project ? project["title"] : "(none)"}"
50
+ puts " Service: #{service ? service["name"] : "(none)"}"
51
+ puts " Date: #{date}"
52
+ puts " Duration: #{duration_hours}h"
53
+ puts " Note: #{note}"
54
+ puts "--------------------------\n\n"
55
+
56
+ unless options[:yes]
57
+ print "Submit? (Y/n): "
58
+ answer = $stdin.gets&.strip&.downcase
59
+ abort("Cancelled.") if answer == "n"
60
+ end
61
+
62
+ entry = {
63
+ "is_logged" => true,
64
+ "duration" => (duration_hours * 3600).to_i,
65
+ "note" => note,
66
+ "started_at" => date,
67
+ "client_id" => client["id"]
68
+ }
69
+ entry["project_id"] = project["id"] if project
70
+ entry["service_id"] = service["id"] if service
71
+
72
+ Api.create_time_entry(entry)
73
+ puts "Time entry created!"
74
+
75
+ new_defaults = { "client_id" => client["id"] }
76
+ new_defaults["project_id"] = project["id"] if project
77
+ new_defaults["service_id"] = service["id"] if service
78
+ Auth.save_defaults(new_defaults)
79
+ end
80
+
81
+ # --- entries ---
82
+
83
+ desc "entries", "List time entries (defaults to current month)"
84
+ method_option :month, type: :numeric, desc: "Month (1-12)"
85
+ method_option :year, type: :numeric, desc: "Year"
86
+ method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
87
+ 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
+ def entries
90
+ Auth.require_config
91
+
92
+ today = Date.today
93
+
94
+ if options[:from] || options[:to]
95
+ first_day = options[:from] ? Date.parse(options[:from]) : nil
96
+ last_day = options[:to] ? Date.parse(options[:to]) : nil
97
+ else
98
+ month = options[:month] || today.month
99
+ year = options[:year] || today.year
100
+ first_day = Date.new(year, month, 1)
101
+ last_day = Date.new(year, month, -1)
102
+ end
103
+
104
+ label = if first_day && last_day
105
+ "#{first_day} to #{last_day}"
106
+ elsif first_day
107
+ "from #{first_day} onwards"
108
+ elsif last_day
109
+ "up to #{last_day}"
110
+ end
111
+
112
+ entries = Spinner.spin("Fetching time entries#{label ? " (#{label})" : ""}") do
113
+ Api.fetch_time_entries(
114
+ started_from: first_day&.to_s,
115
+ started_to: last_day&.to_s
116
+ )
117
+ end
118
+
119
+ if entries.empty?
120
+ puts "No time entries#{label ? " #{label}" : ""}."
121
+ return
122
+ end
123
+
124
+ if options[:format] == "json"
125
+ puts JSON.pretty_generate(entries)
126
+ return
127
+ end
128
+
129
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
130
+ entries.sort_by! { |e| e["started_at"] || "" }
131
+
132
+ rows = entries.map do |e|
133
+ date = e["started_at"] || "?"
134
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
135
+ project = maps[:projects][e["project_id"].to_s] || "-"
136
+ note = (e["note"] || "").slice(0, 50)
137
+ hours = (e["duration"].to_i / 3600.0).round(2)
138
+ [date, client, project, note, "#{hours}h"]
139
+ end
140
+
141
+ headers = ["Date", "Client", "Project", "Note", "Duration"]
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 }
150
+
151
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
152
+ puts "\nTotal: #{total.round(2)}h"
153
+ end
154
+
155
+ # --- help ---
156
+
157
+ desc "help [COMMAND]", "Describe available commands or one specific command"
158
+ method_option :format, type: :string, desc: "Output format: text (default) or json"
159
+ def help(command = nil)
160
+ if options[:format] == "json"
161
+ puts JSON.pretty_generate(help_json)
162
+ return
163
+ end
164
+ super
165
+ end
166
+
167
+ private
168
+
169
+ def select_client(defaults, interactive)
170
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
171
+
172
+ if options[:client]
173
+ match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
174
+ abort("Client not found: #{options[:client]}") unless match
175
+ return match
176
+ end
177
+
178
+ abort("No clients found.") if clients.empty?
179
+
180
+ puts "\nClients:\n\n"
181
+ clients.each_with_index do |c, i|
182
+ name = display_name(c)
183
+ default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
184
+ puts " #{i + 1}. #{name}#{default_marker}"
185
+ end
186
+
187
+ default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
188
+ prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
189
+ print prompt
190
+ input = $stdin.gets&.strip
191
+
192
+ idx = if input.nil? || input.empty?
193
+ default_idx || 0
194
+ else
195
+ input.to_i - 1
196
+ end
197
+
198
+ abort("Invalid selection.") if idx < 0 || idx >= clients.length
199
+ clients[idx]
200
+ end
201
+
202
+ def select_project(client_id, defaults, interactive)
203
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
204
+
205
+ if options[:project]
206
+ match = projects.find { |p| p["title"].downcase == options[:project].downcase }
207
+ abort("Project not found: #{options[:project]}") unless match
208
+ return match
209
+ end
210
+
211
+ return nil if projects.empty?
212
+
213
+ puts "\nProjects:\n\n"
214
+ projects.each_with_index do |p, i|
215
+ default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
216
+ puts " #{i + 1}. #{p["title"]}#{default_marker}"
217
+ end
218
+
219
+ default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
220
+ prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
221
+ print prompt
222
+ input = $stdin.gets&.strip
223
+
224
+ if input.nil? || input.empty?
225
+ return default_idx ? projects[default_idx] : nil
226
+ end
227
+
228
+ idx = input.to_i - 1
229
+ return nil if idx < 0 || idx >= projects.length
230
+ projects[idx]
231
+ end
232
+
233
+ def select_service(defaults, interactive)
234
+ if options[:service]
235
+ services = Spinner.spin("Fetching services") { Api.fetch_services }
236
+ match = services.find { |s| s["name"].downcase == options[:service].downcase }
237
+ abort("Service not found: #{options[:service]}") unless match
238
+ return match
239
+ end
240
+
241
+ return nil unless interactive
242
+
243
+ services = Spinner.spin("Fetching services") { Api.fetch_services }
244
+ return nil if services.empty?
245
+
246
+ puts "\nServices:\n\n"
247
+ services.each_with_index do |s, i|
248
+ default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
249
+ puts " #{i + 1}. #{s["name"]}#{default_marker}"
250
+ end
251
+
252
+ default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
253
+ prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
254
+ print prompt
255
+ input = $stdin.gets&.strip
256
+
257
+ if input.nil? || input.empty?
258
+ return default_idx ? services[default_idx] : nil
259
+ end
260
+
261
+ idx = input.to_i - 1
262
+ return nil if idx < 0 || idx >= services.length
263
+ services[idx]
264
+ end
265
+
266
+ def pick_date(interactive)
267
+ return options[:date] if options[:date]
268
+
269
+ today = Date.today.to_s
270
+ return today unless interactive
271
+
272
+ print "\nDate [#{today}]: "
273
+ input = $stdin.gets&.strip
274
+ (input.nil? || input.empty?) ? today : input
275
+ end
276
+
277
+ def pick_duration(interactive)
278
+ return options[:duration] if options[:duration]
279
+
280
+ print "\nDuration (hours): "
281
+ input = $stdin.gets&.strip
282
+ abort("Duration is required.") if input.nil? || input.empty?
283
+ input.to_f
284
+ end
285
+
286
+ def pick_note(interactive)
287
+ return options[:note] if options[:note]
288
+
289
+ print "\nNote: "
290
+ input = $stdin.gets&.strip
291
+ abort("Note is required.") if input.nil? || input.empty?
292
+ input
293
+ end
294
+
295
+ def display_name(client)
296
+ name = client["organization"]
297
+ (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
298
+ end
299
+
300
+ def help_json
301
+ {
302
+ name: "fb",
303
+ description: "FreshBooks time tracking CLI",
304
+ required_scopes: Auth::REQUIRED_SCOPES,
305
+ commands: {
306
+ auth: {
307
+ description: "Authenticate with FreshBooks via OAuth2",
308
+ usage: "fb auth",
309
+ interactive: true
310
+ },
311
+ log: {
312
+ description: "Log a time entry (interactive prompts with defaults from last use)",
313
+ usage: "fb log",
314
+ interactive: true,
315
+ flags: {
316
+ "--client" => "Pre-select client by name (skip prompt)",
317
+ "--project" => "Pre-select project by name (skip prompt)",
318
+ "--service" => "Pre-select service by name (skip prompt)",
319
+ "--duration" => "Duration in hours (e.g. 2.5)",
320
+ "--note" => "Work description",
321
+ "--date" => "Date (YYYY-MM-DD, defaults to today)",
322
+ "--yes" => "Skip confirmation prompt"
323
+ }
324
+ },
325
+ entries: {
326
+ description: "List time entries (defaults to current month)",
327
+ usage: "fb entries",
328
+ flags: {
329
+ "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
330
+ "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
331
+ "--month" => "Month (1-12, defaults to current)",
332
+ "--year" => "Year (defaults to current)",
333
+ "--format" => "Output format: table (default) or json"
334
+ }
335
+ },
336
+ help: {
337
+ description: "Show help information",
338
+ usage: "fb help [COMMAND]",
339
+ flags: {
340
+ "--format" => "Output format: text (default) or json"
341
+ }
342
+ }
343
+ }
344
+ }
345
+ end
346
+ end
347
+ end
data/lib/fb/spinner.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FB
4
+ module Spinner
5
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
6
+
7
+ def self.spin(message)
8
+ done = false
9
+ result = nil
10
+
11
+ thread = Thread.new do
12
+ i = 0
13
+ while !done
14
+ print "\r#{FRAMES[i % FRAMES.length]} #{message}"
15
+ $stdout.flush
16
+ i += 1
17
+ sleep 0.08
18
+ end
19
+ end
20
+
21
+ begin
22
+ result = yield
23
+ ensure
24
+ done = true
25
+ thread.join
26
+ print "\r✓ #{message}\n"
27
+ end
28
+
29
+ result
30
+ end
31
+ end
32
+ end
data/lib/fb/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FB
4
+ VERSION = "0.1.0"
5
+ end
data/lib/fb.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fb/version"
4
+ require_relative "fb/spinner"
5
+ require_relative "fb/auth"
6
+ require_relative "fb/api"
7
+ require_relative "fb/cli"
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: freshbooks-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - parasquid
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: httparty
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.24'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '1.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0.24'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ description: Manage FreshBooks time entries from the command line. Supports OAuth2
47
+ auth, interactive time logging with defaults, and monthly entry listings.
48
+ email:
49
+ - git@parasquid.com
50
+ executables:
51
+ - fb
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - bin/fb
56
+ - lib/fb.rb
57
+ - lib/fb/api.rb
58
+ - lib/fb/auth.rb
59
+ - lib/fb/cli.rb
60
+ - lib/fb/spinner.rb
61
+ - lib/fb/version.rb
62
+ homepage: https://github.com/parasquid/freshbooks-cli
63
+ licenses:
64
+ - GPL-3.0-only
65
+ metadata:
66
+ source_code_uri: https://github.com/parasquid/freshbooks-cli
67
+ bug_tracker_uri: https://github.com/parasquid/freshbooks-cli/issues
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.3
83
+ specification_version: 4
84
+ summary: FreshBooks time tracking CLI
85
+ test_files: []