truenorth 0.5.0 → 0.5.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: 116fc6f2abc9a07b52f6b54e913596b5d7d716ca37d503fe7da2ea244b50364c
4
+ data.tar.gz: 552250affcde71d62fc6220052123563cc1908ddc3696209851412884530afe9
5
5
  SHA512:
6
- metadata.gz: 38de51f859b0c3c6f20a2c23d2bffabc91de6de3d4e8f065a9e9f25bacfa2b1ac6d53d5aed5e8755a67ba8b99ed46909a63f8ea891929958e0e0e020bdb37949
7
- data.tar.gz: e8372584aaa758b89b29c620e0c31ff63d97740e1a8bfd3ccaec52e88a063b2934e05aad1c465995454d0ffa1171596d4fd74d3dc26160c85f1e5b2aa1a3249c
6
+ metadata.gz: 84e9bafbd6575a41d066f23e0ac65a369c3dce76e88b94c9dfc779323151bf46dc2678ec0b93913a9ebfc747609197b390e9cde8053388c15ee543bb7e65aad4
7
+ data.tar.gz: 72328e475662ea0ca28122c8e2de29489235a42174e9a513615700fd5997646832c6cdc58c7081d3e5157fcfdaa928907c0d687b8bfdb51e4822c40f446b3661
@@ -0,0 +1,967 @@
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
+ # Maximize window in debug mode
32
+ if @debug && @browser
33
+ @browser.resize(width: 1920, height: 1080)
34
+ log 'Browser window resized to 1920x1080'
35
+ end
36
+ end
37
+
38
+ def quit
39
+ @browser&.quit
40
+ log 'Browser quit'
41
+ end
42
+
43
+ def login
44
+ start unless @browser
45
+
46
+ # Try using cached cookies first
47
+ cookies = Config.cookies
48
+ if !cookies.empty?
49
+ log "Loading #{cookies.length} cached cookies"
50
+ @browser.go_to(@base_url)
51
+
52
+ cookies.each do |name, value|
53
+ @browser.cookies.set(
54
+ name: name,
55
+ value: value,
56
+ domain: URI.parse(@base_url).host
57
+ )
58
+ end
59
+
60
+ # Test if cookies work
61
+ @browser.go_to("#{@base_url}/group/pages/facility-booking")
62
+ sleep 2
63
+
64
+ if @browser.body.include?('Sign Out') || @browser.title.include?('Facility Booking')
65
+ log 'Logged in with cached cookies'
66
+ return true
67
+ end
68
+
69
+ log 'Cached cookies expired, logging in...'
70
+ end
71
+
72
+ # Login via HTTP client to get fresh cookies
73
+ http_client = Client.new(base_url: @base_url, debug: @debug)
74
+ http_client.login
75
+
76
+ # Get cookies from HTTP client
77
+ @browser.go_to(@base_url)
78
+ http_client.cookies.each do |name, value|
79
+ @browser.cookies.set(
80
+ name: name,
81
+ value: value,
82
+ domain: URI.parse(@base_url).host
83
+ )
84
+ end
85
+
86
+ log 'Logged in via HTTP client, cookies transferred to browser'
87
+ true
88
+ end
89
+
90
+ def availability(date, activity: 'squash')
91
+ login unless @browser
92
+
93
+ log "\n=== BROWSER GET AVAILABILITY ==="
94
+ log "Date: #{date}, Activity: #{activity}"
95
+
96
+ navigate_to_date_and_activity(date, activity)
97
+
98
+ # Parse the availability table
99
+ slots = parse_availability_table
100
+ log "Found #{slots.count} time slots across all courts"
101
+
102
+ {
103
+ success: true,
104
+ date: date.to_s,
105
+ activity: activity,
106
+ slots: slots
107
+ }
108
+ ensure
109
+ quit
110
+ end
111
+
112
+ def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false)
113
+ login unless @browser
114
+
115
+ log "\n=== BROWSER BOOK SLOT ==="
116
+ log "Time: #{time}, Date: #{date}, Court: #{court || 'any'}, Activity: #{activity}"
117
+ log 'DRY RUN MODE' if dry_run
118
+
119
+ navigate_to_date_and_activity(date, activity)
120
+
121
+ # Find and click the slot
122
+ slot_info = find_and_click_slot(time, court)
123
+ raise BookingError, "No slot available at #{time}" unless slot_info
124
+
125
+ log "Clicked slot: #{slot_info[:court]} at #{slot_info[:time]}"
126
+
127
+ # Wait for dialogs to appear
128
+ sleep 2
129
+
130
+ # Check URL after click
131
+ current_url = @browser.url
132
+ page_title = @browser.title
133
+ log "After slot click - URL: #{current_url}"
134
+ log "After slot click - Title: #{page_title}"
135
+
136
+ # Capture what dialog appeared (for debugging)
137
+ if @debug
138
+ dialog_info = @browser.evaluate(<<~JS)
139
+ (function() {
140
+ var result = {
141
+ dialogsFound: 0,
142
+ visibleDialogs: 0,
143
+ dialogHtml: null,
144
+ allDialogIds: []
145
+ };
146
+
147
+ var dialogs = document.querySelectorAll('.ui-dialog');
148
+ result.dialogsFound = dialogs.length;
149
+
150
+ for (var i = 0; i < dialogs.length; i++) {
151
+ var dialog = dialogs[i];
152
+ var content = dialog.querySelector('.ui-dialog-content');
153
+ result.allDialogIds.push(content ? content.id : 'no-id');
154
+
155
+ var style = window.getComputedStyle(dialog);
156
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
157
+ result.visibleDialogs++;
158
+ if (!result.dialogHtml) {
159
+ result.dialogHtml = dialog.outerHTML;
160
+ }
161
+ }
162
+ }
163
+
164
+ return result;
165
+ })()
166
+ JS
167
+
168
+ log "Dialog capture info: #{dialog_info['dialogsFound']} total, #{dialog_info['visibleDialogs']} visible"
169
+ log "Dialog IDs: #{dialog_info['allDialogIds'].join(', ')}"
170
+
171
+ if dialog_info['dialogHtml']
172
+ File.write('/tmp/first_dialog.html', dialog_info['dialogHtml'])
173
+ log 'Saved first dialog HTML to /tmp/first_dialog.html'
174
+ else
175
+ log 'WARNING: No visible dialog HTML to capture!'
176
+ end
177
+
178
+ @browser.screenshot(path: '/tmp/first_dialog.png')
179
+ log "Saved first dialog screenshot (URL: #{@browser.url}, Title: #{@browser.title})"
180
+ end
181
+
182
+ # Close legends dialog if it appears
183
+ legends_closed = @browser.evaluate(<<~JS)
184
+ (function() {
185
+ var dialogs = document.querySelectorAll('.ui-dialog');
186
+ for (var i = 0; i < dialogs.length; i++) {
187
+ var dialog = dialogs[i];
188
+ if (dialog.style.display === 'none') continue;
189
+ var legendsContent = dialog.querySelector('[id*="legends_content"]');
190
+ if (legendsContent) {
191
+ var closeBtn = dialog.querySelector('a.cross');
192
+ if (closeBtn) {
193
+ closeBtn.click();
194
+ return true;
195
+ }
196
+ }
197
+ }
198
+ return false;
199
+ })()
200
+ JS
201
+
202
+ if legends_closed
203
+ log 'Closed legends dialog, clicking slot again...'
204
+ sleep 1
205
+
206
+ # Click the slot again to open booking dialog
207
+ click_result = @browser.evaluate(<<~JS)
208
+ (function() {
209
+ var div = document.querySelector('div[data-area-id="' + #{slot_info[:area_id]} + '"]');
210
+ if (!div) return { clicked: false, reason: 'slot not found' };
211
+
212
+ var td = div.parentElement;
213
+ while (td && td.tagName !== 'TD') {
214
+ td = td.parentElement;
215
+ }
216
+ if (!td) return { clicked: false, reason: 'td not found' };
217
+
218
+ // Click and immediately check for dialog or auto-booking
219
+ td.click();
220
+
221
+ // Wait just a tiny bit for potential dialog
222
+ var startTime = Date.now();
223
+ while (Date.now() - startTime < 500) {
224
+ // Busy wait for 500ms
225
+ }
226
+
227
+ // Check what happened
228
+ var dialogs = document.querySelectorAll('.ui-dialog');
229
+ var visibleDialogs = 0;
230
+ var saveButton = null;
231
+
232
+ for (var i = 0; i < dialogs.length; i++) {
233
+ var dialog = dialogs[i];
234
+ var style = window.getComputedStyle(dialog);
235
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
236
+
237
+ visibleDialogs++;
238
+
239
+ // Try to find and click save button immediately
240
+ var btn =
241
+ dialog.querySelector('a.btn-save') ||
242
+ dialog.querySelector('button.btn-save') ||
243
+ dialog.querySelector('a[id*="save"]') ||
244
+ dialog.querySelector('button[id*="save"]') ||
245
+ dialog.querySelector('a.ui-commandlink:not(.cross)') ||
246
+ dialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
247
+ dialog.querySelector('a.ui-area-btn-success') ||
248
+ dialog.querySelector('button[type="submit"]');
249
+
250
+ if (btn) {
251
+ btn.click();
252
+ saveButton = { id: btn.id, text: btn.textContent.trim() };
253
+ break;
254
+ }
255
+ }
256
+
257
+ return {
258
+ clicked: true,
259
+ visibleDialogs: visibleDialogs,
260
+ saveButton: saveButton,
261
+ saveClicked: !!saveButton
262
+ };
263
+ })()
264
+ JS
265
+
266
+ log "Second click result: #{click_result.inspect}"
267
+
268
+ if click_result['saveClicked']
269
+ log "Save button clicked immediately after dialog opened!"
270
+ sleep 3
271
+ page_text = @browser.evaluate('document.body.textContent')
272
+ if page_text =~ /confirmed|success|booked|reservation.*created/i
273
+ log 'Booking appears confirmed based on page content'
274
+ return {
275
+ success: true,
276
+ court: slot_info[:court],
277
+ time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
278
+ confirmation: 'Booking confirmed'
279
+ }
280
+ end
281
+ end
282
+
283
+ # In debug mode, wait so user can see the browser
284
+ if @debug
285
+ log 'DEBUG: Waiting 10 seconds so you can see the browser state...'
286
+ sleep 10
287
+ else
288
+ sleep 2
289
+ end
290
+ end
291
+
292
+ # Check if booking dialog opened
293
+ dialog_visible = @browser.evaluate(<<~JS)
294
+ (function() {
295
+ var dialogs = document.querySelectorAll('.ui-dialog');
296
+ for (var i = 0; i < dialogs.length; i++) {
297
+ var dialog = dialogs[i];
298
+ if (dialog.style.display === 'none') continue;
299
+ // Make sure it's not the legends dialog
300
+ var isLegends = !!dialog.querySelector('[id*="legends_content"]');
301
+ if (!isLegends) return true;
302
+ }
303
+ return false;
304
+ })()
305
+ JS
306
+
307
+ unless dialog_visible
308
+ log 'ERROR: Booking dialog did not open!'
309
+ if @debug
310
+ @browser.screenshot(path: '/tmp/no_dialog.png')
311
+ log 'Saved screenshot to /tmp/no_dialog.png'
312
+ end
313
+ raise BookingError, 'Booking dialog did not open after clicking slot'
314
+ end
315
+
316
+ log 'Booking dialog opened'
317
+
318
+ # Capture dialog HTML for debugging
319
+ if @debug
320
+ dialog_html = @browser.evaluate(<<~JS)
321
+ (function() {
322
+ var dialogs = document.querySelectorAll('.ui-dialog');
323
+ for (var i = 0; i < dialogs.length; i++) {
324
+ var dialog = dialogs[i];
325
+ var style = window.getComputedStyle(dialog);
326
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
327
+ return dialog.outerHTML;
328
+ }
329
+ }
330
+ return null;
331
+ })()
332
+ JS
333
+
334
+ if dialog_html
335
+ File.write('/tmp/booking_dialog.html', dialog_html)
336
+ log 'Saved booking dialog HTML to /tmp/booking_dialog.html'
337
+ end
338
+
339
+ @browser.screenshot(path: '/tmp/booking_dialog.png')
340
+ log 'Saved screenshot to /tmp/booking_dialog.png'
341
+ end
342
+
343
+ if dry_run
344
+ log 'Dry run - closing dialog without booking'
345
+ close_dialog
346
+ return {
347
+ success: true,
348
+ dry_run: true,
349
+ court: slot_info[:court],
350
+ time: time,
351
+ message: 'Dry run completed - booking dialog opened successfully'
352
+ }
353
+ end
354
+
355
+ # Try to find and click the save button in the browser
356
+ log 'Looking for save button in dialog...'
357
+ sleep 1 # Quick check before dialog might close
358
+
359
+ # Check for save button immediately
360
+ quick_check = @browser.evaluate(<<~JS)
361
+ (function() {
362
+ var dialogs = document.querySelectorAll('.ui-dialog');
363
+ var visibleCount = 0;
364
+ var saveButton = null;
365
+
366
+ for (var i = 0; i < dialogs.length; i++) {
367
+ var dialog = dialogs[i];
368
+ var style = window.getComputedStyle(dialog);
369
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
370
+
371
+ visibleCount++;
372
+
373
+ // Try to find save button
374
+ var btn =
375
+ dialog.querySelector('a.btn-save') ||
376
+ dialog.querySelector('button.btn-save') ||
377
+ dialog.querySelector('a[id*="save"]') ||
378
+ dialog.querySelector('button[id*="save"]') ||
379
+ dialog.querySelector('a.ui-commandlink:not(.cross)') ||
380
+ dialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
381
+ dialog.querySelector('a.ui-area-btn-success') ||
382
+ dialog.querySelector('button[type="submit"]');
383
+
384
+ if (btn) {
385
+ saveButton = {
386
+ id: btn.id || '',
387
+ text: btn.textContent.trim(),
388
+ className: btn.className || ''
389
+ };
390
+ btn.click();
391
+ return { found: true, clicked: true, button: saveButton, visibleCount: visibleCount };
392
+ }
393
+ }
394
+
395
+ return { found: false, clicked: false, visibleCount: visibleCount };
396
+ })()
397
+ JS
398
+
399
+ log "Quick check result: #{quick_check.inspect}"
400
+
401
+ if quick_check['clicked']
402
+ log "Save button clicked immediately!"
403
+ sleep 3
404
+ page_text = @browser.evaluate('document.body.textContent')
405
+ if page_text =~ /confirmed|success|booked/i
406
+ log 'Booking confirmed'
407
+ return {
408
+ success: true,
409
+ court: slot_info[:court],
410
+ time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
411
+ confirmation: 'Booking confirmed'
412
+ }
413
+ end
414
+ end
415
+
416
+ sleep 2 # Wait a bit more
417
+
418
+ # Get dialog info (any visible dialog)
419
+ dialog_info = @browser.evaluate(<<~JS)
420
+ (function() {
421
+ var dialogs = document.querySelectorAll('.ui-dialog');
422
+ var visibleDialogs = [];
423
+
424
+ // Find all visible dialogs
425
+ for (var i = 0; i < dialogs.length; i++) {
426
+ var dialog = dialogs[i];
427
+ var style = window.getComputedStyle(dialog);
428
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
429
+ visibleDialogs.push(dialog);
430
+ }
431
+ }
432
+
433
+ if (visibleDialogs.length === 0) return { found: false, count: 0 };
434
+
435
+ // Use the last visible dialog (most recently opened)
436
+ var dialog = visibleDialogs[visibleDialogs.length - 1];
437
+
438
+ // Find all buttons in this dialog
439
+ var buttons = [];
440
+ var allBtns = dialog.querySelectorAll('a, button');
441
+ for (var j = 0; j < allBtns.length; j++) {
442
+ var btn = allBtns[j];
443
+ buttons.push({
444
+ tag: btn.tagName,
445
+ id: btn.id || '',
446
+ className: btn.className || '',
447
+ text: btn.textContent.trim(),
448
+ disabled: btn.disabled || btn.className.indexOf('disabled') > -1
449
+ });
450
+ }
451
+
452
+ var content = dialog.querySelector('.ui-dialog-content');
453
+ return {
454
+ found: true,
455
+ count: visibleDialogs.length,
456
+ contentId: content ? content.id : '',
457
+ buttons: buttons,
458
+ html: dialog.innerHTML.substring(0, 1000)
459
+ };
460
+ })()
461
+ JS
462
+
463
+ log "Dialog info: #{dialog_info.inspect}"
464
+
465
+ # Try multiple save button selectors (use last visible dialog)
466
+ save_result = @browser.evaluate(<<~JS)
467
+ (function() {
468
+ // Find all visible dialogs
469
+ var dialogs = document.querySelectorAll('.ui-dialog');
470
+ var visibleDialogs = [];
471
+ for (var i = 0; i < dialogs.length; i++) {
472
+ var dialog = dialogs[i];
473
+ var style = window.getComputedStyle(dialog);
474
+ if (style.display !== 'none' && style.visibility !== 'hidden') {
475
+ visibleDialogs.push(dialog);
476
+ }
477
+ }
478
+
479
+ if (visibleDialogs.length === 0) return { clicked: false, reason: 'no visible dialogs' };
480
+
481
+ // Use the last visible dialog (most recently opened)
482
+ var targetDialog = visibleDialogs[visibleDialogs.length - 1];
483
+
484
+ // Try to find save/submit button in this dialog
485
+ var saveBtn =
486
+ targetDialog.querySelector('a.btn-save') ||
487
+ targetDialog.querySelector('button.btn-save') ||
488
+ targetDialog.querySelector('a[id*="save"]') ||
489
+ targetDialog.querySelector('button[id*="save"]') ||
490
+ targetDialog.querySelector('a.ui-commandlink:not(.cross)') ||
491
+ targetDialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
492
+ targetDialog.querySelector('a.ui-area-btn-success') ||
493
+ targetDialog.querySelector('button[type="submit"]');
494
+
495
+ if (saveBtn) {
496
+ var btnInfo = {
497
+ id: saveBtn.id || '',
498
+ className: saveBtn.className || '',
499
+ text: saveBtn.textContent.trim(),
500
+ disabled: saveBtn.disabled || saveBtn.className.indexOf('disabled') > -1
501
+ };
502
+ console.log('Found save button:', saveBtn.id || saveBtn.className);
503
+ saveBtn.click();
504
+ return { clicked: true, button: btnInfo };
505
+ }
506
+ return { clicked: false, reason: 'no save button found', dialogCount: visibleDialogs.length };
507
+ })()
508
+ JS
509
+
510
+ save_clicked = save_result['clicked']
511
+ log "Save button result: #{save_result.inspect}" if save_result['button']
512
+
513
+ if save_clicked
514
+ log 'Save button clicked, waiting for confirmation...'
515
+ sleep 3
516
+
517
+ # Check for success message
518
+ page_text = @browser.evaluate('document.body.textContent')
519
+ if page_text =~ /confirmed|success|booked/i
520
+ log 'Booking confirmed'
521
+ return {
522
+ success: true,
523
+ court: slot_info[:court],
524
+ time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
525
+ confirmation: 'Booking confirmed'
526
+ }
527
+ else
528
+ log 'Warning: Save button clicked but no confirmation message detected'
529
+ return {
530
+ success: false,
531
+ error: 'Booking uncertain - please check My Reservations'
532
+ }
533
+ end
534
+ else
535
+ log 'No save button found, falling back to HTTP client approach'
536
+
537
+ # Fallback to HTTP client approach
538
+ http_client = Client.new(debug: @debug)
539
+
540
+ # Transfer cookies from browser to HTTP client
541
+ browser_cookies = @browser.cookies.all
542
+ browser_cookies.each do |name, cookie|
543
+ http_client.cookies[name] = cookie.value
544
+ end
545
+
546
+ # Calculate end time (assuming 1-hour slots)
547
+ start_time_obj = Time.parse(slot_info[:time])
548
+ end_time = (start_time_obj + 3600).strftime('%-I:%M %p').upcase
549
+
550
+ # Pass the slot info directly to avoid re-finding
551
+ result = http_client.book(
552
+ time,
553
+ date: date,
554
+ court: court,
555
+ activity: activity,
556
+ dry_run: false,
557
+ slot_info: {
558
+ area_id: slot_info[:area_id],
559
+ court: slot_info[:court],
560
+ start_time: slot_info[:time],
561
+ end_time: end_time
562
+ }
563
+ )
564
+
565
+ result
566
+ end
567
+ ensure
568
+ quit
569
+ end
570
+
571
+ def reservations
572
+ login unless @browser
573
+
574
+ log "\n=== BROWSER GET RESERVATIONS ==="
575
+
576
+ @browser.go_to("#{@base_url}/group/pages/my-reservations")
577
+ sleep 3
578
+
579
+ reservations = parse_reservations_table
580
+ log "Found #{reservations.count} reservations"
581
+
582
+ reservations
583
+ ensure
584
+ quit
585
+ end
586
+
587
+ def cancel(reservation_id, dry_run: false)
588
+ login unless @browser
589
+
590
+ log "\n=== BROWSER CANCEL RESERVATION ==="
591
+ log "Cancel ID: #{reservation_id}"
592
+ log 'DRY RUN MODE' if dry_run
593
+
594
+ return { success: true, dry_run: true, message: 'Dry run - would cancel reservation' } if dry_run
595
+
596
+ @browser.go_to("#{@base_url}/group/pages/my-reservations")
597
+ sleep 3
598
+
599
+ # Click the cancel button
600
+ log "Clicking cancel button: #{reservation_id}"
601
+ @browser.execute(<<~JS, reservation_id)
602
+ (function() {
603
+ var cancelBtn = document.getElementById(arguments[0]);
604
+ if (cancelBtn) {
605
+ cancelBtn.click();
606
+ } else {
607
+ throw new Error('Cancel button not found');
608
+ }
609
+ })()
610
+ JS
611
+
612
+ # Wait for confirmation dialog
613
+ sleep 2
614
+
615
+ # Click YES in the confirmation dialog
616
+ log 'Clicking YES to confirm cancellation'
617
+ @browser.execute(<<~JS)
618
+ (function() {
619
+ var yesBtn = document.querySelector('a.ui-area-btn-danger, button.ui-area-btn-danger');
620
+ if (yesBtn && yesBtn.textContent.indexOf('YES') > -1) {
621
+ yesBtn.click();
622
+ } else {
623
+ throw new Error('YES button not found');
624
+ }
625
+ })()
626
+ JS
627
+
628
+ # Wait for cancellation to complete
629
+ sleep 3
630
+
631
+ # Check for success
632
+ success_msg = @browser.evaluate("document.body.textContent")
633
+ if success_msg.include?('cancelled') || success_msg.include?('canceled') || success_msg.include?('success')
634
+ log 'Cancellation confirmed'
635
+ { success: true, message: 'Reservation cancelled' }
636
+ else
637
+ { success: false, error: 'Cancellation uncertain - please verify' }
638
+ end
639
+ ensure
640
+ quit
641
+ end
642
+
643
+ private
644
+
645
+ def navigate_to_date_and_activity(date, activity)
646
+ @browser.go_to("#{@base_url}/group/pages/facility-booking")
647
+ sleep 3
648
+
649
+ # Select activity
650
+ activity_id = Client::ACTIVITIES[activity.to_s.downcase] || '5'
651
+ select_activity(activity_id)
652
+ sleep 5
653
+
654
+ # TODO: Navigate to specific date if not today
655
+ current_url = @browser.url
656
+ page_title = @browser.title
657
+ log "Navigated to #{activity} for #{date}"
658
+ log "Current URL: #{current_url}"
659
+ log "Page title: #{page_title}"
660
+ end
661
+
662
+ def select_activity(activity_id)
663
+ log "Selecting activity ID: #{activity_id}"
664
+ @browser.execute(<<~JS)
665
+ (function() {
666
+ var select = document.querySelector('select[id*="j_idt51_input"]');
667
+ if (select) {
668
+ select.value = '#{activity_id}';
669
+ PrimeFaces.ab({
670
+ s: "_activities_WAR_northstarportlet_:activityForm:j_idt51",
671
+ e: "change",
672
+ f: "_activities_WAR_northstarportlet_:activityForm",
673
+ p: "_activities_WAR_northstarportlet_:activityForm:j_idt51",
674
+ u: "_activities_WAR_northstarportlet_:activityForm"
675
+ });
676
+ }
677
+ })()
678
+ JS
679
+ end
680
+
681
+ def find_and_click_slot(target_time, preferred_court)
682
+ # Normalize time
683
+ normalized_time = target_time.strip.gsub(/^0/, '').upcase
684
+
685
+ # Find all open slots matching the time
686
+ slots = @browser.evaluate(<<~JS)
687
+ (function() {
688
+ var openSlots = document.querySelectorAll('td.slot.open');
689
+ var result = [];
690
+ for (var i = 0; i < openSlots.length; i++) {
691
+ var td = openSlots[i];
692
+ var timeCell = td.parentElement.querySelector('td.interval');
693
+ var time = timeCell ? timeCell.textContent.trim() : '';
694
+ var div = td.querySelector('div[data-start-time]');
695
+ var areaId = div ? div.getAttribute('data-area-id') : null;
696
+
697
+ if (time && areaId) {
698
+ result.push({
699
+ time: time,
700
+ areaId: areaId
701
+ });
702
+ }
703
+ }
704
+ return result;
705
+ })()
706
+ JS
707
+
708
+ # Find matching slot
709
+ matching_slot = slots.find do |slot|
710
+ time_match = slot['time'].gsub(/^0/, '').upcase == normalized_time
711
+ court_match = if preferred_court
712
+ court_name = Client::COURTS[slot['areaId']]
713
+ court_name&.downcase&.include?(preferred_court.downcase)
714
+ else
715
+ true
716
+ end
717
+ time_match && court_match
718
+ end
719
+
720
+ return nil unless matching_slot
721
+
722
+ # Click the slot
723
+ court_name = Client::COURTS[matching_slot['areaId']]
724
+ log "Clicking slot: #{court_name} at #{matching_slot['time']}"
725
+
726
+ # Find and click using the area ID
727
+ @browser.execute(<<~JS, matching_slot['areaId'])
728
+ (function() {
729
+ var div = document.querySelector('div[data-area-id="' + arguments[0] + '"]');
730
+ if (div) {
731
+ var td = div.parentElement;
732
+ while (td && td.tagName !== 'TD') {
733
+ td = td.parentElement;
734
+ }
735
+ if (td) td.click();
736
+ }
737
+ })()
738
+ JS
739
+
740
+ {
741
+ court: court_name,
742
+ time: matching_slot['time'],
743
+ area_id: matching_slot['areaId']
744
+ }
745
+ end
746
+
747
+ def close_dialog
748
+ @browser.execute(<<~JS)
749
+ (function() {
750
+ var closeBtn = document.querySelector('.ui-dialog-titlebar-close');
751
+ if (closeBtn) closeBtn.click();
752
+ })()
753
+ JS
754
+ end
755
+
756
+ def submit_booking
757
+ log 'Submitting booking...'
758
+
759
+ # Try to find and trigger the save button via PrimeFaces
760
+ success = @browser.execute(<<~JS)
761
+ (function() {
762
+ // Look for the save button anywhere on the page
763
+ var saveBtn = document.querySelector('a.btn-save, button.btn-save') ||
764
+ document.querySelector('a[id*="save"]') ||
765
+ document.querySelector('button[id*="save"]') ||
766
+ document.querySelector('.ui-dialog a.ui-commandlink') ||
767
+ document.querySelector('.ui-dialog button[type="button"]');
768
+
769
+ if (saveBtn) {
770
+ console.log('Clicking save button:', saveBtn.id);
771
+ saveBtn.click();
772
+ return true;
773
+ }
774
+
775
+ // If no button found, try submitting via form
776
+ var form = document.querySelector('form[id*="activityForm"]');
777
+ if (form) {
778
+ console.log('Submitting form');
779
+ form.submit();
780
+ return true;
781
+ }
782
+
783
+ return false;
784
+ })()
785
+ JS
786
+
787
+ log "Booking submission triggered: #{success}"
788
+ end
789
+
790
+ def parse_reservations_table
791
+ reservations = []
792
+
793
+ # Get all reservation sections (by member)
794
+ member_sections = @browser.css('dt.ui-datalist-item')
795
+
796
+ member_sections.each_with_index do |section, member_idx|
797
+ # Extract member name
798
+ header_text = @browser.evaluate("arguments[0].textContent.trim()", section)
799
+ member_name = if header_text.include?("'s Reservations")
800
+ header_text[/^(.+?)'s Reservations/, 1]
801
+ else
802
+ nil
803
+ end
804
+
805
+ # Get all reservation rows for this member
806
+ rows = @browser.evaluate(<<~JS, section)
807
+ (function() {
808
+ var tables = arguments[0].parentElement.querySelectorAll('table tbody tr');
809
+ var result = [];
810
+ for (var i = 0; i < tables.length; i++) {
811
+ var row = tables[i];
812
+ var cellElements = row.querySelectorAll('td');
813
+ var cells = [];
814
+ for (var j = 0; j < cellElements.length; j++) {
815
+ cells.push(cellElements[j].textContent.trim());
816
+ }
817
+ var cancelLink = row.querySelector('a[title="Cancel Reservation"]');
818
+ var cancelId = cancelLink ? cancelLink.id : null;
819
+ result.push({ cells: cells, cancelId: cancelId, rowIdx: i });
820
+ }
821
+ return result;
822
+ })()
823
+ JS
824
+
825
+ rows.each do |row|
826
+ next if row['cells'].length < 2
827
+
828
+ # Parse reservation data
829
+ res_data = parse_reservation_row(row['cells'])
830
+ next unless res_data && res_data[:date]
831
+
832
+ res_data[:member] = member_name
833
+ res_data[:member_idx] = member_idx
834
+ res_data[:row_idx] = row['rowIdx']
835
+ res_data[:cancel_id] = row['cancelId']
836
+
837
+ reservations << res_data
838
+ end
839
+ end
840
+
841
+ # Sort by date
842
+ reservations.sort_by! do |res|
843
+ Date.strptime(res[:date], '%m/%d/%Y') rescue Date.today
844
+ end
845
+
846
+ reservations
847
+ end
848
+
849
+ def parse_reservation_row(text_parts)
850
+ return nil if text_parts.length < 2
851
+
852
+ cell1 = text_parts[1]
853
+
854
+ # Extract activity/court info
855
+ activity_match = cell1.match(/(Activities|Events)\s+\((.+?)\)\s*\d{2}\/\d{2}\/\d{4}/)
856
+ activity = nil
857
+ court = nil
858
+
859
+ if activity_match
860
+ activity_full = activity_match[2]
861
+ if activity_full.include?('|')
862
+ parts = activity_full.split('|').map(&:strip)
863
+ if parts[0] =~ /Court|Training|Room/
864
+ court = parts[0]
865
+ activity = parts[1]
866
+ else
867
+ activity = parts[0]
868
+ court = parts[1] if parts[1]
869
+ end
870
+ else
871
+ activity = activity_full
872
+ end
873
+ end
874
+
875
+ # Extract dates and times
876
+ dates = cell1.scan(/\b(\d{2}\/\d{2}\/\d{4})\b/).flatten
877
+ times = cell1.scan(/(\d{1,2}:\d{2}\s+[AP]M)/).flatten
878
+
879
+ return nil if dates.empty?
880
+
881
+ date = dates.first
882
+ time = if times.length >= 2
883
+ "#{times[0]} - #{times[1]}"
884
+ elsif times.length == 1
885
+ times[0]
886
+ end
887
+
888
+ {
889
+ date: date,
890
+ time: time,
891
+ activity: activity,
892
+ court: court
893
+ }
894
+ end
895
+
896
+ def parse_availability_table
897
+ slots = {}
898
+
899
+ # Get table rows using a safer approach
900
+ rows = @browser.evaluate(<<~JS)
901
+ (function() {
902
+ var tbody = document.querySelector('tbody.ui-datatable-data');
903
+ if (!tbody) return [];
904
+
905
+ var rows = tbody.querySelectorAll('tr');
906
+ var result = [];
907
+
908
+ for (var i = 0; i < rows.length; i++) {
909
+ var row = rows[i];
910
+ var timeCell = row.querySelector('td.interval');
911
+ if (!timeCell) continue;
912
+
913
+ var time = timeCell.textContent.trim();
914
+ if (!time) continue;
915
+
916
+ var courtCells = [];
917
+ var slots = row.querySelectorAll('td.slot');
918
+
919
+ for (var j = 0; j < slots.length; j++) {
920
+ var td = slots[j];
921
+ var classes = td.className || '';
922
+ var isOpen = classes.indexOf('open') > -1;
923
+ var isReserved = classes.indexOf('reserved') > -1;
924
+ var areaDiv = td.querySelector('div[data-area-id]');
925
+ var areaId = areaDiv ? areaDiv.getAttribute('data-area-id') : null;
926
+
927
+ if (isOpen && !isReserved && areaId) {
928
+ courtCells.push({
929
+ areaId: areaId
930
+ });
931
+ }
932
+ }
933
+
934
+ if (courtCells.length > 0) {
935
+ result.push({
936
+ time: time,
937
+ courts: courtCells
938
+ });
939
+ }
940
+ }
941
+
942
+ return result;
943
+ })()
944
+ JS
945
+
946
+ log "Found #{rows.length} time slots with availability"
947
+
948
+ # Process the results
949
+ rows.each do |row_data|
950
+ time = row_data['time']
951
+ row_data['courts'].each do |court|
952
+ area_id = court['areaId']
953
+ court_name = Client::COURTS[area_id] || "Court #{area_id}"
954
+
955
+ slots[time] ||= []
956
+ slots[time] << court_name unless slots[time].include?(court_name)
957
+ end
958
+ end
959
+
960
+ slots
961
+ end
962
+
963
+ def log(message)
964
+ puts message if @debug
965
+ end
966
+ end
967
+ 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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.5.0'
4
+ VERSION = '0.5.1'
5
5
  end
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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - usiegj00
@@ -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