truenorth 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 131d576fcb2ae365a296327ed2d3a56f40bea4bc6d56cd71457885b92a973f72
4
- data.tar.gz: 84955a3b673d18bbed8b7d2cb07a5d7e334cfc53350236c3018e8247bfe34018
3
+ metadata.gz: 8d89d28373a48164f8f6cf1f93672dd70d4f8aaf8035ae40bdc144fe76a8d6be
4
+ data.tar.gz: 266756fa35b43d17ad657bb11ab46d0411c63da6620bf630594a08ac7f4d5226
5
5
  SHA512:
6
- metadata.gz: d855f7fd99a257b1634f26b3b1d2c318d64ad727189a87b0f3d958673f3422ec8db13837ec0ecd197353a8c8e235c209d152f4802272aeb8fc0751c78eaa1288
7
- data.tar.gz: 3bcab5c590187397b26e9c0bbe95ee9d3023e8dbfddd761b0a01d87f37592dc9a64319f19fe94cef59094adc6cb74d206bbcb483aa63cc4271b7895ebbb7fa09
6
+ metadata.gz: e1c0d586aa1b9146bedb498c81036d1446f23a9f6ee92cae1d14887007227196986e4ed245511cd39a58375729fa55831174f1536721f5d57a39ca8db03983bb
7
+ data.tar.gz: a764f05fe8325d6457f03467ba1c3a169b9422b8bbaa8d2030264a0f8bf5bcacda0e346fd913c0d415880f26d1f6dde03c6ef3fd58525c80e8e5ef2153b862c3
@@ -5,6 +5,7 @@ require 'uri'
5
5
  require 'nokogiri'
6
6
  require 'json'
7
7
  require 'base64'
8
+ require 'date'
8
9
 
9
10
  module Truenorth
10
11
  # HTTP client for NorthStar facility booking systems
@@ -109,17 +110,24 @@ module Truenorth
109
110
  response = get(BOOKING_PATH)
110
111
  html = Nokogiri::HTML(response.body)
111
112
 
112
- # Check if we need to change activity type first
113
+ # Navigate to the requested date first if needed
114
+ current_date = html.at_css('input[name*="sheetDate"]')&.[]('value')
115
+ requested_date = date.strftime('%m/%d/%Y')
113
116
  activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
117
+
118
+ if current_date != requested_date
119
+ log "Navigating from #{current_date} to #{requested_date}"
120
+ html = change_date(html, requested_date, activity_id)
121
+ end
122
+
123
+ # Then change activity type - this should now return slots for the correct date
114
124
  current_activity = html.at_css('input[name*="activityId"]')&.[]('value')
115
125
 
116
126
  if current_activity && current_activity != activity_id
117
- # Need to change activity via AJAX
127
+ log "Changing activity from #{current_activity} to #{activity_id}"
118
128
  html = change_activity(html, activity_id)
119
129
  end
120
130
 
121
- # TODO: Handle date navigation if needed
122
-
123
131
  slots = parse_slots(html)
124
132
  log "Found #{slots.count} available time slots"
125
133
 
@@ -131,6 +139,7 @@ module Truenorth
131
139
  }
132
140
  end
133
141
 
142
+
134
143
  # Book a slot
135
144
  def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false)
136
145
  ensure_logged_in!
@@ -283,7 +292,8 @@ module Truenorth
283
292
 
284
293
  raise BookingError, 'Could not extract view state' unless view_state
285
294
 
286
- # Build the cancel AJAX request
295
+ # Step 1: Click the cancel button to open the confirmation dialog
296
+ # The dialog button will be enabled in the AJAX response
287
297
  ajax_url = "#{@base_url}#{RESERVATIONS_PATH}?p_p_id=memberReservations_WAR_northstarportlet" \
288
298
  '&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view' \
289
299
  '&p_p_cacheability=cacheLevelPage' \
@@ -301,50 +311,98 @@ module Truenorth
301
311
 
302
312
  response = post_ajax(ajax_url, form_data)
303
313
 
