truenorth 0.6.1 → 0.7.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: bf116b555b67f39c34c1579a5d1212ee86d26c33a2d1bc88bf681d6145728b2f
4
- data.tar.gz: d6ed28ab2f74be19204f331e1c37a4c925c021c00689c33d0b0a56880abdb9b1
3
+ metadata.gz: 8122d3972ae1ccc7041a3218e320b50665ee2df333fe998ab7127e2e3f04dec9
4
+ data.tar.gz: 2cb16851538859493d237c29cdb73e0d70b49cf43723fc99be4542878a2d3ad0
5
5
  SHA512:
6
- metadata.gz: '079000943acd8e2f5a8cd278c5c20f52fe400d2df7189e6baa9aa4b64c7c814fa07f614af06fb1c6251fa302b125f8d1beb3cfa84b58287fdb04b8265706df7b'
7
- data.tar.gz: 77a071e50db72b2c239f6b1d1beba278ff8e728aea8cd955f9a73bdd13d4535493b1412e3527fc9ebeff9fc964a8dbcff31eae2f2bf9addbf77a523965004756
6
+ metadata.gz: 324a9afac6e47cb66f141567ddcb65195c0f2a001cc1b48e1b24824f0aada5779d6ae620c5c2a2df60f09097ce424f7db1b30f7bc14b3f0d7479dbc988b03f4b
7
+ data.tar.gz: 8cfdc09c006491569b05f256096d55003d4d1a40bd3b9925ff1f1f7378745a3c657ce4cf1ed2350d7e0bb237fb485842288f631e2e82661f00f49d1eabf13685
data/lib/truenorth/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'date'
5
+ require 'chronic'
5
6
  require 'tty-table'
6
7
  require 'io/console'
7
8
  require_relative '../truenorth'
@@ -114,15 +115,16 @@ module Truenorth
114
115
  say "Confirmation: #{result[:confirmation]}" if result[:confirmation]
115
116
  end
116
117
  end
117
- rescue Error => e
118
+ rescue BookingError => e
118
119
  say "Error: #{e.message}", :red
119
120
 
120
- # If no slot available, show nearby available times using HTTP client
121
- if e.message.include?('No slot available')
122
- http_client = Client.new(debug: options[:debug])
123
- show_nearby_availability(http_client, date, time, activity)
124
- end
121
+ # Show nearby available times using HTTP client
122
+ http_client = Client.new(debug: options[:debug])
123
+ show_nearby_availability(http_client, date, time, activity)
125
124
 
125
+ exit 1
126
+ rescue Error => e
127
+ say "Error: #{e.message}", :red
126
128
  exit 1
127
129
  end
128
130
 
@@ -410,67 +412,35 @@ module Truenorth
410
412
  input = input.gsub(/\b(squash|golf|music|room)\b/i, '').strip
411
413
  end
412
414
 
413
- # Extract date if present
414
- date = nil
415
- input_lower = input.downcase
416
-
417
- # Try patterns like "feb 15", "february 15th", "2/15"
418
- if input_lower =~ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(st|nd|rd|th)?\b/i
419
- month_str = ::Regexp.last_match(1)
420
- day = ::Regexp.last_match(2).to_i
421
-
422
- month_map = {
423
- 'jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4,
424
- 'may' => 5, 'jun' => 6, 'jul' => 7, 'aug' => 8,
425
- 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12
426
- }
427
-
428
- month = month_map[month_str[0..2].downcase]
429
- year = Date.today.year
430
- year += 1 if month && month < Date.today.month # Next year if month has passed
431
-
432
- date = Date.new(year, month, day) if month
433
- input = input.gsub(/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(st|nd|rd|th)?\b/i, '').strip
434
- elsif input =~ %r{\b(\d{1,2})/(\d{1,2})\b}
435
- month = ::Regexp.last_match(1).to_i
436
- day = ::Regexp.last_match(2).to_i
437
- year = Date.today.year
438
- date = Date.new(year, month, day)
439
- input = input.gsub(%r{\b\d{1,2}/\d{1,2}\b}, '').strip
440
- end
415
+ # Normalize shorthand am/pm ("4:45p" -> "4:45pm", "10a" -> "10am")
416
+ input = input.gsub(/(\d)\s*([ap])\b(?!m)/i) { "#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}m" }
441
417
 
