freshbooks-cli 0.3.2 → 0.4.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreshBooks
4
+ module CLI
5
+ module Spinner
6
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
7
+
8
+ @interactive = nil
9
+
10
+ def self.interactive=(value)
11
+ @interactive = value
12
+ end
13
+
14
+ def self.interactive?
15
+ return @interactive unless @interactive.nil?
16
+ $stderr.tty?
17
+ end
18
+
19
+ def self.spin(message)
20
+ result = nil
21
+
22
+ unless interactive?
23
+ result = yield
24
+ return result
25
+ end
26
+
27
+ done = false
28
+ thread = Thread.new do
29
+ i = 0
30
+ while !done
31
+ $stderr.print "\r#{FRAMES[i % FRAMES.length]} #{message}"
32
+ $stderr.flush
33
+ i += 1
34
+ sleep 0.08
35
+ end
36
+ end
37
+
38
+ begin
39
+ result = yield
40
+ ensure
41
+ done = true
42
+ thread.join
43
+ $stderr.print "\r✓ #{message}\n"
44
+ end
45
+
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreshBooks
4
+ module CLI
5
+ VERSION = "0.4.0"
6
+ end
7
+ end
data/lib/freshbooks.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "freshbooks/version"
4
+ require_relative "freshbooks/spinner"
5
+ require_relative "freshbooks/auth"
6
+ require_relative "freshbooks/api"
7
+ require_relative "freshbooks/cli"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshbooks-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - parasquid
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-04 00:00:00.000000000 Z
11
+ date: 2026-04-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -44,6 +44,20 @@ dependencies:
44
44
  - - "<"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '1.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: dotenv
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.1'
47
61
  - !ruby/object:Gem::Dependency
48
62
  name: rspec
49
63
  requirement: !ruby/object:Gem::Requirement
@@ -96,12 +110,12 @@ extensions: []
96
110
  extra_rdoc_files: []
97
111
  files:
98
112
  - bin/fb
99
- - lib/fb.rb
100
- - lib/fb/api.rb
101
- - lib/fb/auth.rb
102
- - lib/fb/cli.rb
103
- - lib/fb/spinner.rb
104
- - lib/fb/version.rb
113
+ - lib/freshbooks.rb
114
+ - lib/freshbooks/api.rb
115
+ - lib/freshbooks/auth.rb
116
+ - lib/freshbooks/cli.rb
117
+ - lib/freshbooks/spinner.rb
118
+ - lib/freshbooks/version.rb
105
119
  homepage: https://github.com/parasquid/freshbooks-cli
106
120
  licenses:
107
121
  - GPL-3.0-only
data/lib/fb/api.rb DELETED
@@ -1,301 +0,0 @@
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
- # --- 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
-
94
- # --- Clients ---
95
-
96
- def fetch_clients(force: false)
97
- unless force
98
- cached = cached_data("clients_data")
99
- return cached if cached
100
- end
101
-
102
- url = "#{BASE}/accounting/account/#{account_id}/users/clients"
103
- results = fetch_all_pages(url, "clients")
104
- update_cache("clients_data", results)
105
- results
106
- end
107
-
108
- # --- Projects ---
109
-
110
- def fetch_projects(force: false)
111
- unless force
112
- cached = cached_data("projects_data")
113
- return cached if cached
114
- end
115
-
116
- url = "#{BASE}/projects/business/#{business_id}/projects"
117
- results = fetch_all_pages(url, "projects")
118
- update_cache("projects_data", results)
119
- results
120
- end
121
-
122
- def fetch_projects_for_client(client_id)
123
- all = fetch_projects
124
- all.select { |p| p["client_id"].to_i == client_id.to_i }
125
- end
126
-
127
- # --- Services ---
128
-
129
- def fetch_services(force: false)
130
- unless force
131
- cached = cached_data("services_data")
132
- return cached if cached
133
- end
134
-
135
- url = "#{BASE}/comments/business/#{business_id}/services"
136
- response = HTTParty.get(url, { headers: headers })
137
-
138
- unless response.success?
139
- body = response.parsed_response
140
- msg = extract_error(body) || response.body
141
- abort("API error: #{msg}")
142
- end
143
-
144
- data = response.parsed_response
145
- services_hash = data.dig("result", "services") || {}
146
- results = services_hash.values
147
- update_cache("services_data", results)
148
- results
149
- end
150
-
151
- # --- Time Entries ---
152
-
153
- def fetch_time_entries(started_from: nil, started_to: nil)
154
- url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
155
- params = {}
156
- params["started_from"] = "#{started_from}T00:00:00Z" if started_from
157
- params["started_to"] = "#{started_to}T23:59:59Z" if started_to
158
- fetch_all_pages(url, "time_entries", params: params)
159
- end
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
-
175
- def create_time_entry(entry)
176
- url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
177
- body = { time_entry: entry }
178
-
179
- response = HTTParty.post(url, {
180
- headers: headers,
181
- body: body.to_json
182
- })
183
-
184
- unless response.success?
185
- body = response.parsed_response
186
- msg = extract_error(body) || response.body
187
- abort("API error: #{msg}")
188
- end
189
-
190
- response.parsed_response
191
- end
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
-
225
- # --- Name Resolution (for entries display) ---
226
-
227
- def build_name_maps
228
- cache = Auth.load_cache
229
- now = Time.now.to_i
230
-
231
- if cache["updated_at"] && (now - cache["updated_at"]) < 600 &&
232
- cache["clients"] && !cache["clients"].empty?
233
- return {
234
- clients: (cache["clients"] || {}),
235
- projects: (cache["projects"] || {}),
236
- services: (cache["services"] || {})
237
- }
238
- end
239
-
240
- clients = fetch_clients(force: true)
241
- projects = fetch_projects(force: true)
242
- services = fetch_services(force: true)
243
-
244
- client_map = {}
245
- clients.each do |c|
246
- name = c["organization"]
247
- name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
248
- client_map[c["id"].to_s] = name
249
- end
250
-
251
- project_map = {}
252
- projects.each do |p|
253
- project_map[p["id"].to_s] = p["title"]
254
- end
255
-
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)
274
-
275
- { clients: client_map, projects: project_map, services: service_map }
276
- end
277
-
278
- private
279
-
280
- def extract_error(body)
281
- return nil unless body.is_a?(Hash)
282
- body["error_description"] ||
283
- body.dig("response", "errors", 0, "message") ||
284
- body.dig("error") ||
285
- body.dig("message")
286
- end
287
-
288
- def dig_results(data, key)
289
- data.dig("result", key) ||
290
- data.dig("response", "result", key) ||
291
- data.dig(key)
292
- end
293
-
294
- def dig_meta(data)
295
- data.dig("result", "meta") ||
296
- data.dig("response", "result", "meta") ||
297
- data.dig("meta")
298
- end
299
- end
300
- end
301
- end