304
- if response.is_a?(Net::HTTPSuccess)
305
- body = response.body
306
-
307
- # Log response for debugging
308
- log "Response length: #{body.length}"
309
- log "Response preview: #{body[0..500]}"
310
-
311
- # Check for specific PrimeFaces success indicators
312
- # Look for actual cancellation confirmation messages
313
- success_indicators = [
314
- /cancelled.*successfully/i,
315
- /reservation.*cancelled/i,
316
- /successfully.*cancelled/i,
317
- /<update.*id=".*growl"/i # PrimeFaces growl messages usually indicate success/error
318
- ]
319
-
320
- # Check for error indicators
321
- error_indicators = [
322
- /error/i,
323
- /failed/i,
324
- /unable/i,
325
- /cannot.*cancel/i
326
- ]
327
-
328
- has_error = error_indicators.any? { |pattern| body =~ pattern }
329
- has_success = success_indicators.any? { |pattern| body =~ pattern }
330
-
331
- if has_error
332
- # Extract error message if present
333
- error_msg = body[/error[^<]*|failed[^<]*/i] || 'Cancellation may have failed'
334
- log "Error detected: #{error_msg}"
335
- { success: false, error: error_msg }
336
- elsif has_success || body.length < 500
337
- # Very short response likely means success (no error message)
338
- log 'Cancellation appears successful'
314
+ unless response.is_a?(Net::HTTPSuccess)
315
+ return { success: false, error: "HTTP #{response.code}" }
316
+ end
317
+
318
+ body = response.body
319
+ log "Step 1 response length: #{body.length}"
320
+
321
+ # Check if a confirmation dialog was opened
322
+ if body.include?("PF('cancelReservationDialog').show()") || body.include?('.show()')
323
+ log "Confirmation dialog opened"
324
+
325
+ # Extract the updated ViewState from the AJAX response
326
+ new_view_state = extract_view_state_from_ajax(body) || view_state
327
+
328
+ # Parse the AJAX response to find the enabled YES button
329
+ response_html = Nokogiri::HTML(body)
330
+
331
+ # Look for the YES button in the dialog (not in the reservation list!)
332
+ # The YES button has specific characteristics:
333
+ # 1. Contains text "YES"
334
+ # 2. Has class "ui-area-btn-danger"
335
+ # 3. Is inside the cancel dialog (j_idt274)
336
+ confirm_button = response_html.css('div[id*="j_idt274"] a.ui-area-btn-danger').find do |link|
337
+ link.text.strip.upcase.include?('YES')
338
+ end
339
+
340
+ # Fallback: look for any YES button with the right classes
341
+ unless confirm_button
342
+ confirm_button = response_html.css('a.ui-commandlink').find do |link|
343
+ link_text = link.text.strip.upcase
344
+ link_class = link['class'].to_s
345
+ link_text == 'YES' && link_class.include?('ui-area-btn-danger') &&
346
+ !link_class.include?('disabled')
347
+ end
348
+ end
349
+
350
+ unless confirm_button
351
+ log "Could not find enabled YES button in response"
352
+ log "Response preview: #{body[0..2000]}"
353
+ return { success: false, error: 'Could not find confirmation button' }
354
+ end
355
+
356
+ confirm_button_id = confirm_button['id']
357
+ log "Found confirmation button in response: #{confirm_button_id}"
358
+
359
+ # Step 2: Click the "Yes" button to actually cancel
360
+ confirm_data = {
361
+ 'javax.faces.partial.ajax' => 'true',
362
+ 'javax.faces.source' => confirm_button_id,
363
+ 'javax.faces.partial.execute' => '@all',
364
+ 'javax.faces.partial.render' => form_id,
365
+ form_id => form_id,
366
+ 'javax.faces.ViewState' => new_view_state,
367
+ confirm_button_id => confirm_button_id
368
+ }
369
+
370
+ log "Clicking confirmation button: #{confirm_button_id}"
371
+ confirm_response = post_ajax(ajax_url, confirm_data)
372
+
373
+ if confirm_response.is_a?(Net::HTTPSuccess)
374
+ confirm_body = confirm_response.body
375
+ log "Step 2 response length: #{confirm_body.length}"
376
+ log "Step 2 response preview: #{confirm_body[0..500]}"
377
+
378
+ # Check for success indicators
379
+ if confirm_body =~ /cancelled.*successfully/i ||
380
+ confirm_body =~ /reservation.*cancelled/i ||
381
+ confirm_body =~ /successfully.*cancelled/i ||
382
+ confirm_body.include?('growl') ||
383
+ confirm_body.length < 1000
384
+ log 'Cancellation confirmed successfully'
385
+ { success: true, message: 'Reservation cancelled' }
386
+ else
387
+ log "Warning: Uncertain confirmation response"
388
+ { success: true, message: 'Cancellation likely succeeded (please verify)' }
389
+ end
390
+ else
391
+ { success: false, error: "Confirmation failed: HTTP #{confirm_response.code}" }
392
+ end
393
+ else
394
+ # No dialog - check if it was directly cancelled
395
+ log "No confirmation dialog detected"
396
+
397
+ if body =~ /cancelled.*successfully/i ||
398
+ body =~ /reservation.*cancelled/i ||
399
+ body.length < 500
400
+ log 'Direct cancellation successful'
339
401
  { success: true, message: 'Reservation cancelled' }