442
- # Remove day names (monday, tuesday, etc.)
443
- input = input.gsub(/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i, '').strip
418
+ # Use Chronic for natural language date/time parsing
419
+ parsed_time = Chronic.parse(input, context: :future)
444
420
 
445
- # Extract time - look for patterns like "10am", "10:00am", "10:00 AM", "10"
446
- time = nil
447
- if input =~ /\b(\d{1,2})(:(\d{2}))?\s*(am|pm)?\b/i
448
- hour = ::Regexp.last_match(1).to_i
449
- minute = ::Regexp.last_match(3) || '00'
450
- period = ::Regexp.last_match(4) || (hour < 8 ? 'PM' : 'AM')
451
-
452
- time = "#{hour}:#{minute} #{period.upcase}"
421
+ if parsed_time
422
+ date = parsed_time.to_date
423
+ time = parsed_time.strftime('%-l:%M %p')
424
+ else
425
+ # Fallback: use date option and treat input as time
426
+ date = parse_date(date_option)
427
+ time = input.strip
453
428
  end
454
429
 
455
- # Use provided date option if date wasn't extracted
456
- date ||= parse_date(date_option)
457
-
458
- # If no time found, use the remaining input as-is
459
- time ||= input.strip
460
-
461
430
  { date: date, time: time, activity: activity }
462
431
  end
463
432
 
464
433
  def parse_date(date_str)
465
434
  return Date.today if date_str.nil? || date_str.empty?
466
435
 
467
- case date_str
468
- when /^\+(\d+)$/
469
- Date.today + ::Regexp.last_match(1).to_i
470
- when 'today'
471
- Date.today
472
- when 'tomorrow'
473
- Date.today + 1
436
+ # Handle +N shorthand for days from today
437
+ if date_str =~ /^\+(\d+)$/
438
+ return Date.today + ::Regexp.last_match(1).to_i
439
+ end
440
+
441
+ parsed = Chronic.parse(date_str, context: :future)
442
+ if parsed
443
+ parsed.to_date
474
444
  else
475
445
  Date.parse(date_str)
476
446
  end
@@ -56,6 +56,7 @@ module Truenorth
56
56
  @debug = debug
57
57
  @debug_log = StringIO.new
58
58
  @logged_in = !@cookies.empty? # If we have cookies, will verify on first use
59
+ @login_time = nil
59
60
  @last_verified_response = nil
60
61
 
61
62
  log "Loaded #{@cookies.length} cookies from cache" if @logged_in && @debug
@@ -103,6 +104,7 @@ module Truenorth
103
104
 
104
105
  if response.body.include?('Sign Out') || response.body.include?('My Reservations')
105
106
  @logged_in = true
107
+ @login_time = Time.now
106
108
  Config.save_cookies(@cookies)
107
109
  log 'Login successful (cookies saved)'
108
110
  true
@@ -123,70 +125,37 @@ module Truenorth
123
125
  response = get(BOOKING_PATH)
124
126
  html = Nokogiri::HTML(response.body)
125
127
 
126
- # Navigate to the requested date first if needed
127
- current_date = html.at_css('input[name*="sheetDate"]')&.[]('value')
128
128
  requested_date = date.strftime('%m/%d/%Y')
129
129
  activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
130
130
 
131
- if current_date != requested_date
132
- log "Navigating from #{current_date} to #{requested_date}"
133
- html = change_date(html, requested_date, activity_id)
134
- end
135
-
136
- # Then change activity type - this should now return slots for the correct date
137
- current_activity = html.at_css('input[name*="activityId"]')&.[]('value')
138
-
139
- if current_activity && current_activity != activity_id
140
- log "Changing activity from #{current_activity} to #{activity_id}"
141
- html = change_activity(html, activity_id)
142
- end
131
+ # Preserve full-page form state before AJAX navigation
132
+ form_id = extract_form_id(html)
133
+ view_state = extract_view_state(html)
134
+ components = extract_primefaces_components(html)
135
+ form_fields = extract_all_form_fields(html, form_id)
143
136
 
