truenorth 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/truenorth/cli.rb +117 -10
- data/lib/truenorth/client.rb +60 -5
- data/lib/truenorth/version.rb +1 -1
- data/truenorth.gemspec +2 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 947c02ebdef94353524d6b1c62e4be1283aed2a296f1712257a1d2b183b241d9
|
|
4
|
+
data.tar.gz: 670aa44189ee7935e117d9a4b5936a4441caaf7c8618734b43683667f6bf99d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab5f4ce7a698a3419d1278d64f50cff501960508b47d2d176e3ce845c22ad0329de6256a545354a8046535eda31e710f554faf81c0df62789155fcebeaac9bf8
|
|
7
|
+
data.tar.gz: 51808fa039dcdf6c7a49eff96da6554ae7ad3de6193f8d1ddac191d51a49d73cab491862fad094d746c7d06a2cf532ffa3e8e32817daae33cb7d1797cd0fa510
|
data/lib/truenorth/cli.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'thor'
|
|
4
4
|
require 'date'
|
|
5
|
+
require 'tty-table'
|
|
6
|
+
require 'io/console'
|
|
5
7
|
require_relative '../truenorth'
|
|
6
8
|
|
|
7
9
|
module Truenorth
|
|
@@ -107,11 +109,7 @@ module Truenorth
|
|
|
107
109
|
if results.empty?
|
|
108
110
|
say 'No reservations found.', :yellow
|
|
109
111
|
else
|
|
110
|
-
|
|
111
|
-
results.each do |res|
|
|
112
|
-
parts = [res[:date], res[:time], res[:activity], res[:court], res[:member]].compact
|
|
113
|
-
say " #{parts.join(' - ')}"
|
|
114
|
-
end
|
|
112
|
+
display_reservations_table(results)
|
|
115
113
|
end
|
|
116
114
|
end
|
|
117
115
|
rescue Error => e
|
|
@@ -119,15 +117,41 @@ module Truenorth
|
|
|
119
117
|
exit 1
|
|
120
118
|
end
|
|
121
119
|
|
|
122
|
-
desc 'cancel
|
|
120
|
+
desc 'cancel INDEX', 'Cancel a reservation by index (from reservations list)'
|
|
123
121
|
option :dry_run, type: :boolean, aliases: '-n', desc: 'Test without actually canceling'
|
|
124
|
-
def cancel(
|
|
122
|
+
def cancel(index)
|
|
125
123
|
client = Client.new
|
|
126
124
|
|
|
127
|
-
say
|
|
128
|
-
client.
|
|
125
|
+
say 'Fetching reservations...', :cyan
|
|
126
|
+
results = client.reservations
|
|
127
|
+
|
|
128
|
+
idx = index.to_i - 1
|
|
129
|
+
if idx < 0 || idx >= results.length
|
|
130
|
+
say "Invalid index. Must be between 1 and #{results.length}", :red
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
reservation = results[idx]
|
|
135
|
+
unless reservation[:cancel_id]
|
|
136
|
+
say 'This reservation cannot be cancelled (no cancel button found)', :red
|
|
137
|
+
exit 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
say "\nCancelling:", :cyan
|
|
141
|
+
say " #{format_reservation_line(reservation)}"
|
|
129
142
|
|
|
130
|
-
|
|
143
|
+
result = client.cancel(reservation[:cancel_id], dry_run: options[:dry_run])
|
|
144
|
+
|
|
145
|
+
if result[:success]
|
|
146
|
+
if result[:dry_run]
|
|
147
|
+
say "\nDry run successful - would cancel this reservation", :yellow
|
|
148
|
+
else
|
|
149
|
+
say "\n✓ Reservation cancelled!", :green
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
say "\nFailed: #{result[:error]}", :red
|
|
153
|
+
exit 1
|
|
154
|
+
end
|
|
131
155
|
rescue Error => e
|
|
132
156
|
say "Error: #{e.message}", :red
|
|
133
157
|
exit 1
|
|
@@ -164,6 +188,89 @@ module Truenorth
|
|
|
164
188
|
|
|
165
189
|
private
|
|
166
190
|
|
|
191
|
+
def display_reservations_table(results)
|
|
192
|
+
# Get terminal width
|
|
193
|
+
term_width = begin
|
|
194
|
+
IO.console.winsize[1]
|
|
195
|
+
rescue StandardError
|
|
196
|
+
80
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Prepare table data
|
|
200
|
+
rows = results.each_with_index.map do |res, idx|
|
|
201
|
+
[
|
|
202
|
+
(idx + 1).to_s,
|
|
203
|
+
format_compact_date(res[:date]),
|
|
204
|
+
format_compact_time(res[:time]),
|
|
205
|
+
truncate_text(res[:activity] || '', 25),
|
|
206
|
+
truncate_text(res[:member] || '', 15)
|
|
207
|
+
]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Create table
|
|
211
|
+
table = TTY::Table.new(
|
|
212
|
+
header: ['#', 'Date', 'Time', 'Activity', 'Member'],
|
|
213
|
+
rows: rows
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Render with auto-width based on terminal
|
|
217
|
+
renderer = if term_width < 100
|
|
218
|
+
:basic
|
|
219
|
+
else
|
|
220
|
+
:unicode
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
puts table.render(renderer, padding: [0, 1])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def format_reservation_line(res)
|
|
227
|
+
"#{format_compact_date(res[:date])} #{format_compact_time(res[:time])} - #{res[:activity]} - #{res[:member]}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def format_compact_date(date_str)
|
|
231
|
+
# Convert "02/11/2026" to "02-11"
|
|
232
|
+
return '' unless date_str
|
|
233
|
+
|
|
234
|
+
date = Date.strptime(date_str, '%m/%d/%Y')
|
|
235
|
+
date.strftime('%m-%d')
|
|
236
|
+
rescue StandardError
|
|
237
|
+
date_str
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def format_compact_time(time_str)
|
|
241
|
+
# Convert "09:00 AM - 09:45 AM" to "09:00 (45min)"
|
|
242
|
+
return '' unless time_str
|
|
243
|
+
|
|
244
|
+
times = time_str.scan(/(\d{1,2}):(\d{2})\s*([AP]M)/)
|
|
245
|
+
return time_str if times.length < 2
|
|
246
|
+
|
|
247
|
+
start_h, start_m, start_period = times[0]
|
|
248
|
+
end_h, end_m, end_period = times[1]
|
|
249
|
+
|
|
250
|
+
# Convert to 24h format for calculation
|
|
251
|
+
start_24h = start_h.to_i
|
|
252
|
+
start_24h += 12 if start_period == 'PM' && start_24h != 12
|
|
253
|
+
start_24h = 0 if start_period == 'AM' && start_24h == 12
|
|
254
|
+
|
|
255
|
+
end_24h = end_h.to_i
|
|
256
|
+
end_24h += 12 if end_period == 'PM' && end_24h != 12
|
|
257
|
+
end_24h = 0 if end_period == 'AM' && end_24h == 12
|
|
258
|
+
|
|
259
|
+
# Calculate duration in minutes
|
|
260
|
+
duration = (end_24h * 60 + end_m.to_i) - (start_24h * 60 + start_m.to_i)
|
|
261
|
+
|
|
262
|
+
"#{start_h.rjust(2, '0')}:#{start_m} (#{duration}min)"
|
|
263
|
+
rescue StandardError
|
|
264
|
+
time_str
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def truncate_text(text, max_length)
|
|
268
|
+
return '' unless text
|
|
269
|
+
return text if text.length <= max_length
|
|
270
|
+
|
|
271
|
+
"#{text[0...max_length - 1]}…"
|
|
272
|
+
end
|
|
273
|
+
|
|
167
274
|
def parse_date(date_str)
|
|
168
275
|
return Date.today if date_str.nil? || date_str.empty?
|
|
169
276
|
|
data/lib/truenorth/client.rb
CHANGED
|
@@ -217,7 +217,7 @@ module Truenorth
|
|
|
217
217
|
html.css('script').remove
|
|
218
218
|
|
|
219
219
|
# Reservations are grouped by member in dt.ui-datalist-item elements
|
|
220
|
-
html.css('dt.ui-datalist-item').
|
|
220
|
+
html.css('dt.ui-datalist-item').each_with_index do |member_section, member_idx|
|
|
221
221
|
# Extract member name from the header
|
|
222
222
|
# Format: "Siegel, Jonathan's Reservations (50)" or "My Reservations(5)"
|
|
223
223
|
header_text = member_section.text.gsub(/\s+/, ' ').strip
|
|
@@ -229,7 +229,7 @@ module Truenorth
|
|
|
229
229
|
end
|
|
230
230
|
|
|
231
231
|
# Now find all tables within this member section
|
|
232
|
-
member_section.css('table tbody tr').
|
|
232
|
+
member_section.css('table tbody tr').each_with_index do |row, row_idx|
|
|
233
233
|
cells = row.css('td')
|
|
234
234
|
next if cells.length < 2
|
|
235
235
|
|
|
@@ -237,10 +237,17 @@ module Truenorth
|
|
|
237
237
|
text_parts = cells.map { |cell| clean_cell_text(cell) }
|
|
238
238
|
next if text_parts.all?(&:empty?)
|
|
239
239
|
|
|
240
|
+
# Find the cancel button link (title="Cancel Reservation")
|
|
241
|
+
cancel_link = row.at_css('a[title="Cancel Reservation"]')
|
|
242
|
+
cancel_id = cancel_link['id'] if cancel_link
|
|
243
|
+
|
|
240
244
|
# Parse the reservation data
|
|
241
245
|
reservation = parse_reservation_row(text_parts)
|
|
242
246
|
if reservation && reservation[:date]
|
|
243
247
|
reservation[:member] = member_name
|
|
248
|
+
reservation[:member_idx] = member_idx
|
|
249
|
+
reservation[:row_idx] = row_idx
|
|
250
|
+
reservation[:cancel_id] = cancel_id
|
|
244
251
|
reservations << reservation
|
|
245
252
|
end
|
|
246
253
|
end
|
|
@@ -257,9 +264,56 @@ module Truenorth
|
|
|
257
264
|
end
|
|
258
265
|
|
|
259
266
|
# Cancel a reservation
|
|
267
|
+
# reservation_id is the cancel button ID from the reservation
|
|
260
268
|
def cancel(reservation_id, dry_run: false)
|
|
261
269
|
ensure_logged_in!
|
|
262
|
-
|
|
270
|
+
|
|
271
|
+
log "\n=== CANCEL RESERVATION ==="
|
|
272
|
+
log "Cancel ID: #{reservation_id}"
|
|
273
|
+
log 'DRY RUN MODE' if dry_run
|
|
274
|
+
|
|
275
|
+
return { success: true, dry_run: true, message: 'Dry run - would cancel reservation' } if dry_run
|
|
276
|
+
|
|
277
|
+
# Get the reservations page to extract form state
|
|
278
|
+
response = get(RESERVATIONS_PATH)
|
|
279
|
+
html = Nokogiri::HTML(response.body)
|
|
280
|
+
|
|
281
|
+
view_state = extract_view_state(html)
|
|
282
|
+
form_id = '_memberReservations_WAR_northstarportlet_:reservationsForm'
|
|
283
|
+
|
|
284
|
+
raise BookingError, 'Could not extract view state' unless view_state
|
|
285
|
+
|
|
286
|
+
# Build the cancel AJAX request
|
|
287
|
+
ajax_url = "#{@base_url}#{RESERVATIONS_PATH}?p_p_id=memberReservations_WAR_northstarportlet" \
|
|
288
|
+
'&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view' \
|
|
289
|
+
'&p_p_cacheability=cacheLevelPage' \
|
|
290
|
+
'&_memberReservations_WAR_northstarportlet__jsfBridgeAjax=true'
|
|
291
|
+
|
|
292
|
+
form_data = {
|
|
293
|
+
'javax.faces.partial.ajax' => 'true',
|
|
294
|
+
'javax.faces.source' => reservation_id,
|
|
295
|
+
'javax.faces.partial.execute' => '@all',
|
|
296
|
+
'javax.faces.partial.render' => form_id,
|
|
297
|
+
form_id => form_id,
|
|
298
|
+
'javax.faces.ViewState' => view_state,
|
|
299
|
+
reservation_id => reservation_id
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
response = post_ajax(ajax_url, form_data)
|
|
303
|
+
|
|
304
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
305
|
+
# Check for success indicators in response
|
|
306
|
+
body = response.body
|
|
307
|
+
if body.include?('success') || body.include?('cancel') || body.length < 1000
|
|
308
|
+
log 'Cancellation successful'
|
|
309
|
+
{ success: true, message: 'Reservation cancelled' }
|
|
310
|
+
else
|
|
311
|
+
log "Unexpected response: #{body[0..200]}"
|
|
312
|
+
{ success: false, error: 'Unexpected response from server' }
|
|
313
|
+
end
|
|
314
|
+
else
|
|
315
|
+
{ success: false, error: "HTTP #{response.code}" }
|
|
316
|
+
end
|
|
263
317
|
end
|
|
264
318
|
|
|
265
319
|
private
|
|
@@ -349,8 +403,9 @@ module Truenorth
|
|
|
349
403
|
cell1 = text_parts[1]
|
|
350
404
|
|
|
351
405
|
# Extract activity/event type and details
|
|
352
|
-
# Format: "Activities (Court 2 | Squash)" or "Events (Event Name | Category)"
|
|
353
|
-
|
|
406
|
+
# Format: "Activities (Court 2 | Squash)" or "Events (Event Name (time) | Category)"
|
|
407
|
+
# Use a greedy match that stops before the date pattern
|
|
408
|
+
activity_match = cell1.match(/(Activities|Events)\s+\((.+?)\)\s*\d{2}\/\d{2}\/\d{4}/)
|
|
354
409
|
activity = nil
|
|
355
410
|
court = nil
|
|
356
411
|
|
data/lib/truenorth/version.rb
CHANGED
data/truenorth.gemspec
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |spec|
|
|
4
4
|
spec.name = 'truenorth'
|
|
5
|
-
spec.version = '0.
|
|
5
|
+
spec.version = '0.2.0'
|
|
6
6
|
spec.authors = ['usiegj00']
|
|
7
7
|
spec.email = ['112138+usiegj00@users.noreply.github.com']
|
|
8
8
|
|
|
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
|
|
|
30
30
|
spec.add_dependency 'base64', '~> 0.2'
|
|
31
31
|
spec.add_dependency 'nokogiri', '~> 1.15'
|
|
32
32
|
spec.add_dependency 'thor', '~> 1.3'
|
|
33
|
+
spec.add_dependency 'tty-table', '~> 0.12'
|
|
33
34
|
|
|
34
35
|
spec.add_development_dependency 'bundler', '~> 2.0'
|
|
35
36
|
spec.add_development_dependency 'rake', '~> 13.0'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truenorth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- usiegj00
|
|
@@ -52,6 +52,20 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '1.3'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: tty-table
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.12'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.12'
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
70
|
name: bundler
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|