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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ae8cfc6540c62e513428be9bf6b460cba5f1207f405d5dd075d2f3754fe9976
4
- data.tar.gz: 20382b0505099606ebf214359796a956947ee6cf3667b7ac8d5797debf7e995b
3
+ metadata.gz: bf116b555b67f39c34c1579a5d1212ee86d26c33a2d1bc88bf681d6145728b2f
4
+ data.tar.gz: d6ed28ab2f74be19204f331e1c37a4c925c021c00689c33d0b0a56880abdb9b1
5
5
  SHA512:
6
- metadata.gz: 38de51f859b0c3c6f20a2c23d2bffabc91de6de3d4e8f065a9e9f25bacfa2b1ac6d53d5aed5e8755a67ba8b99ed46909a63f8ea891929958e0e0e020bdb37949
7
- data.tar.gz: e8372584aaa758b89b29c620e0c31ff63d97740e1a8bfd3ccaec52e88a063b2934e05aad1c465995454d0ffa1171596d4fd74d3dc26160c85f1e5b2aa1a3249c
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
- show_nearby_availability(client, date, time, activity)
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
@@ -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' => 'Squash Court 1',
34
- '17' => 'Squash Court 2',
35
- '18' => 'Squash Court 3',
36
- '30' => 'Court 1',
37
- '31' => 'Court 2',
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[16 17 18],
44
- 'golf' => %w[30 31 32],
45
- 'music' => %w[16 17 18], # May need to update these
46
- 'room' => %w[30 31 32], # May need to update these
47
- 'meeting' => %w[30 31 32] # May need to update these
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, assume logged in
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 = get(RESERVATIONS_PATH)
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
- return if @logged_in
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.1'
5
5
  end
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.5.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-07 00:00:00.000000000 Z
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