340
402
  else
341
- # Ambiguous - log full response for analysis
342
- log "Ambiguous response (#{body.length} bytes)"
343
- log "Full response: #{body}"
403
+ log "Uncertain result (#{body.length} bytes)"
344
404
  { success: false, error: 'Uncertain if cancellation succeeded - please verify' }
345
405
  end
346
- else
347
- { success: false, error: "HTTP #{response.code}" }
348
406
  end
349
407
  end
350
408
 
@@ -359,17 +417,58 @@ module Truenorth
359
417
  def parse_slots(html)
360
418
  slots = {}
361
419
 
362
- html.css('td.slot.open').each do |td|
363
- div = td.at_css('div[data-start-time]')
364
- next unless div
365
- next if td['class']&.include?('reserved')
420
+ # Find ALL slots with data-start-time
421
+ html.css('td.slot div[data-start-time]').each do |div|
422
+ td = div.parent
423
+ while td && td.name != 'td'
424
+ td = td.parent
425
+ end
426
+ next unless td
366
427
 
367
- start_time = div['data-start-time']
368
- area_id = div['data-area-id']
369
- court_name = COURTS[area_id] || "Court #{area_id}"
428
+ classes = td['class'].to_s
370
429
 
371
- slots[start_time] ||= []
372
- slots[start_time] << court_name
430
+ # Skip definitively unavailable slots
431
+ next if classes.include?('reserved')
432
+ next if classes.include?('restrict')
433
+ next if classes.include?('blocked')
434
+
435
+ # Include ANY slot that's marked as "open", even if it has past-time
436
+ # The website shows past-time slots for future dates as bookable
437
+ if classes.include?('open')
438
+ start_time = div['data-start-time']
439
+ area_id = div['data-area-id']
440
+ court_name = COURTS[area_id] || "Court #{area_id}"
441
+
442
+ slots[start_time] ||= []
443
+ slots[start_time] << court_name unless slots[start_time].include?(court_name)
444
+ end
445
+ end
446
+
447
+ # If we found very few slots, be more aggressive
448
+ if slots.length < 10
449
+ log "Found only #{slots.length} slots with strict parsing, trying relaxed parsing..."
450
+
451
+ # Try including ALL non-reserved slots
452
+ html.css('td.slot div[data-start-time]').each do |div|
453
+ td = div.parent
454
+ while td && td.name != 'td'
455
+ td = td.parent
456
+ end
457
+ next unless td
458
+
459
+ classes = td['class'].to_s
460
+ # Only skip if explicitly reserved/restricted/blocked
461
+ next if classes.include?('reserved') && !classes.include?('open')
462
+ next if classes.include?('restrict')
463
+ next if classes.include?('blocked')
464
+
465
+ start_time = div['data-start-time']
466
+ area_id = div['data-area-id']
467
+ court_name = COURTS[area_id] || "Court #{area_id}"
468
+
469
+ slots[start_time] ||= []
470
+ slots[start_time] << court_name unless slots[start_time].include?(court_name)
471
+ end
373
472
  end
374
473
 
375
474
  slots
@@ -494,6 +593,58 @@ module Truenorth
494
593
  Nokogiri::HTML(result[:body])
