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 +4 -4
- data/lib/truenorth/cli.rb +27 -57
- data/lib/truenorth/client.rb +228 -108
- data/lib/truenorth/version.rb +1 -1
- data/truenorth.gemspec +1 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8122d3972ae1ccc7041a3218e320b50665ee2df333fe998ab7127e2e3f04dec9
|
|
4
|
+
data.tar.gz: 2cb16851538859493d237c29cdb73e0d70b49cf43723fc99be4542878a2d3ad0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
118
|
+
rescue BookingError => e
|
|
118
119
|
say "Error: #{e.message}", :red
|
|
119
120
|
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
#
|
|
414
|
-
|
|
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
|
-
#
|
|
443
|
-
|
|
418
|
+
# Use Chronic for natural language date/time parsing
|
|
419
|
+
parsed_time = Chronic.parse(input, context: :future)
|
|
444
420
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
Date.today + ::Regexp.last_match(1).to_i
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
data/lib/truenorth/client.rb
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 =
|
|
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]')
|
|
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
|
-
#
|
|
762
|
-
form_data =
|
|
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
|
-
|
|
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 =
|
|
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["#{
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
982
|
-
|
|
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["#{
|
|
986
|
-
form_data["#{form_id}:showAllAreasOrTrainers"] = 'true'
|
|
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']
|
|
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
|
-
|
|
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']
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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)
|
data/lib/truenorth/version.rb
CHANGED
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.
|
|
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-
|
|
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
|