144
- # Debug: Save HTML after activity change
145
- if @debug
146
- debug_file = "/tmp/truenorth_after_activity_#{Time.now.to_i}.html"
147
- File.write(debug_file, html.to_s)
148
- log "Saved HTML after activity change to: #{debug_file}"
137
+ # Step 1: Navigate to the correct date
138
+ log "Navigating to #{requested_date}"
139
+ html = change_date(html, requested_date, activity_id)
140
+ view_state = extract_view_state(html) || view_state
141
+
142
+ # Step 2: Change activity type
143
+ # The dateSelect event only changes the date; a separate change event
144
+ # is needed to switch the activity (e.g., from golf to squash).
145
+ log "Changing activity to #{activity_id} (#{activity})"
146
+ updated_fields = extract_all_form_fields(html, form_id)
147
+ updated_fields = form_fields.dup if updated_fields.empty?
148
+ updated_fields["#{form_id}:activityId"] = activity_id
149
+ updated_fields["#{form_id}:sheetDate"] = requested_date
150
+
151
+ result = change_activity_ajax(form_id, view_state, activity_id, updated_fields, components)
152
+ if result[:success]
153
+ view_state = result[:view_state] || view_state
154
+ html = parse_ajax_cdata(result[:body]) || html
149
155
  end
150
156
 