495
594
  end
496
595
 
596
+ def change_date(html, date_str, activity_id = nil)
597
+ # Extract what we can from the HTML
598
+ form_id = extract_form_id(html)
599
+ view_state = extract_view_state(html)
600
+
601
+ # Build minimal form data manually to avoid extraction issues
602
+ form_data = build_minimal_form_data(html, form_id, activity_id)
603
+
604
+ result = change_date_ajax(form_id, view_state, date_str, form_data)
605
+ return html unless result[:success]
606
+
607
+ # Parse the AJAX response
608
+ ajax_body = result[:body]
609
+ cdata_content = ajax_body.scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
610
+
611
+ if !cdata_content.empty? && cdata_content.include?('slot')
612
+ Nokogiri::HTML(cdata_content)
613
+ else
614
+ html
615
+ end
616
+ end
617
+
618
+ def build_minimal_form_data(html, form_id, activity_id)
619
+ # Build the minimum required form data from scratch
620
+ # Only extract the fields we absolutely need
621
+ data = {}
622
+
623
+ # Try to find the form
624
+ form = html.at_css("form[id='#{form_id}']")
625
+ return data unless form
626
+
627
+ # Extract only the essential hidden fields
628
+ form.css('input[type="hidden"]').each do |input|
629
+ name = input['name']
630
+ value = input['value']
631
+ next unless name
632
+
633
+ # Only keep fields that belong to our form
634
+ if name.to_s.start_with?(form_id)
635
+ data[name] = value || ''
636
+ end
637
+ end
638
+
639
+ # Override activity ID if provided
640
+ if activity_id
641
+ data["#{form_id}:activityId"] = activity_id.to_s
642
+ data["#{form_id}:j_idt51_input"] = activity_id.to_s
643
+ end
644
+
645
+ data
646
+ end
647
+
497
648
  def extract_view_state(html)
498
649
  html.at_css('input[name="javax.faces.ViewState"]')&.[]('value')
499
650
  end
@@ -579,6 +730,39 @@ module Truenorth
579
730
  end
580
731
  end
581
732
 
733
+ def change_date_ajax(form_id, view_state, date_str, form_data)
734
+ return { success: false, error: 'No form_id' } unless form_id
735
+
736
+ date_picker = "#{form_id}:j_idt57"
737
+ ajax_url = build_ajax_url
738
+ encoded_url = URI.encode_www_form_component(ajax_url)
739
+
740
+ # Use form_data as-is (already built correctly by build_minimal_form_data)
741
+ # Just add our date values and AJAX control parameters
742
+ form_data = form_data.dup
743
+ form_data["#{form_id}:sheetDate"] = date_str
744
+ form_data["#{form_id}:j_idt57_input"] = date_str
745
+
746
+ form_data.merge!(
747
+ 'javax.faces.partial.ajax' => 'true',
748
+ 'javax.faces.source' => date_picker,
749
+ 'javax.faces.partial.execute' => date_picker,
750
+ 'javax.faces.partial.render' => form_id,
751
+ 'javax.faces.behavior.event' => 'dateSelect',
752
+ 'javax.faces.partial.event' => 'dateSelect',
753
+ form_id => form_id,
754
+ 'javax.faces.encodedURL' => encoded_url,
755
+ 'javax.faces.ViewState' => view_state
756
+ )
757
+
758
+ response = post_ajax(ajax_url, form_data)
759
+ if response.is_a?(Net::HTTPSuccess)
760
+ { success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
761
+ else
762
+ { success: false, error: "HTTP #{response.code}" }
763
+ end
764
+ end
765
+
582
766
  def select_slot_ajax(form_id, view_state, slot, components, form_fields)
583
767
  source_id = components['showReservationScreen'] || "#{form_id}:j_idt146"
584
768
  ajax_url = build_ajax_url
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.2.7'
4
+ VERSION = '0.3.0'
5
5
  end
data/truenorth.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = 'truenorth'
5
- spec.version = '0.2.7'
5
+ spec.version = '0.3.0'
6
6
  spec.authors = ['usiegj00']
7
7
  spec.email = ['112138+usiegj00@users.noreply.github.com']
8
8
 
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.2.7
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - usiegj00