truenorth 0.5.0 → 0.6.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/truenorth/browser_client.rb +765 -0
- data/lib/truenorth/cli.rb +6 -2
- data/lib/truenorth/client.rb +37 -15
- data/lib/truenorth/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf116b555b67f39c34c1579a5d1212ee86d26c33a2d1bc88bf681d6145728b2f
|
|
4
|
+
data.tar.gz: d6ed28ab2f74be19204f331e1c37a4c925c021c00689c33d0b0a56880abdb9b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '079000943acd8e2f5a8cd278c5c20f52fe400d2df7189e6baa9aa4b64c7c814fa07f614af06fb1c6251fa302b125f8d1beb3cfa84b58287fdb04b8265706df7b'
|
|
7
|
+
data.tar.gz: 77a071e50db72b2c239f6b1d1beba278ff8e728aea8cd955f9a73bdd13d4535493b1412e3527fc9ebeff9fc964a8dbcff31eae2f2bf9addbf77a523965004756
|
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ferrum'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module Truenorth
|
|
7
|
+
# Browser-based client using Ferrum for JavaScript-heavy operations
|
|
8
|
+
class BrowserClient
|
|
9
|
+
attr_reader :browser, :debug
|
|
10
|
+
|
|
11
|
+
def initialize(base_url: nil, debug: false)
|
|
12
|
+
@base_url = base_url || Config.base_url
|
|
13
|
+
raise Error, 'No base URL configured. Run: truenorth configure' unless @base_url
|
|
14
|
+
|
|
15
|
+
@debug = debug
|
|
16
|
+
@browser = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
@browser = Ferrum::Browser.new(
|
|
21
|
+
headless: !@debug,
|
|
22
|
+
timeout: 30,
|
|
23
|
+
window_size: [1920, 1080],
|
|
24
|
+
browser_options: {
|
|
25
|
+
'no-sandbox': nil,
|
|
26
|
+
'window-size': '1920,1080'
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
log 'Browser started'
|
|
30
|
+
|
|
31
|
+
# Auto-accept any alert dialogs (e.g., "Please create reservation for the future")
|
|
32
|
+
@browser.on(:dialog) { |dialog| dialog.accept }
|
|
33
|
+
|
|
34
|
+
# Maximize window in debug mode
|
|
35
|
+
if @debug && @browser
|
|
36
|
+
@browser.resize(width: 1920, height: 1080)
|
|
37
|
+
log 'Browser window resized to 1920x1080'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def quit
|
|
42
|
+
@browser&.quit
|
|
43
|
+
log 'Browser quit'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def login
|
|
47
|
+
start unless @browser
|
|
48
|
+
|
|
49
|
+
# Try using cached cookies first
|
|
50
|
+
cookies = Config.cookies
|
|
51
|
+
if !cookies.empty?
|
|
52
|
+
log "Loading #{cookies.length} cached cookies"
|
|
53
|
+
@browser.go_to(@base_url)
|
|
54
|
+
|
|
55
|
+
cookies.each do |name, value|
|
|
56
|
+
@browser.cookies.set(
|
|
57
|
+
name: name,
|
|
58
|
+
value: value,
|
|
59
|
+
domain: URI.parse(@base_url).host
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Test if cookies work
|
|
64
|
+
@browser.go_to("#{@base_url}/group/pages/facility-booking")
|
|
65
|
+
sleep 2
|
|
66
|
+
|
|
67
|
+
if @browser.body.include?('Sign Out') || @browser.title.include?('Facility Booking')
|
|
68
|
+
log 'Logged in with cached cookies'
|
|
69
|
+
return true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
log 'Cached cookies expired, logging in...'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Login via HTTP client to get fresh cookies
|
|
76
|
+
http_client = Client.new(base_url: @base_url, debug: @debug)
|
|
77
|
+
http_client.login
|
|
78
|
+
|
|
79
|
+
# Get cookies from HTTP client
|
|
80
|
+
@browser.go_to(@base_url)
|
|
81
|
+
http_client.cookies.each do |name, value|
|
|
82
|
+
@browser.cookies.set(
|
|
83
|
+
name: name,
|
|
84
|
+
value: value,
|
|
85
|
+
domain: URI.parse(@base_url).host
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
log 'Logged in via HTTP client, cookies transferred to browser'
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def availability(date, activity: 'squash')
|
|
94
|
+
login unless @browser
|
|
95
|
+
|
|
96
|
+
log "\n=== BROWSER GET AVAILABILITY ==="
|
|
97
|
+
log "Date: #{date}, Activity: #{activity}"
|
|
98
|
+
|
|
99
|
+
navigate_to_date_and_activity(date, activity)
|
|
100
|
+
|
|
101
|
+
# Parse the availability table
|
|
102
|
+
slots = parse_availability_table
|
|
103
|
+
log "Found #{slots.count} time slots across all courts"
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
success: true,
|
|
107
|
+
date: date.to_s,
|
|
108
|
+
activity: activity,
|
|
109
|
+
slots: slots
|
|
110
|
+
}
|
|
111
|
+
ensure
|
|
112
|
+
quit
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false)
|
|
116
|
+
login unless @browser
|
|
117
|
+
|
|
118
|
+
log "\n=== BROWSER BOOK SLOT ==="
|
|
119
|
+
log "Time: #{time}, Date: #{date}, Court: #{court || 'any'}, Activity: #{activity}"
|
|
120
|
+
log 'DRY RUN MODE' if dry_run
|
|
121
|
+
|
|
122
|
+
navigate_to_date_and_activity(date, activity)
|
|
123
|
+
|
|
124
|
+
# Find and click the slot (dispatches mousedown+mouseup, triggers reservation AJAX)
|
|
125
|
+
slot_info = find_and_click_slot(time, court, date)
|
|
126
|
+
raise BookingError, "No slot available at #{time}" unless slot_info
|
|
127
|
+
|
|
128
|
+
log "Clicked slot: #{slot_info[:court]} at #{slot_info[:time]}"
|
|
129
|
+
|
|
130
|
+
# Check if reservation panel appeared with Save button
|
|
131
|
+
panel_state = @browser.evaluate(<<~JS)
|
|
132
|
+
(function() {
|
|
133
|
+
var panel = document.querySelector('[id*="reservationPanel"]');
|
|
134
|
+
var saveBtn = document.querySelector('.btn-save');
|
|
135
|
+
var fromTime = document.querySelector('select[name*="fromTime_input"]');
|
|
136
|
+
return {
|
|
137
|
+
hasPanel: !!panel,
|
|
138
|
+
panelVisible: panel ? panel.offsetWidth > 0 : false,
|
|
139
|
+
hasSaveBtn: !!saveBtn,
|
|
140
|
+
saveBtnVisible: saveBtn ? saveBtn.offsetWidth > 0 : false,
|
|
141
|
+
saveBtnId: saveBtn ? saveBtn.id : null,
|
|
142
|
+
fromTime: fromTime ? fromTime.value : null
|
|
143
|
+
};
|
|
144
|
+
})()
|
|
145
|
+
JS
|
|
146
|
+
log "Reservation panel: #{panel_state.inspect}"
|
|
147
|
+
|
|
148
|
+
unless panel_state['saveBtnVisible']
|
|
149
|
+
log 'Save button not visible after slot click'
|
|
150
|
+
if @debug
|
|
151
|
+
@browser.screenshot(path: '/tmp/no_save_btn.png', full: true)
|
|
152
|
+
log 'Saved screenshot to /tmp/no_save_btn.png'
|
|
153
|
+
end
|
|
154
|
+
raise BookingError, 'Reservation panel did not appear after clicking slot'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if dry_run
|
|
158
|
+
log 'Dry run - not clicking Save'
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
dry_run: true,
|
|
162
|
+
court: slot_info[:court],
|
|
163
|
+
time: slot_info[:time],
|
|
164
|
+
message: "Dry run - reservation panel opened for #{slot_info[:court]} at #{slot_info[:time]}"
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Click the Save button
|
|
169
|
+
log 'Clicking Save button...'
|
|
170
|
+
save_btn_id = panel_state['saveBtnId']
|
|
171
|
+
@browser.evaluate(<<~JS)
|
|
172
|
+
(function() {
|
|
173
|
+
var btn = document.getElementById('#{save_btn_id}');
|
|
174
|
+
if (btn) btn.click();
|
|
175
|
+
})()
|
|
176
|
+
JS
|
|
177
|
+
|
|
178
|
+
# Wait for save AJAX to complete
|
|
179
|
+
wait_for_ajax(timeout: 10)
|
|
180
|
+
sleep 2
|
|
181
|
+
|
|
182
|
+
# Check for success or error
|
|
183
|
+
result_state = @browser.evaluate(<<~JS)
|
|
184
|
+
(function() {
|
|
185
|
+
var body = document.body.textContent;
|
|
186
|
+
|
|
187
|
+
// Check for PrimeFaces error messages
|
|
188
|
+
var msgs = document.querySelectorAll('.ui-messages-error, .ui-message-error');
|
|
189
|
+
var errors = [];
|
|
190
|
+
for (var i = 0; i < msgs.length; i++) {
|
|
191
|
+
var txt = msgs[i].textContent.trim();
|
|
192
|
+
if (txt) errors.push(txt);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check PrimeFaces Growl notifications (success/error popups)
|
|
196
|
+
var growlMsgs = document.querySelectorAll('.ui-growl-message, .ui-growl-item');
|
|
197
|
+
var growls = [];
|
|
198
|
+
for (var i = 0; i < growlMsgs.length; i++) {
|
|
199
|
+
var summary = growlMsgs[i].querySelector('.ui-growl-title');
|
|
200
|
+
var detail = growlMsgs[i].querySelector('.ui-growl-message');
|
|
201
|
+
var text = (summary ? summary.textContent : '') + ' ' + (detail ? detail.textContent : '');
|
|
202
|
+
if (!text.trim()) text = growlMsgs[i].textContent.trim();
|
|
203
|
+
if (text.trim()) growls.push(text.trim());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check if reservation panel is gone (success) or still showing (error)
|
|
207
|
+
var panel = document.querySelector('[id*="reservationPanel"]');
|
|
208
|
+
var panelVisible = panel ? panel.offsetWidth > 0 : false;
|
|
209
|
+
var saveBtn = document.querySelector('.btn-save');
|
|
210
|
+
var saveBtnVisible = saveBtn ? saveBtn.offsetWidth > 0 : false;
|
|
211
|
+
|
|
212
|
+
// Check for Back button (visible means we're still on the reservation form)
|
|
213
|
+
var backBtn = document.querySelector('.btn-back');
|
|
214
|
+
var backBtnVisible = backBtn ? backBtn.offsetWidth > 0 : false;
|
|
215
|
+
|
|
216
|
+
var bodyLower = body.toLowerCase();
|
|
217
|
+
return {
|
|
218
|
+
errors: errors,
|
|
219
|
+
growls: growls,
|
|
220
|
+
panelStillVisible: panelVisible,
|
|
221
|
+
saveBtnStillVisible: saveBtnVisible,
|
|
222
|
+
backBtnStillVisible: backBtnVisible,
|
|
223
|
+
hasConfirmation: bodyLower.indexOf('confirmed') > -1 ||
|
|
224
|
+
bodyLower.indexOf('success') > -1 ||
|
|
225
|
+
bodyLower.indexOf('booked') > -1 ||
|
|
226
|
+
bodyLower.indexOf('reservation has been') > -1
|
|
227
|
+
};
|
|
228
|
+
})()
|
|
229
|
+
JS
|
|
230
|
+
log "After save: #{result_state.inspect}"
|
|
231
|
+
|
|
232
|
+
if @debug
|
|
233
|
+
@browser.screenshot(path: '/tmp/after_save.png', full: true)
|
|
234
|
+
log 'Saved screenshot to /tmp/after_save.png'
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if result_state['errors'].any?
|
|
238
|
+
raise BookingError, "Booking failed: #{result_state['errors'].join(', ')}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Check growl messages for errors
|
|
242
|
+
growl_errors = result_state['growls'].select { |g| g =~ /error|fail|unable|invalid/i }
|
|
243
|
+
if growl_errors.any?
|
|
244
|
+
raise BookingError, "Booking failed: #{growl_errors.join(', ')}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Success indicators:
|
|
248
|
+
# 1. Save button disappeared (panel closed after successful save)
|
|
249
|
+
# 2. Confirmation text in body or growl
|
|
250
|
+
# 3. Back button gone (returned to slot view)
|
|
251
|
+
growl_success = result_state['growls'].any? { |g| g =~ /success|confirm|booked|reserved/i }
|
|
252
|
+
|
|
253
|
+
if !result_state['saveBtnStillVisible'] || result_state['hasConfirmation'] || growl_success
|
|
254
|
+
confirmation_msg = result_state['growls'].first || 'Booking confirmed'
|
|
255
|
+
log "Booking confirmed: #{confirmation_msg}"
|
|
256
|
+
{
|
|
257
|
+
success: true,
|
|
258
|
+
court: slot_info[:court],
|
|
259
|
+
time: slot_info[:time],
|
|
260
|
+
confirmation: confirmation_msg
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
log 'Booking status uncertain - check My Reservations'
|
|
264
|
+
{
|
|
265
|
+
success: false,
|
|
266
|
+
error: 'Booking uncertain - please check My Reservations'
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
ensure
|
|
270
|
+
quit
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def reservations
|
|
274
|
+
login unless @browser
|
|
275
|
+
|
|
276
|
+
log "\n=== BROWSER GET RESERVATIONS ==="
|
|
277
|
+
|
|
278
|
+
@browser.go_to("#{@base_url}/group/pages/my-reservations")
|
|
279
|
+
sleep 3
|
|
280
|
+
|
|
281
|
+
reservations = parse_reservations_table
|
|
282
|
+
log "Found #{reservations.count} reservations"
|
|
283
|
+
|
|
284
|
+
reservations
|
|
285
|
+
ensure
|
|
286
|
+
quit
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def cancel(reservation_id, dry_run: false)
|
|
290
|
+
login unless @browser
|
|
291
|
+
|
|
292
|
+
log "\n=== BROWSER CANCEL RESERVATION ==="
|
|
293
|
+
log "Cancel ID: #{reservation_id}"
|
|
294
|
+
log 'DRY RUN MODE' if dry_run
|
|
295
|
+
|
|
296
|
+
return { success: true, dry_run: true, message: 'Dry run - would cancel reservation' } if dry_run
|
|
297
|
+
|
|
298
|
+
@browser.go_to("#{@base_url}/group/pages/my-reservations")
|
|
299
|
+
sleep 3
|
|
300
|
+
|
|
301
|
+
# Click the cancel button
|
|
302
|
+
log "Clicking cancel button: #{reservation_id}"
|
|
303
|
+
@browser.execute(<<~JS, reservation_id)
|
|
304
|
+
(function() {
|
|
305
|
+
var cancelBtn = document.getElementById(arguments[0]);
|
|
306
|
+
if (cancelBtn) {
|
|
307
|
+
cancelBtn.click();
|
|
308
|
+
} else {
|
|
309
|
+
throw new Error('Cancel button not found');
|
|
310
|
+
}
|
|
311
|
+
})()
|
|
312
|
+
JS
|
|
313
|
+
|
|
314
|
+
# Wait for confirmation dialog
|
|
315
|
+
sleep 2
|
|
316
|
+
|
|
317
|
+
# Click YES in the confirmation dialog
|
|
318
|
+
log 'Clicking YES to confirm cancellation'
|
|
319
|
+
@browser.execute(<<~JS)
|
|
320
|
+
(function() {
|
|
321
|
+
var yesBtn = document.querySelector('a.ui-area-btn-danger, button.ui-area-btn-danger');
|
|
322
|
+
if (yesBtn && yesBtn.textContent.indexOf('YES') > -1) {
|
|
323
|
+
yesBtn.click();
|
|
324
|
+
} else {
|
|
325
|
+
throw new Error('YES button not found');
|
|
326
|
+
}
|
|
327
|
+
})()
|
|
328
|
+
JS
|
|
329
|
+
|
|
330
|
+
# Wait for cancellation to complete
|
|
331
|
+
sleep 3
|
|
332
|
+
|
|
333
|
+
# Check for success
|
|
334
|
+
success_msg = @browser.evaluate("document.body.textContent")
|
|
335
|
+
if success_msg.include?('cancelled') || success_msg.include?('canceled') || success_msg.include?('success')
|
|
336
|
+
log 'Cancellation confirmed'
|
|
337
|
+
{ success: true, message: 'Reservation cancelled' }
|
|
338
|
+
else
|
|
339
|
+
{ success: false, error: 'Cancellation uncertain - please verify' }
|
|
340
|
+
end
|
|
341
|
+
ensure
|
|
342
|
+
quit
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def navigate_to_date_and_activity(date, activity)
|
|
348
|
+
@browser.go_to("#{@base_url}/group/pages/facility-booking")
|
|
349
|
+
sleep 3
|
|
350
|
+
|
|
351
|
+
activity_id = Client::ACTIVITIES[activity.to_s.downcase] || '5'
|
|
352
|
+
|
|
353
|
+
# Step 1: Change activity via PrimeFaces SelectOneMenu widget (triggers AJAX)
|
|
354
|
+
# This updates the server-side activity. Do NOT reload - that resets to default (golf).
|
|
355
|
+
select_activity_ui(activity_id)
|
|
356
|
+
wait_for_ajax
|
|
357
|
+
|
|
358
|
+
# Step 2: Navigate to correct date - the dateSelect AJAX renders the table
|
|
359
|
+
# with the correct activity from the session updated in step 1
|
|
360
|
+
navigate_to_date(date, force: true)
|
|
361
|
+
wait_for_ajax
|
|
362
|
+
|
|
363
|
+
# No extra sheetDate fix needed - the Calendar widget properly updates the server
|
|
364
|
+
|
|
365
|
+
# Verify correct activity courts are shown
|
|
366
|
+
verify = @browser.evaluate(<<~JS)
|
|
367
|
+
(function() {
|
|
368
|
+
var slots = document.querySelectorAll('td.slot div[data-area-id]');
|
|
369
|
+
var areaIds = {};
|
|
370
|
+
for (var i = 0; i < slots.length; i++) {
|
|
371
|
+
var aid = slots[i].getAttribute('data-area-id');
|
|
372
|
+
areaIds[aid] = (areaIds[aid] || 0) + 1;
|
|
373
|
+
}
|
|
374
|
+
return { totalSlots: slots.length, areaIds: areaIds };
|
|
375
|
+
})()
|
|
376
|
+
JS
|
|
377
|
+
log "After navigation - slots: #{verify['totalSlots']}, areas: #{verify['areaIds']}"
|
|
378
|
+
|
|
379
|
+
log "Navigated to #{activity} for #{date}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def wait_for_ajax(timeout: 15)
|
|
383
|
+
timeout.times do |i|
|
|
384
|
+
sleep 1
|
|
385
|
+
idle = @browser.evaluate(<<~JS)
|
|
386
|
+
(function() {
|
|
387
|
+
try {
|
|
388
|
+
if (typeof PrimeFaces !== 'undefined' && PrimeFaces.ajax) {
|
|
389
|
+
if (PrimeFaces.ajax.Queue && PrimeFaces.ajax.Queue.isEmpty) {
|
|
390
|
+
return PrimeFaces.ajax.Queue.isEmpty();
|
|
391
|
+
}
|
|
392
|
+
// Older PrimeFaces versions
|
|
393
|
+
if (PrimeFaces.ajax.QUEUE && PrimeFaces.ajax.QUEUE.isEmpty) {
|
|
394
|
+
return PrimeFaces.ajax.QUEUE.isEmpty();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return true;
|
|
398
|
+
} catch(e) { return true; }
|
|
399
|
+
})()
|
|
400
|
+
JS
|
|
401
|
+
if idle
|
|
402
|
+
log "AJAX completed after #{i + 1} seconds" if @debug
|
|
403
|
+
break
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
sleep 1 # Extra buffer for DOM rendering
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def select_activity_ui(activity_id)
|
|
410
|
+
log "Selecting activity ID: #{activity_id}"
|
|
411
|
+
|
|
412
|
+
# Use PrimeFaces SelectOneMenu widget's selectItem method (non-silent)
|
|
413
|
+
# This fires the change behavior AJAX, properly updating the server session
|
|
414
|
+
result = @browser.evaluate(<<~JS)
|
|
415
|
+
(function() {
|
|
416
|
+
for (var key in PrimeFaces.widgets) {
|
|
417
|
+
var w = PrimeFaces.widgets[key];
|
|
418
|
+
if (!w.input || !w.input[0] || w.input[0].tagName !== 'SELECT') continue;
|
|
419
|
+
var sel = w.input[0];
|
|
420
|
+
|
|
421
|
+
var targetIdx = -1;
|
|
422
|
+
for (var i = 0; i < sel.options.length; i++) {
|
|
423
|
+
if (sel.options[i].value === '#{activity_id}') {
|
|
424
|
+
targetIdx = i;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (targetIdx === -1) continue;
|
|
429
|
+
|
|
430
|
+
var oldValue = sel.value;
|
|
431
|
+
if (oldValue === '#{activity_id}') {
|
|
432
|
+
return { success: true, method: 'already_selected', value: oldValue };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// selectItem without silent flag triggers the change behavior AJAX
|
|
436
|
+
w.selectItem(w.items.eq(targetIdx));
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
success: true, method: 'ui_click',
|
|
440
|
+
oldValue: oldValue, newValue: sel.value,
|
|
441
|
+
targetIdx: targetIdx, widgetKey: key
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return { error: 'widget not found' };
|
|
445
|
+
})()
|
|
446
|
+
JS
|
|
447
|
+
log "Activity select result: #{result.inspect}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def navigate_to_date(date, force: false)
|
|
451
|
+
target_str = date.strftime('%m/%d/%Y')
|
|
452
|
+
|
|
453
|
+
current_date = @browser.evaluate(<<~JS)
|
|
454
|
+
(function() {
|
|
455
|
+
var input = document.querySelector('input[name*="sheetDate"]');
|
|
456
|
+
return input ? input.value : null;
|
|
457
|
+
})()
|
|
458
|
+
JS
|
|
459
|
+
|
|
460
|
+
if current_date == target_str && !force
|
|
461
|
+
log "Already on correct date: #{target_str}"
|
|
462
|
+
return
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
log "Navigating from #{current_date} to #{target_str}"
|
|
466
|
+
|
|
467
|
+
# Use the Calendar widget (j_idt79) which has setDate + dateSelect behavior.
|
|
468
|
+
# This properly updates the server's session date, unlike directly setting
|
|
469
|
+
# the sheetDate input which only updates the display.
|
|
470
|
+
result = @browser.evaluate(<<~JS)
|
|
471
|
+
(function() {
|
|
472
|
+
var parts = '#{target_str}'.split('/');
|
|
473
|
+
var targetDate = new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
|
|
474
|
+
|
|
475
|
+
// Find the Calendar widget with setDate + dateSelect behavior
|
|
476
|
+
for (var key in PrimeFaces.widgets) {
|
|
477
|
+
var w = PrimeFaces.widgets[key];
|
|
478
|
+
if (typeof w.setDate !== 'function') continue;
|
|
479
|
+
if (!w.cfg || !w.cfg.behaviors || !w.cfg.behaviors.dateSelect) continue;
|
|
480
|
+
|
|
481
|
+
w.setDate(targetDate);
|
|
482
|
+
w.cfg.behaviors.dateSelect.call(w, {
|
|
483
|
+
params: [{ name: w.id + '_selectedDate', value: targetDate.getTime() }]
|
|
484
|
+
});
|
|
485
|
+
return { success: true, method: 'setDate+behavior', widgetKey: key };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Fallback: click the day tab link if target is within the visible week
|
|
489
|
+
var dayLinks = document.querySelectorAll('a[id*="j_idt98"]');
|
|
490
|
+
for (var i = 0; i < dayLinks.length; i++) {
|
|
491
|
+
var dayNum = dayLinks[i].textContent.match(/\\b(\\d{1,2})\\b/);
|
|
492
|
+
if (dayNum && parseInt(dayNum[1]) === parseInt(parts[1])) {
|
|
493
|
+
dayLinks[i].click();
|
|
494
|
+
return { success: true, method: 'dayTab_click', day: dayNum[1] };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { error: 'No Calendar widget or matching day tab found' };
|
|
499
|
+
})()
|
|
500
|
+
JS
|
|
501
|
+
log "Date navigation result: #{result.inspect}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def find_and_click_slot(target_time, preferred_court, booking_date = Date.today)
|
|
505
|
+
# Normalize time
|
|
506
|
+
normalized_time = target_time.strip.gsub(/^0/, '').upcase
|
|
507
|
+
|
|
508
|
+
# Only filter past-time slots for today/past dates.
|
|
509
|
+
# The page erroneously applies past-time class to future dates based on current time of day,
|
|
510
|
+
# but the server accepts bookings for those slots fine.
|
|
511
|
+
is_today = booking_date <= Date.today
|
|
512
|
+
slot_selector = is_today ? 'td.slot.open:not(.past-time)' : 'td.slot.open'
|
|
513
|
+
|
|
514
|
+
# Find all open slots
|
|
515
|
+
slots = @browser.evaluate(<<~JS)
|
|
516
|
+
(function() {
|
|
517
|
+
var openSlots = document.querySelectorAll('#{slot_selector}');
|
|
518
|
+
var result = [];
|
|
519
|
+
for (var i = 0; i < openSlots.length; i++) {
|
|
520
|
+
var td = openSlots[i];
|
|
521
|
+
var timeCell = td.parentElement.querySelector('td.interval');
|
|
522
|
+
var time = timeCell ? timeCell.textContent.trim() : '';
|
|
523
|
+
var div = td.querySelector('div[data-start-time]');
|
|
524
|
+
var areaId = div ? div.getAttribute('data-area-id') : null;
|
|
525
|
+
|
|
526
|
+
if (areaId) {
|
|
527
|
+
result.push({
|
|
528
|
+
time: time,
|
|
529
|
+
areaId: areaId,
|
|
530
|
+
startTime: div ? div.getAttribute('data-start-time') : '',
|
|
531
|
+
endTime: div ? div.getAttribute('data-end-time') : ''
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
536
|
+
})()
|
|
537
|
+
JS
|
|
538
|
+
|
|
539
|
+
# Debug: show available times
|
|
540
|
+
unique_times = slots.map { |s| s['startTime'] }.uniq.sort
|
|
541
|
+
log "Available times (#{slots.length} bookable slots): #{unique_times.first(10).join(', ')}#{unique_times.length > 10 ? '...' : ''}"
|
|
542
|
+
log "Looking for: '#{normalized_time}'"
|
|
543
|
+
|
|
544
|
+
# Find matching slot by time display or data-start-time
|
|
545
|
+
matching_slot = slots.find do |slot|
|
|
546
|
+
time_match = slot['time'].gsub(/^0/, '').upcase == normalized_time ||
|
|
547
|
+
slot['startTime'].gsub(/^0/, '').upcase == normalized_time
|
|
548
|
+
court_match = if preferred_court
|
|
549
|
+
court_name = Client::COURTS[slot['areaId']]
|
|
550
|
+
court_name&.downcase&.include?(preferred_court.downcase)
|
|
551
|
+
else
|
|
552
|
+
true
|
|
553
|
+
end
|
|
554
|
+
time_match && court_match
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
return nil unless matching_slot
|
|
558
|
+
|
|
559
|
+
court_name = Client::COURTS[matching_slot['areaId']]
|
|
560
|
+
log "Clicking slot: #{court_name} at #{matching_slot['startTime']}"
|
|
561
|
+
|
|
562
|
+
# Call rc_showReservationScreen directly with slot parameters.
|
|
563
|
+
# This bypasses the client-side past-time check (which erroneously blocks
|
|
564
|
+
# future-date slots) and fires the PrimeFaces AJAX to open the reservation panel.
|
|
565
|
+
click_result = @browser.evaluate(<<~JS)
|
|
566
|
+
(function() {
|
|
567
|
+
rc_showReservationScreen([
|
|
568
|
+
{name: 'activityAreaId', value: '#{matching_slot['areaId']}'},
|
|
569
|
+
{name: 'startTime', value: '#{matching_slot['startTime']}'},
|
|
570
|
+
{name: 'endTime', value: '#{matching_slot['endTime']}'}
|
|
571
|
+
]);
|
|
572
|
+
return { success: true, areaId: '#{matching_slot['areaId']}', startTime: '#{matching_slot['startTime']}' };
|
|
573
|
+
})()
|
|
574
|
+
JS
|
|
575
|
+
log "Slot click result: #{click_result.inspect}"
|
|
576
|
+
|
|
577
|
+
wait_for_ajax
|
|
578
|
+
sleep 1
|
|
579
|
+
|
|
580
|
+
{
|
|
581
|
+
court: court_name,
|
|
582
|
+
time: matching_slot['startTime'],
|
|
583
|
+
area_id: matching_slot['areaId']
|
|
584
|
+
}
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def parse_reservations_table
|
|
589
|
+
reservations = []
|
|
590
|
+
|
|
591
|
+
# Get all reservation sections (by member)
|
|
592
|
+
member_sections = @browser.css('dt.ui-datalist-item')
|
|
593
|
+
|
|
594
|
+
member_sections.each_with_index do |section, member_idx|
|
|
595
|
+
# Extract member name
|
|
596
|
+
header_text = @browser.evaluate("arguments[0].textContent.trim()", section)
|
|
597
|
+
member_name = if header_text.include?("'s Reservations")
|
|
598
|
+
header_text[/^(.+?)'s Reservations/, 1]
|
|
599
|
+
else
|
|
600
|
+
nil
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Get all reservation rows for this member
|
|
604
|
+
rows = @browser.evaluate(<<~JS, section)
|
|
605
|
+
(function() {
|
|
606
|
+
var tables = arguments[0].parentElement.querySelectorAll('table tbody tr');
|
|
607
|
+
var result = [];
|
|
608
|
+
for (var i = 0; i < tables.length; i++) {
|
|
609
|
+
var row = tables[i];
|
|
610
|
+
var cellElements = row.querySelectorAll('td');
|
|
611
|
+
var cells = [];
|
|
612
|
+
for (var j = 0; j < cellElements.length; j++) {
|
|
613
|
+
cells.push(cellElements[j].textContent.trim());
|
|
614
|
+
}
|
|
615
|
+
var cancelLink = row.querySelector('a[title="Cancel Reservation"]');
|
|
616
|
+
var cancelId = cancelLink ? cancelLink.id : null;
|
|
617
|
+
result.push({ cells: cells, cancelId: cancelId, rowIdx: i });
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
})()
|
|
621
|
+
JS
|
|
622
|
+
|
|
623
|
+
rows.each do |row|
|
|
624
|
+
next if row['cells'].length < 2
|
|
625
|
+
|
|
626
|
+
# Parse reservation data
|
|
627
|
+
res_data = parse_reservation_row(row['cells'])
|
|
628
|
+
next unless res_data && res_data[:date]
|
|
629
|
+
|
|
630
|
+
res_data[:member] = member_name
|
|
631
|
+
res_data[:member_idx] = member_idx
|
|
632
|
+
res_data[:row_idx] = row['rowIdx']
|
|
633
|
+
res_data[:cancel_id] = row['cancelId']
|
|
634
|
+
|
|
635
|
+
reservations << res_data
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Sort by date
|
|
640
|
+
reservations.sort_by! do |res|
|
|
641
|
+
Date.strptime(res[:date], '%m/%d/%Y') rescue Date.today
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
reservations
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def parse_reservation_row(text_parts)
|
|
648
|
+
return nil if text_parts.length < 2
|
|
649
|
+
|
|
650
|
+
cell1 = text_parts[1]
|
|
651
|
+
|
|
652
|
+
# Extract activity/court info
|
|
653
|
+
activity_match = cell1.match(/(Activities|Events)\s+\((.+?)\)\s*\d{2}\/\d{2}\/\d{4}/)
|
|
654
|
+
activity = nil
|
|
655
|
+
court = nil
|
|
656
|
+
|
|
657
|
+
if activity_match
|
|
658
|
+
activity_full = activity_match[2]
|
|
659
|
+
if activity_full.include?('|')
|
|
660
|
+
parts = activity_full.split('|').map(&:strip)
|
|
661
|
+
if parts[0] =~ /Court|Training|Room/
|
|
662
|
+
court = parts[0]
|
|
663
|
+
activity = parts[1]
|
|
664
|
+
else
|
|
665
|
+
activity = parts[0]
|
|
666
|
+
court = parts[1] if parts[1]
|
|
667
|
+
end
|
|
668
|
+
else
|
|
669
|
+
activity = activity_full
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Extract dates and times
|
|
674
|
+
dates = cell1.scan(/\b(\d{2}\/\d{2}\/\d{4})\b/).flatten
|
|
675
|
+
times = cell1.scan(/(\d{1,2}:\d{2}\s+[AP]M)/).flatten
|
|
676
|
+
|
|
677
|
+
return nil if dates.empty?
|
|
678
|
+
|
|
679
|
+
date = dates.first
|
|
680
|
+
time = if times.length >= 2
|
|
681
|
+
"#{times[0]} - #{times[1]}"
|
|
682
|
+
elsif times.length == 1
|
|
683
|
+
times[0]
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
{
|
|
687
|
+
date: date,
|
|
688
|
+
time: time,
|
|
689
|
+
activity: activity,
|
|
690
|
+
court: court
|
|
691
|
+
}
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def parse_availability_table
|
|
695
|
+
slots = {}
|
|
696
|
+
|
|
697
|
+
# Get table rows using a safer approach
|
|
698
|
+
rows = @browser.evaluate(<<~JS)
|
|
699
|
+
(function() {
|
|
700
|
+
var tbody = document.querySelector('tbody.ui-datatable-data');
|
|
701
|
+
if (!tbody) return [];
|
|
702
|
+
|
|
703
|
+
var rows = tbody.querySelectorAll('tr');
|
|
704
|
+
var result = [];
|
|
705
|
+
|
|
706
|
+
for (var i = 0; i < rows.length; i++) {
|
|
707
|
+
var row = rows[i];
|
|
708
|
+
var timeCell = row.querySelector('td.interval');
|
|
709
|
+
if (!timeCell) continue;
|
|
710
|
+
|
|
711
|
+
var time = timeCell.textContent.trim();
|
|
712
|
+
if (!time) continue;
|
|
713
|
+
|
|
714
|
+
var courtCells = [];
|
|
715
|
+
var slots = row.querySelectorAll('td.slot');
|
|
716
|
+
|
|
717
|
+
for (var j = 0; j < slots.length; j++) {
|
|
718
|
+
var td = slots[j];
|
|
719
|
+
var classes = td.className || '';
|
|
720
|
+
var isOpen = classes.indexOf('open') > -1;
|
|
721
|
+
var isReserved = classes.indexOf('reserved') > -1;
|
|
722
|
+
var areaDiv = td.querySelector('div[data-area-id]');
|
|
723
|
+
var areaId = areaDiv ? areaDiv.getAttribute('data-area-id') : null;
|
|
724
|
+
|
|
725
|
+
if (isOpen && !isReserved && areaId) {
|
|
726
|
+
courtCells.push({
|
|
727
|
+
areaId: areaId
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (courtCells.length > 0) {
|
|
733
|
+
result.push({
|
|
734
|
+
time: time,
|
|
735
|
+
courts: courtCells
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return result;
|
|
741
|
+
})()
|
|
742
|
+
JS
|
|
743
|
+
|
|
744
|
+
log "Found #{rows.length} time slots with availability"
|
|
745
|
+
|
|
746
|
+
# Process the results
|
|
747
|
+
rows.each do |row_data|
|
|
748
|
+
time = row_data['time']
|
|
749
|
+
row_data['courts'].each do |court|
|
|
750
|
+
area_id = court['areaId']
|
|
751
|
+
court_name = Client::COURTS[area_id] || "Court #{area_id}"
|
|
752
|
+
|
|
753
|
+
slots[time] ||= []
|
|
754
|
+
slots[time] << court_name unless slots[time].include?(court_name)
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
slots
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def log(message)
|
|
762
|
+
puts message if @debug
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
end
|
data/lib/truenorth/cli.rb
CHANGED
|
@@ -12,6 +12,9 @@ module Truenorth
|
|
|
12
12
|
true
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Handle --version flag
|
|
16
|
+
map %w[--version -v] => :version
|
|
17
|
+
|
|
15
18
|
desc 'configure', 'Set up credentials for the booking system'
|
|
16
19
|
option :username, aliases: '-u', desc: 'Member ID (e.g., 12345-00)'
|
|
17
20
|
option :password, aliases: '-p', desc: 'Password'
|
|
@@ -114,9 +117,10 @@ module Truenorth
|
|
|
114
117
|
rescue Error => e
|
|
115
118
|
say "Error: #{e.message}", :red
|
|
116
119
|
|
|
117
|
-
# If no slot available, show nearby available times
|
|
120
|
+
# If no slot available, show nearby available times using HTTP client
|
|
118
121
|
if e.message.include?('No slot available')
|
|
119
|
-
|
|
122
|
+
http_client = Client.new(debug: options[:debug])
|
|
123
|
+
show_nearby_availability(http_client, date, time, activity)
|
|
120
124
|
end
|
|
121
125
|
|
|
122
126
|
exit 1
|
data/lib/truenorth/client.rb
CHANGED
|
@@ -28,23 +28,22 @@ module Truenorth
|
|
|
28
28
|
'meeting' => '8'
|
|
29
29
|
}.freeze
|
|
30
30
|
|
|
31
|
-
# Court area IDs
|
|
31
|
+
# Court area IDs (from page column headers)
|
|
32
32
|
COURTS = {
|
|
33
|
-
'16' => '
|
|
34
|
-
'17' => '
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'32' => 'Court 3'
|
|
33
|
+
'16' => 'Golf Simulator 1',
|
|
34
|
+
'17' => 'Golf Simulator 2',
|
|
35
|
+
'30' => 'Squash Court 1',
|
|
36
|
+
'31' => 'Squash Court 2',
|
|
37
|
+
'32' => 'Squash Court 3'
|
|
39
38
|
}.freeze
|
|
40
39
|
|
|
41
40
|
# Court IDs by activity
|
|
42
41
|
COURT_IDS_BY_ACTIVITY = {
|
|
43
|
-
'squash' => %w[
|
|
44
|
-
'golf' => %w[
|
|
45
|
-
'music' => %w[16 17
|
|
46
|
-
'room' => %w[30 31 32],
|
|
47
|
-
'meeting' => %w[30 31 32]
|
|
42
|
+
'squash' => %w[30 31 32],
|
|
43
|
+
'golf' => %w[16 17],
|
|
44
|
+
'music' => %w[16 17], # May need to update these
|
|
45
|
+
'room' => %w[30 31 32], # May need to update these
|
|
46
|
+
'meeting' => %w[30 31 32]
|
|
48
47
|
}.freeze
|
|
49
48
|
|
|
50
49
|
attr_reader :cookies, :debug_log, :logged_in
|
|
@@ -56,7 +55,8 @@ module Truenorth
|
|
|
56
55
|
@cookies = Config.cookies || {}
|
|
57
56
|
@debug = debug
|
|
58
57
|
@debug_log = StringIO.new
|
|
59
|
-
@logged_in = !@cookies.empty? # If we have cookies,
|
|
58
|
+
@logged_in = !@cookies.empty? # If we have cookies, will verify on first use
|
|
59
|
+
@last_verified_response = nil
|
|
60
60
|
|
|
61
61
|
log "Loaded #{@cookies.length} cookies from cache" if @logged_in && @debug
|
|
62
62
|
end
|
|
@@ -286,7 +286,9 @@ module Truenorth
|
|
|
286
286
|
|
|
287
287
|
log "\n=== GET RESERVATIONS ==="
|
|
288
288
|
|
|
289
|
-
response
|
|
289
|
+
# Reuse the response from session verification if available
|
|
290
|
+
response = @last_verified_response || get(RESERVATIONS_PATH)
|
|
291
|
+
@last_verified_response = nil
|
|
290
292
|
html = Nokogiri::HTML(response.body)
|
|
291
293
|
|
|
292
294
|
reservations = []
|
|
@@ -478,11 +480,31 @@ module Truenorth
|
|
|
478
480
|
private
|
|
479
481
|
|
|
480
482
|
def ensure_logged_in!
|
|
481
|
-
|
|
483
|
+
if @logged_in
|
|
484
|
+
# Verify cached session is still valid with a lightweight check
|
|
485
|
+
response = get(RESERVATIONS_PATH)
|
|
486
|
+
if authenticated_response?(response)
|
|
487
|
+
@last_verified_response = response
|
|
488
|
+
return
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Session expired - clear stale state and re-login
|
|
492
|
+
log 'Session expired, re-authenticating...'
|
|
493
|
+
@logged_in = false
|
|
494
|
+
@cookies = {}
|
|
495
|
+
end
|
|
482
496
|
|
|
483
497
|
login
|
|
484
498
|
end
|
|
485
499
|
|
|
500
|
+
# Check if a response is from an authenticated session (not a login page)
|
|
501
|
+
def authenticated_response?(response)
|
|
502
|
+
return false unless response.is_a?(Net::HTTPSuccess)
|
|
503
|
+
|
|
504
|
+
body = response.body
|
|
505
|
+
!body.include?('LoginPortlet') && body.include?('Sign Out')
|
|
506
|
+
end
|
|
507
|
+
|
|
486
508
|
def parse_slots(html)
|
|
487
509
|
slots = {}
|
|
488
510
|
|
data/lib/truenorth/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: truenorth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- usiegj00
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -135,6 +135,7 @@ files:
|
|
|
135
135
|
- README.md
|
|
136
136
|
- bin/truenorth
|
|
137
137
|
- lib/truenorth.rb
|
|
138
|
+
- lib/truenorth/browser_client.rb
|
|
138
139
|
- lib/truenorth/cli.rb
|
|
139
140
|
- lib/truenorth/client.rb
|
|
140
141
|
- lib/truenorth/config.rb
|