151
- # Court dropdown not available in HTML response - use hardcoded IDs
152
- court_ids = COURT_IDS_BY_ACTIVITY[activity.to_s.downcase] || []
153
-
154
- if court_ids.length >= 3
155
- log "Querying #{court_ids.length} courts individually (IDs: #{court_ids.join(', ')})"
156
- all_slots = {}
157
-
158
- court_ids.each do |court_id|
159
- court_name = COURTS[court_id] || "Court #{court_id}"
160
- log "Querying #{court_name} (ID: #{court_id})"
161
-
162
- # Set activityAreaId and refresh
163
- form_id = extract_form_id(html)
164
- view_state = extract_view_state(html)
165
- form_fields = extract_all_form_fields(html, form_id)
166
- form_fields["#{form_id}:activityAreaId"] = court_id
167
-
168
- # Make AJAX request to update table for this court
169
- result = change_date_ajax(form_id, view_state, requested_date, form_fields)
170
- if result[:success]
171
- court_html = Nokogiri::HTML(result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n"))
172
- court_slots = parse_slots(court_html)
173
-
174
- # Merge slots
175
- court_slots.each do |time, courts|
176
- all_slots[time] ||= []
177
- all_slots[time].concat(courts)
178
- all_slots[time].uniq!
179
- end
180
- end
181
- end
182
-
183
- slots = all_slots
184
- log "Combined #{slots.count} time slots from all courts"
185
- else
186
- # Fallback to original parsing
187
- slots = parse_slots(html)
188
- log "Found #{slots.count} available time slots"
189
- end
157
+ slots = parse_slots(html)
158
+ log "Found #{slots.count} available time slots"
190
159
 
191
160
  {
192
161
  success: true,
@@ -216,18 +185,28 @@ module Truenorth
216
185
 
217
186
  raise BookingError, 'Could not extract form state' unless view_state && form_id
218
187
 
219
- # Change activity if needed
220
188
  activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
221
- current_activity = form_fields["#{form_id}:activityId"]
189
+ requested_date = date.strftime('%m/%d/%Y')
190
+
191
+ # Step 1: Navigate to the correct date
192
+ log "Navigating to #{requested_date}"
193
+ html = change_date(html, requested_date, activity_id)
194
+ view_state = extract_view_state(html) || view_state
222
195
 
223
- if current_activity != activity_id
224
- log "Changing activity from #{current_activity} to #{activity_id}"
225
- result = change_activity_ajax(form_id, view_state, activity_id, form_fields, components)
226
- raise BookingError, 'Failed to change activity type' unless result[:success]
196
+ # Step 2: Change activity type (dateSelect only changes date, not activity)
197
+ log "Changing activity to #{activity_id} (#{activity})"
198
+ updated_fields = extract_all_form_fields(html, form_id)
199
+ updated_fields = form_fields.dup if updated_fields.empty?
200
+ updated_fields["#{form_id}:activityId"] = activity_id
201
+ updated_fields["#{form_id}:sheetDate"] = requested_date
227
202
 
203
+ result = change_activity_ajax(form_id, view_state, activity_id, updated_fields, components)
204
+ if result[:success]
228
205
  view_state = result[:view_state] || view_state
229
- html = Nokogiri::HTML(result[:body]) if result[:body]
206
+ html = parse_ajax_cdata(result[:body]) || html
207
+ components = extract_components_from_ajax(result[:body]) if result[:body]
230
208
  form_fields = extract_all_form_fields(html, form_id)
209
+ form_fields = updated_fields if form_fields.empty?
231
210
  end
232
211
 
233
212
  # Find the slot (or use provided slot_info)
@@ -247,14 +226,23 @@ module Truenorth
247
226
  log "Found slot: #{slot[:court]} at #{slot[:start_time]}"
248
227
  end
249
228
 
250
- # Select slot via AJAX
229
+ # Select slot via AJAX (opens reservation dialog, starts hold timer)
251
230
  select_result = select_slot_ajax(form_id, view_state, slot, components, form_fields)
252
231
  raise BookingError, 'Failed to select slot' unless select_result[:success]
253
232
 
254
233
  new_view_state = select_result[:view_state] || view_state
255
234
  new_components = select_result[:components] || components
235
+
236
+ # Extract ALL form fields from the select response (includes clientId, dialog fields)
256
237
  dialog_fields = extract_fields_from_ajax_response(select_result[:body], form_id) if select_result[:body]
257
238
 
239
+ # Verify hold timer started (confirms server accepted the slot selection)
240
+ if select_result[:body]&.include?('startHoldTimeTimer')
241
+ log 'Slot hold timer started - slot reserved temporarily'
242
+ else
243
+ log 'WARNING: No hold timer detected in select response'
244
+ end
245
+
258
246
  if dry_run
259
247
  return {
260
248
  success: true,
@@ -480,11 +468,17 @@ module Truenorth
480
468
  private
481
469
 
482
470
  def ensure_logged_in!
471
+ # Skip verification if we logged in recently (within 5 minutes)
472
+ if @logged_in && @login_time && (Time.now - @login_time < 300)
473
+ return
474
+ end
475
+
483
476
  if @logged_in
484
477
  # Verify cached session is still valid with a lightweight check
485
478
  response = get(RESERVATIONS_PATH)
486
479
  if authenticated_response?(response)
487
480
  @last_verified_response = response
481
+ @login_time = Time.now # Reset timer on successful verification
488
482
  return
489
483
  end
490
484
 
@@ -580,7 +574,23 @@ module Truenorth
580
574
  def find_slot(html, target_time, preferred_court = nil)
581
575
  target_normalized = normalize_time(target_time)
582
576
 
583
- html.css('td.slot.open div[data-start-time]').each do |div|
577
+ all_slots = html.css('td.slot.open div[data-start-time]')
578
+ sample_times = all_slots.first(5).map { |d| d['data-start-time'] }
579
+ log "find_slot: looking for '#{target_normalized}', found #{all_slots.length} open slot divs, sample times: #{sample_times.join(', ')}"
580
+ if all_slots.length.zero?
581
+ # Try without 'open' class to see what's there
582
+ any_slots = html.css('td.slot div[data-start-time]')
583
+ log "find_slot: #{any_slots.length} total slot divs (including non-open)"
584
+ if any_slots.length.positive?
585
+ sample_td = any_slots.first.parent
586
+ sample_td = sample_td.parent while sample_td && sample_td.name != 'td'
587
+ log "find_slot: sample td classes: '#{sample_td&.[]('class')}'"
588
+ times = any_slots.first(3).map { |d| d['data-start-time'] }
589
+ log "find_slot: sample times: #{times.join(', ')}"
590
+ end
591
+ end
592
+
593
+ all_slots.each do |div|
584
594
  slot_time = normalize_time(div['data-start-time'])
585
595
  next unless slot_time == target_normalized
586
596
 
@@ -754,12 +764,18 @@ module Truenorth
754
764
  end
755
765
 
756
766
  def change_date(html, date_str, activity_id = nil)
757
- # Extract what we can from the HTML
758
767
  form_id = extract_form_id(html)
759
768
  view_state = extract_view_state(html)
760
769
 
761
- # Build minimal form data manually to avoid extraction issues
762
- form_data = build_minimal_form_data(html, form_id, activity_id)
770
+ # Use full form fields (not just hidden) so dynamic ID detection works
771
+ form_data = extract_all_form_fields(html, form_id)
772
+ if form_data.empty?
773
+ form_data = build_minimal_form_data(html, form_id, activity_id)
774
+ elsif activity_id && form_id
775
+ form_data["#{form_id}:activityId"] = activity_id.to_s
776
+ activity_source = find_activity_source(form_data, form_id)
777
+ form_data["#{activity_source}_input"] = activity_id.to_s
778
+ end
763
779
 
764
780
  result = change_date_ajax(form_id, view_state, date_str, form_data)
765
781
  return html unless result[:success]
@@ -809,7 +825,9 @@ module Truenorth
809
825
  # Override activity ID if provided
810
826
  if activity_id
811
827
  data["#{form_id}:activityId"] = activity_id.to_s
812
- data["#{form_id}:j_idt51_input"] = activity_id.to_s
828
+ # Also set the activity dropdown input (dynamically detected)
829
+ activity_source = find_activity_source(data, form_id)
830
+ data["#{activity_source}_input"] = activity_id.to_s
813
831
  end
814
832
 
815
833
  data
@@ -838,6 +856,33 @@ module Truenorth
838
856
  components
839
857
  end
840
858
 
859
+ # Dynamically find the activity dropdown component ID (PrimeFaces selectOneMenu).
860
+ # SelectOneMenu has both _focus and _input suffixed fields; calendar only has _input.
861
+ def find_activity_source(form_fields, form_id)
862
+ prefix = "#{form_id}:"
863
+ form_fields.each_key do |key|
864
+ next unless key.start_with?(prefix) && key.end_with?('_focus')
865
+
866
+ base = key.sub(/_focus$/, '')
867
+ return base if form_fields.key?("#{base}_input") && base =~ /j_idt\d+$/
868
+ end
869
+ "#{form_id}:j_idt67" # fallback
870
+ end
871
+
872
+ # Dynamically find the date picker component ID (PrimeFaces calendar).
873
+ # Calendar has _input but no _focus (selectOneMenu has both).
874
+ def find_date_picker_source(form_fields, form_id)
875
+ prefix = "#{form_id}:"
876
+ form_fields.each_key do |key|
877
+ next unless key.start_with?(prefix) && key.end_with?('_input') && key =~ /j_idt\d+_input$/
878
+
879
+ base = key.sub(/_input$/, '')
880
+ # Calendar has _input but no _focus
881
+ return base unless form_fields.key?("#{base}_focus")
882
+ end
883
+ "#{form_id}:j_idt79" # fallback
884
+ end
885
+
841
886
  def extract_all_form_fields(html, form_id)
842
887
  form = html.at_css("form[id='#{form_id}']")
843
888
  return {} unless form
@@ -888,18 +933,16 @@ module Truenorth
888
933
  end
889
934
 
890
935
  def change_activity_ajax(form_id, view_state, activity_id, form_fields, _components)
891
- activity_dropdown = "#{form_id}:j_idt57"
936
+ activity_dropdown = find_activity_source(form_fields, form_id)
892
937
  ajax_url = build_ajax_url
893
938
  encoded_url = URI.encode_www_form_component(ajax_url)
894
939
 
895
940
  form_data = form_fields.dup
896
- form_data["#{form_id}:j_idt51_input"] = activity_id
941
+ form_data["#{activity_dropdown}_input"] = activity_id
897
942
  form_data["#{form_id}:activityId"] = activity_id
898
943
  form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
899
944
 
900
- # Debug: Log what we're sending
901
- log "Sending to #{form_id}:showAllAreasOrTrainers = #{form_data["#{form_id}:showAllAreasOrTrainers"]}"
902
- log "Sending to #{form_id}:mobileViewDisplay = #{form_data["#{form_id}:mobileViewDisplay"]}"
945
+ log "Activity dropdown source: #{activity_dropdown}"
903
946
 
904
947
  form_data.merge!(
905
948
  'javax.faces.partial.ajax' => 'true',
@@ -974,16 +1017,16 @@ module Truenorth
974
1017
  def change_date_ajax(form_id, view_state, date_str, form_data)
975
1018
  return { success: false, error: 'No form_id' } unless form_id
976
1019
 
977
- date_picker = "#{form_id}:j_idt57"
1020
+ date_picker = find_date_picker_source(form_data, form_id)
978
1021
  ajax_url = build_ajax_url
979
1022
  encoded_url = URI.encode_www_form_component(ajax_url)
980
1023
 
981
- # Use form_data as-is (already built correctly by build_minimal_form_data)
982
- # Just add our date values and AJAX control parameters
1024
+ log "Date picker source: #{date_picker}"
1025
+
983
1026
  form_data = form_data.dup
984
1027
  form_data["#{form_id}:sheetDate"] = date_str
985
- form_data["#{form_id}:j_idt57_input"] = date_str
986
- form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
1028
+ form_data["#{date_picker}_input"] = date_str
1029
+ form_data["#{form_id}:showAllAreasOrTrainers"] = 'true'
987
1030
 
988
1031
  form_data.merge!(
989
1032
  'javax.faces.partial.ajax' => 'true',
@@ -1006,20 +1049,31 @@ module Truenorth
1006
1049
  end
1007
1050
 
1008
1051
  def select_slot_ajax(form_id, view_state, slot, components, form_fields)
1009
- source_id = components['showReservationScreen'] || "#{form_id}:j_idt146"
1052
+ source_id = components['showReservationScreen']
1053
+ unless source_id
1054
+ # The showReservationScreen function is triggered when clicking a slot.
1055
+ # Look for it in the page JavaScript or use a dynamic approach.
1056
+ source_id = "#{form_id}:j_idt146"
1057
+ log "select_slot: using fallback source_id #{source_id} (showReservationScreen not in components)"
1058
+ end
1059
+ log "select_slot source_id: #{source_id}"
1010
1060
  ajax_url = build_ajax_url
1011
1061
  encoded_url = URI.encode_www_form_component(ajax_url)
1012
1062
 
1013
1063
  form_data = form_fields.dup
1064
+ # Set the activityAreaId form field to the slot's area
1065
+ form_data["#{form_id}:activityAreaId"] = slot[:area_id]
1014
1066
  form_data.merge!(
1015
1067
  'javax.faces.partial.ajax' => 'true',
1016
1068
  'javax.faces.source' => source_id,
1017
- 'javax.faces.partial.execute' => '@all',
1069
+ # PrimeFaces uses p: sourceId, meaning only the source component is processed
1070
+ 'javax.faces.partial.execute' => source_id,
1018
1071
  'javax.faces.partial.render' => form_id,
1019
1072
  source_id => source_id,
1020
1073
  form_id => form_id,
1021
1074
  'javax.faces.encodedURL' => encoded_url,
1022
1075
  'javax.faces.ViewState' => view_state,
1076
+ # These are PrimeFaces pa (params) - sent as plain form data
1023
1077
  'activityAreaId' => slot[:area_id],
1024
1078
  'startTime' => slot[:start_time],
1025
1079
  'endTime' => slot[:end_time]
@@ -1028,6 +1082,10 @@ module Truenorth
1028
1082
  response = post_ajax(ajax_url, form_data)
1029
1083
  if response.is_a?(Net::HTTPSuccess)
1030
1084
  new_components = extract_components_from_ajax(response.body)
1085
+ log "select_slot response components: #{new_components.inspect}"
1086
+ # Also try to find save button dynamically from the dialog HTML
1087
+ new_components['saveButton'] ||= find_save_button_id(response.body)
1088
+ log "select_slot save button: #{new_components['saveButton'] || 'NOT FOUND'}"
1031
1089
  { success: true, view_state: extract_view_state_from_ajax(response.body), components: new_components, body: response.body }
1032
1090
  else
1033
1091
  { success: false, error: "HTTP #{response.code}" }
@@ -1035,15 +1093,16 @@ module Truenorth
1035
1093
  end
1036
1094
 
1037
1095
  def save_booking_ajax(form_id, view_state, slot, components, dialog_fields)
1038
- save_button_id = components['saveButton'] || "#{form_id}:j_idt378"
1096
+ save_button_id = components['saveButton']
1097
+ unless save_button_id
1098
+ log 'WARNING: Save button not found in components, cannot save booking'
1099
+ return { success: false, error: 'Save button not found - slot selection may have failed' }
1100
+ end
1101
+ log "save_booking using button: #{save_button_id}"
1039
1102
  ajax_url = build_ajax_url
1040
1103
  encoded_url = URI.encode_www_form_component(ajax_url)
1041
1104
 
1042
1105
  form_data = (dialog_fields || {}).dup
1043
- form_data["#{form_id}:selectedSlotId"] = slot[:id]
1044
- form_data["#{form_id}:selectedAreaId"] = slot[:area_id]
1045
- form_data["#{form_id}:selectedStartTime"] = slot[:start_time]
1046
- form_data["#{form_id}:selectedEndTime"] = slot[:end_time]
1047
1106
  form_data.merge!(
1048
1107
  'javax.faces.partial.ajax' => 'true',
1049
1108
  'javax.faces.source' => save_button_id,
@@ -1058,39 +1117,100 @@ module Truenorth
1058
1117
  response = post_ajax(ajax_url, form_data)
1059
1118
  if response.is_a?(Net::HTTPSuccess)
1060
1119
  body = response.body
1061
- body_lower = body.downcase
1062
-
1063
- # Check for explicit error indicators
1064
- if body_lower.include?('error') || body_lower.include?('failed') ||
1065
- body_lower.include?('unable') || body_lower.include?('invalid')
1066
- log "Booking save returned error indicators in response"
1067
- { success: false, error: 'Booking save failed' }
1068
- # Check for success indicators OR assume success if reasonable response
1069
- elsif body_lower.include?('success') || body_lower.include?('confirmed') ||
1070
- body_lower.include?('booked') || body_lower.include?('reserved') ||
1071
- (body.length < 5000 && !body_lower.include?('exception'))
1072
- log "Booking save appears successful (HTTP 200)"
1073
- { success: true, confirmation: 'Booking confirmed' }
1074
- else
1075
- log "Uncertain booking result, response length: #{body.length}"
1076
- { success: false, error: 'No confirmation in response' }
1077
- end
1120
+ parse_save_response(body)
1078
1121
  else
1079
1122
  { success: false, error: "HTTP #{response.code}" }
1080
1123
  end
1081
1124
  end
1082
1125
 
1126
+ def parse_save_response(body)
1127
+ # Check for advance booking restriction overlay
1128
+ if body =~ /advance/i && body =~ /not allowed|only be made/i
1129
+ restriction = body.match(/Advance reservation[^.]*\./i)&.[](0) ||
1130
+ body.match(/Reservations can only be made[^.]*\./i)&.[](0)
1131
+ log "Booking rejected: #{restriction || 'advance booking restriction'}"
1132
+ return { success: false, error: restriction || 'Advance booking restriction' }
1133
+ end
1134
+
1135
+ # Check for PrimeFaces error messages (ui-messages-error or growl)
1136
+ if body.include?('ui-messages-error') || body =~ /severity["']?\s*:\s*["']?error/i
1137
+ error_html = Nokogiri::HTML(body)
1138
+ error_text = error_html.at_css('.ui-messages-error-detail, .ui-growl-message')&.text&.strip
1139
+ log "Booking save returned error: #{error_text || 'unknown'}"
1140
+ return { success: false, error: error_text || 'Booking save failed' }
1141
+ end
1142
+
1143
+ # Check for server exception
1144
+ if body.include?('exception') && body.include?('stacktrace')
1145
+ log 'Booking save returned server exception'
1146
+ return { success: false, error: 'Server error during booking' }
1147
+ end
1148
+
1149
+ # Check for restriction/warning overlays
1150
+ if body =~ /not allowed|cannot be booked|restriction|already reserved/i
1151
+ msg = body.match(/((?:not allowed|cannot be booked|restriction|already reserved)[^.<]*)/i)&.[](0)
1152
+ log "Booking rejected: #{msg}"
1153
+ return { success: false, error: msg || 'Booking not allowed' }
1154
+ end
1155
+
1156
+ # HTTP 200 with no error indicators = success
1157
+ log "Booking save appears successful (HTTP 200, #{body.length} bytes)"
1158
+ { success: true, confirmation: 'Booking confirmed' }
1159
+ end
1160
+
1161
+ # Find the save button ID dynamically from the AJAX response body.
1162
+ # Parses CDATA sections with Nokogiri for reliable detection.
1163
+ def find_save_button_id(response_body)
1164
+ return nil unless response_body
1165
+
1166
+ # Parse each CDATA update section with Nokogiri
1167
+ response_body.scan(/<update[^>]*>\s*<!\[CDATA\[(.*?)\]\]>/m) do |match|
1168
+ frag = Nokogiri::HTML::DocumentFragment.parse(match[0])
1169
+
1170
+ # Look for btn-save class on buttons or links
1171
+ save_el = frag.at_css('.btn-save[id]') ||
1172
+ frag.at_css('button[id]') { |el| el.text.strip =~ /\bSave\b/i } ||
1173
+ frag.at_css('a[id]') { |el| el.text.strip =~ /\bSave\b/i }
1174
+ return save_el['id'] if save_el&.[]('id')
1175
+ end
1176
+
1177
+ # Fallback: regex for btn-save (handles either class-before-id or id-before-class)
1178
+ if (match = response_body.match(/class="[^"]*btn-save[^"]*"[^>]*id="([^"]+)"/))
1179
+ return match[1]
1180
+ end
1181
+ if (match = response_body.match(/id="([^"]+)"[^>]*class="[^"]*btn-save/))
1182
+ return match[1]
1183
+ end
1184
+
1185
+ nil
1186
+ end
1187
+
1083
1188
  def extract_components_from_ajax(response_body)
1084
1189
  components = {}
1085
- if (save_match = response_body.match(/id="([^"]+)"[^>]*class="[^"]*btn-save/))
1086
- components['saveButton'] = save_match[1]
1087
- end
1190
+
1191
+ # Find save button via Nokogiri CDATA parsing
1192
+ save_id = find_save_button_id(response_body)
1193
+ components['saveButton'] = save_id if save_id
1194
+
1195
+ # Extract rc_ PrimeFaces remote command functions
1088
1196
  response_body.scan(/rc_(\w+)\s*=\s*function\(\)\s*\{PrimeFaces\.ab\(\{s:"([^"]+)"/) do |match|
1089
1197
  components[match[0]] = match[1]
1090
1198
  end
1091
1199
  components
1092
1200
  end
1093
1201
 
1202
+ # Parse CDATA content from a PrimeFaces AJAX response
1203
+ def parse_ajax_cdata(body)
1204
+ return nil unless body
1205
+
1206
+ cdata_content = body.scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
1207
+ has_slot = cdata_content.include?('slot')
1208
+ log "parse_ajax_cdata: #{cdata_content.length} bytes, has slot: #{has_slot}"
1209
+ return nil if cdata_content.empty? || !has_slot
1210
+
1211
+ Nokogiri::HTML(cdata_content)
1212
+ end
1213
+
1094
1214
  def extract_view_state_from_ajax(response_body)
1095
1215
  match = response_body.match(/ViewState[^>]*>(?:<!\[CDATA\[)?([^<\]]+)/)
1096
1216
  match&.[](1)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.0'
5
5
  end
data/truenorth.gemspec CHANGED
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.require_paths = ['lib']
31
31
 
32
32
  spec.add_dependency 'base64', '~> 0.2'
33
+ spec.add_dependency 'chronic', '~> 0.10'
33
34
  spec.add_dependency 'ferrum', '~> 0.15'
34
35
  spec.add_dependency 'nokogiri', '~> 1.15'
35
36
  spec.add_dependency 'thor', '~> 1.3'
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.6.1
4
+ version: 0.7.0
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-09 00:00:00.000000000 Z
11
+ date: 2026-02-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: chronic
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: ferrum
29
43
  requirement: !ruby/object:Gem::Requirement