truenorth 0.2.8 → 0.5.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: ebf93538d8d794249ddcb8f4ddb2832eba001e6758d275c4ea59af595777d50e
4
- data.tar.gz: e857b71120bf6c327fb83284ad901472656e12f0df542abd75223a953c6ea202
3
+ metadata.gz: 1ae8cfc6540c62e513428be9bf6b460cba5f1207f405d5dd075d2f3754fe9976
4
+ data.tar.gz: 20382b0505099606ebf214359796a956947ee6cf3667b7ac8d5797debf7e995b
5
5
  SHA512:
6
- metadata.gz: c4b4e536e26ee506d3ee57b1cc9f186f9696c2f10ae61ab834e4dd40999d36381f1aa01b03096c1eb118ab92c720bef71d1b74f07c18c37fd6050545d13b67ad
7
- data.tar.gz: '0619e6eb3fcb0220aebdcc6bb13f60c452d157c6e772dbd63596981c0dc4b6fed5cdbd8acf16a3c00916b853791875020651a0a3c5fe326fa8115a93fae5ab76'
6
+ metadata.gz: 38de51f859b0c3c6f20a2c23d2bffabc91de6de3d4e8f065a9e9f25bacfa2b1ac6d53d5aed5e8755a67ba8b99ed46909a63f8ea891929958e0e0e020bdb37949
7
+ data.tar.gz: e8372584aaa758b89b29c620e0c31ff63d97740e1a8bfd3ccaec52e88a063b2934e05aad1c465995454d0ffa1171596d4fd74d3dc26160c85f1e5b2aa1a3249c
data/lib/truenorth/cli.rb CHANGED
@@ -35,9 +35,17 @@ module Truenorth
35
35
  desc 'availability [DATE]', 'Check available slots'
36
36
  option :activity, aliases: '-a', default: 'squash', desc: 'Activity type (squash, golf, music, meeting)'
37
37
  option :json, type: :boolean, desc: 'Output as JSON'
38
+ option :debug, type: :boolean, desc: 'Show debug output'
39
+ option :http, type: :boolean, desc: 'Use HTTP mode (faster, but only 2 courts)'
38
40
  def availability(date = nil)
39
41
  date = parse_date(date)
40
- client = Client.new
42
+
43
+ client = if options[:http]
44
+ say 'Using HTTP mode (fast, 2 courts only)...', :yellow if !options[:json]
45
+ Client.new(debug: options[:debug])
46
+ else
47
+ BrowserClient.new(debug: options[:debug])
48
+ end
41
49
 
42
50
  say "Checking availability for #{date}...", :cyan
43
51
  result = client.availability(date, activity: options[:activity])
@@ -64,6 +72,8 @@ module Truenorth
64
72
  option :court, aliases: '-c', desc: 'Preferred court (e.g., "Court 1", "Squash Court 2")'
65
73
  option :activity, aliases: '-a', default: 'squash', desc: 'Activity type'
66
74
  option :dry_run, type: :boolean, aliases: '-n', desc: 'Test without actually booking'
75
+ option :http, type: :boolean, desc: 'Use HTTP mode (faster, but only 2 courts)'
76
+ option :debug, type: :boolean, desc: 'Show debug output'
67
77
  def book(time_or_description)
68
78
  # Parse natural language input
69
79
  parsed = parse_booking_request(time_or_description, options[:date], options[:activity])
@@ -71,7 +81,13 @@ module Truenorth
71
81
  date = parsed[:date]
72
82
  time = parsed[:time]
73
83
  activity = parsed[:activity]
74
- client = Client.new
84
+
85
+ # Use browser mode unless --http flag is set (needed to see all 3 courts)
86
+ client = if options[:http]
87
+ Client.new(debug: options[:debug])
88
+ else
89
+ BrowserClient.new(debug: options[:debug])
90
+ end
75
91
 
76
92
  mode = options[:dry_run] ? ' (DRY RUN)' : ''
77
93
  say "Booking #{activity} at #{time} on #{date}#{mode}...", :cyan
@@ -109,8 +125,10 @@ module Truenorth
109
125
  desc 'reservations', 'List your current reservations'
110
126
  option :json, type: :boolean, desc: 'Output as JSON'
111
127
  option :all, type: :boolean, aliases: '-a', desc: 'Show all family members (default: only you)'
128
+ option :debug, type: :boolean, desc: 'Show debug output'
112
129
  def reservations
113
- client = Client.new
130
+ # Use HTTP mode for reservations (works reliably)
131
+ client = Client.new(debug: options[:debug])
114
132
 
115
133
  say 'Fetching reservations...', :cyan
116
134
  results = client.reservations
@@ -153,6 +171,7 @@ module Truenorth
153
171
  option :all, type: :boolean, aliases: '-a', desc: 'Cancel from full list (use with --all view)'
154
172
  option :debug, type: :boolean, desc: 'Show debug output'
155
173
  def cancel(index)
174
+ # Use HTTP mode for cancellation (works reliably)
156
175
  client = Client.new(debug: options[:debug])
157
176
 
158
177
  say 'Fetching reservations...', :cyan
@@ -15,7 +15,8 @@ module Truenorth
15
15
  BOOKING_PATH = '/group/pages/facility-booking'
16
16
  RESERVATIONS_PATH = '/group/pages/my-reservations'
17
17
 
18
- USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' \
18
+ # Use a desktop user agent - server may detect mobile based on UA
19
+ USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' \
19
20
  'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
20
21
 
21
22
  # Activity type IDs
@@ -37,16 +38,27 @@ module Truenorth
37
38
  '32' => 'Court 3'
38
39
  }.freeze
39
40
 
41
+ # Court IDs by activity
42
+ COURT_IDS_BY_ACTIVITY = {
43
+ 'squash' => %w[16 17 18],
44
+ 'golf' => %w[30 31 32],
45
+ 'music' => %w[16 17 18], # May need to update these
46
+ 'room' => %w[30 31 32], # May need to update these
47
+ 'meeting' => %w[30 31 32] # May need to update these
48
+ }.freeze
49
+
40
50
  attr_reader :cookies, :debug_log, :logged_in
41
51
 
42
52
  def initialize(base_url: nil, debug: false)
43
53
  @base_url = base_url || Config.base_url
44
54
  raise Error, 'No base URL configured. Run: truenorth configure' unless @base_url
45
55
 
46
- @cookies = {}
56
+ @cookies = Config.cookies || {}
47
57
  @debug = debug
48
58
  @debug_log = StringIO.new
49
- @logged_in = false
59
+ @logged_in = !@cookies.empty? # If we have cookies, assume logged in
60
+
61
+ log "Loaded #{@cookies.length} cookies from cache" if @logged_in && @debug
50
62
  end
51
63
 
52
64
  # Login to the booking system
@@ -91,7 +103,8 @@ module Truenorth
91
103
 
92
104
  if response.body.include?('Sign Out') || response.body.include?('My Reservations')
93
105
  @logged_in = true
94
- log 'Login successful'
106
+ Config.save_cookies(@cookies)
107
+ log 'Login successful (cookies saved)'
95
108
  true
96
109
  else
97
110
  html = Nokogiri::HTML(response.body)
@@ -110,19 +123,70 @@ module Truenorth
110
123
  response = get(BOOKING_PATH)
111
124
  html = Nokogiri::HTML(response.body)
112
125
 
113
- # Check if we need to change activity type first
126
+ # Navigate to the requested date first if needed
127
+ current_date = html.at_css('input[name*="sheetDate"]')&.[]('value')
128
+ requested_date = date.strftime('%m/%d/%Y')
114
129
  activity_id = ACTIVITIES[activity.to_s.downcase] || '5'
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
115
137
  current_activity = html.at_css('input[name*="activityId"]')&.[]('value')
116
138
 
117
139
  if current_activity && current_activity != activity_id
118
- # Need to change activity via AJAX
140
+ log "Changing activity from #{current_activity} to #{activity_id}"
119
141
  html = change_activity(html, activity_id)
120
142
  end
121
143
 
122
- # TODO: Handle date navigation if needed
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}"
149
+ end
123
150
 
124
- slots = parse_slots(html)
125
- log "Found #{slots.count} available time slots"
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
126
190
 
127
191
  {
128
192
  success: true,
@@ -132,8 +196,10 @@ module Truenorth
132
196
  }
133
197
  end
134
198
 
199
+
135
200
  # Book a slot
136
- def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false)
201
+ # If slot_info is provided, it should have: { area_id:, start_time:, end_time:, court: }
202
+ def book(time, date: Date.today, court: nil, activity: 'squash', dry_run: false, slot_info: nil)
137
203
  ensure_logged_in!
138
204
 
139
205
  log "\n=== BOOK SLOT ==="
@@ -164,11 +230,22 @@ module Truenorth
164
230
  form_fields = extract_all_form_fields(html, form_id)
165
231
  end
166
232
 
167
- # Find the slot
168
- slot = find_slot(html, time, court)
169
- raise BookingError, "No slot available at #{time}" unless slot
170
-
171
- log "Found slot: #{slot[:court]} at #{slot[:start_time]}"
233
+ # Find the slot (or use provided slot_info)
234
+ if slot_info
235
+ log "Using provided slot info: #{slot_info[:court]} at #{slot_info[:start_time]}"
236
+ # Convert slot_info keys from symbols if needed and ensure we have an id
237
+ slot = {
238
+ id: nil, # We'll generate this or it's not needed for AJAX
239
+ area_id: slot_info[:area_id],
240
+ court: slot_info[:court],
241
+ start_time: slot_info[:start_time] || slot_info[:time],
242
+ end_time: slot_info[:end_time]
243
+ }
244
+ else
245
+ slot = find_slot(html, time, court)
246
+ raise BookingError, "No slot available at #{time}" unless slot
247
+ log "Found slot: #{slot[:court]} at #{slot[:start_time]}"
248
+ end
172
249
 
173
250
  # Select slot via AJAX
174
251
  select_result = select_slot_ajax(form_id, view_state, slot, components, form_fields)
@@ -409,17 +486,70 @@ module Truenorth
409
486
  def parse_slots(html)
410
487
  slots = {}
411
488
 
412
- html.css('td.slot.open').each do |td|
413
- div = td.at_css('div[data-start-time]')
414
- next unless div
415
- next if td['class']&.include?('reserved')
489
+ # Debug: Count total columns found
490
+ headers = html.css('thead th[role="columnheader"]')
491
+ log "Found #{headers.length} column headers: #{headers.map { |h| h['aria-label'] }.join(', ')}"
416
492
 
417
- start_time = div['data-start-time']
493
+ # Debug: Count slots by area-id
494
+ area_counts = Hash.new(0)
495
+ html.css('td.slot div[data-start-time]').each do |div|
418
496
  area_id = div['data-area-id']
419
- court_name = COURTS[area_id] || "Court #{area_id}"
497
+ area_counts[area_id] += 1
498
+ end
499
+ log "Slots by area ID: #{area_counts.inspect}"
500
+
501
+ # Find ALL slots with data-start-time
502
+ html.css('td.slot div[data-start-time]').each do |div|
503
+ td = div.parent
504
+ while td && td.name != 'td'
505
+ td = td.parent
506
+ end
507
+ next unless td
508
+
509
+ classes = td['class'].to_s
510
+
511
+ # Skip definitively unavailable slots
512
+ next if classes.include?('reserved')
513
+ next if classes.include?('restrict')
514
+ next if classes.include?('blocked')
420
515
 
421
- slots[start_time] ||= []
422
- slots[start_time] << court_name
516
+ # Include ANY slot that's marked as "open", even if it has past-time
517
+ # The website shows past-time slots for future dates as bookable
518
+ if classes.include?('open')
519
+ start_time = div['data-start-time']
520
+ area_id = div['data-area-id']
521
+ court_name = COURTS[area_id] || "Court #{area_id}"
522
+
523
+ slots[start_time] ||= []
524
+ slots[start_time] << court_name unless slots[start_time].include?(court_name)
525
+ end
526
+ end
527
+
528
+ # If we found very few slots, be more aggressive
529
+ if slots.length < 10
530
+ log "Found only #{slots.length} slots with strict parsing, trying relaxed parsing..."
531
+
532
+ # Try including ALL non-reserved slots
533
+ html.css('td.slot div[data-start-time]').each do |div|
534
+ td = div.parent
535
+ while td && td.name != 'td'
536
+ td = td.parent
537
+ end
538
+ next unless td
539
+
540
+ classes = td['class'].to_s
541
+ # Only skip if explicitly reserved/restricted/blocked
542
+ next if classes.include?('reserved') && !classes.include?('open')
543
+ next if classes.include?('restrict')
544
+ next if classes.include?('blocked')
545
+
546
+ start_time = div['data-start-time']
547
+ area_id = div['data-area-id']
548
+ court_name = COURTS[area_id] || "Court #{area_id}"
549
+
550
+ slots[start_time] ||= []
551
+ slots[start_time] << court_name unless slots[start_time].include?(court_name)
552
+ end
423
553
  end
424
554
 
425
555
  slots
@@ -541,9 +671,128 @@ module Truenorth
541
671
  result = change_activity_ajax(form_id, view_state, activity_id, form_fields, components)
542
672
  return html unless result[:success]
543
673
 
674
+ # Parse the response and check for Court 3
675
+ parsed = Nokogiri::HTML(result[:body])
676
+ court3_elements = parsed.css('th[aria-label*="Court 3"], div[data-area-id="18"], div[data-area-id="32"]')
677
+ log "After change_activity: Found #{court3_elements.length} Court 3 elements in response"
678
+
679
+ parsed
680
+ end
681
+
682
+ def change_court_dropdown(html, court_id)
683
+ form_id = extract_form_id(html)
684
+ view_state = extract_view_state(html)
685
+ form_fields = extract_all_form_fields(html, form_id)
686
+
687
+ # Find the court dropdown field dynamically (in .activity-areas div)
688
+ court_dropdown = html.at_css('.activity-areas select, div[class*="area"] select[id*="j_idt"]')
689
+ return html unless court_dropdown
690
+
691
+ dropdown_id = court_dropdown['id']
692
+ dropdown_source = dropdown_id.gsub(/_input$/, '')
693
+
694
+ result = change_court_dropdown_ajax(form_id, view_state, court_id, dropdown_id, dropdown_source, form_fields)
695
+ return html unless result[:success]
696
+
697
+ # Parse CDATA content from response
698
+ cdata_content = result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
699
+ if !cdata_content.empty?
700
+ cdata_html = Nokogiri::HTML(cdata_content)
701
+ slots = cdata_html.css('div[data-start-time]')
702
+ log "Court #{court_id}: Found #{slots.length} slots in CDATA response"
703
+ return cdata_html if slots.length > 0
704
+ end
705
+
544
706
  Nokogiri::HTML(result[:body])
545
707
  end
546
708
 
709
+ def change_court_area(html, area_id)
710
+ form_id = extract_form_id(html)
711
+ view_state = extract_view_state(html)
712
+ form_fields = extract_all_form_fields(html, form_id)
713
+
714
+ result = change_court_area_ajax(form_id, view_state, area_id, form_fields)
715
+ return html unless result[:success]
716
+
717
+ # Debug: Check response
718
+ parsed = Nokogiri::HTML(result[:body])
719
+ slot_divs = parsed.css('div[data-start-time]')
720
+ log "Court area #{area_id} response: #{slot_divs.length} slot divs found"
721
+
722
+ # Also check CDATA content
723
+ cdata_content = result[:body].scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
724
+ if !cdata_content.empty?
725
+ cdata_html = Nokogiri::HTML(cdata_content)
726
+ cdata_slots = cdata_html.css('div[data-start-time]')
727
+ log "Court area #{area_id} CDATA: #{cdata_slots.length} slot divs in CDATA"
728
+ return cdata_html if cdata_slots.length > 0
729
+ end
730
+
731
+ parsed
732
+ end
733
+
734
+ def change_date(html, date_str, activity_id = nil)
735
+ # Extract what we can from the HTML
736
+ form_id = extract_form_id(html)
737
+ view_state = extract_view_state(html)
738
+
739
+ # Build minimal form data manually to avoid extraction issues
740
+ form_data = build_minimal_form_data(html, form_id, activity_id)
741
+
742
+ result = change_date_ajax(form_id, view_state, date_str, form_data)
743
+ return html unless result[:success]
744
+
745
+ # Parse the AJAX response
746
+ ajax_body = result[:body]
747
+ cdata_content = ajax_body.scan(/<!\[CDATA\[(.*?)\]\]>/m).flatten.join("\n")
748
+
749
+ if !cdata_content.empty? && cdata_content.include?('slot')
750
+ parsed_html = Nokogiri::HTML(cdata_content)
751
+
752
+ # Debug: Check if Court 3 is in the response
753
+ court3_headers = parsed_html.css('th[aria-label*="Court 3"]')
754
+ log "Court 3 headers found in AJAX response: #{court3_headers.length}"
755
+
756
+ # Debug: Check for area ID 18 or 32 (Court 3 IDs)
757
+ court3_slots = parsed_html.css('div[data-area-id="18"], div[data-area-id="32"]')
758
+ log "Court 3 slots (area 18/32) found in AJAX response: #{court3_slots.length}"
759
+
760
+ parsed_html
761
+ else
762
+ html
763
+ end
764
+ end
765
+
766
+ def build_minimal_form_data(html, form_id, activity_id)
767
+ # Build the minimum required form data from scratch
768
+ # Only extract the fields we absolutely need
769
+ data = {}
770
+
771
+ # Try to find the form
772
+ form = html.at_css("form[id='#{form_id}']")
773
+ return data unless form
774
+
775
+ # Extract only the essential hidden fields
776
+ form.css('input[type="hidden"]').each do |input|
777
+ name = input['name']
778
+ value = input['value']
779
+ next unless name
780
+
781
+ # Only keep fields that belong to our form
782
+ if name.to_s.start_with?(form_id)
783
+ data[name] = value || ''
784
+ end
785
+ end
786
+
787
+ # Override activity ID if provided
788
+ if activity_id
789
+ data["#{form_id}:activityId"] = activity_id.to_s
790
+ data["#{form_id}:j_idt51_input"] = activity_id.to_s
791
+ end
792
+
793
+ data
794
+ end
795
+
547
796
  def extract_view_state(html)
548
797
  html.at_css('input[name="javax.faces.ViewState"]')&.[]('value')
549
798
  end
@@ -580,7 +829,22 @@ module Truenorth
580
829
 
581
830
  selected = select.at_css('option[selected]')
582
831
  fields[select['name']] = selected['value'] if selected
832
+
833
+ # Debug: Log select options
834
+ if select['name']&.include?('area') || select['name']&.include?('court') || select['name']&.include?('trainer')
835
+ options = select.css('option').map { |opt| "#{opt.text.strip}=#{opt['value']}" }
836
+ log "Found selector #{select['name']}: #{options.join(', ')}"
837
+ end
583
838
  end
839
+
840
+ # Force parameters to get all courts
841
+ fields["#{form_id}:mobileViewDisplay"] = '0' # 0 = full view
842
+
843
+ # Debug: Log all field names and key values
844
+ log "Form fields: #{fields.keys.join(', ')}"
845
+ log "activityAreaId current value: #{fields["#{form_id}:activityAreaId"]}"
846
+ log "showAllAreasOrTrainers: #{fields["#{form_id}:showAllAreasOrTrainers"]}"
847
+
584
848
  fields
585
849
  end
586
850
 
@@ -609,6 +873,12 @@ module Truenorth
609
873
  form_data = form_fields.dup
610
874
  form_data["#{form_id}:j_idt51_input"] = activity_id
611
875
  form_data["#{form_id}:activityId"] = activity_id
876
+ form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
877
+
878
+ # Debug: Log what we're sending
879
+ log "Sending to #{form_id}:showAllAreasOrTrainers = #{form_data["#{form_id}:showAllAreasOrTrainers"]}"
880
+ log "Sending to #{form_id}:mobileViewDisplay = #{form_data["#{form_id}:mobileViewDisplay"]}"
881
+
612
882
  form_data.merge!(
613
883
  'javax.faces.partial.ajax' => 'true',
614
884
  'javax.faces.source' => activity_dropdown,
@@ -629,6 +899,90 @@ module Truenorth
629
899
  end
630
900
  end
631
901
 
902
+ def change_court_dropdown_ajax(form_id, view_state, court_id, dropdown_input_id, dropdown_source, form_fields)
903
+ ajax_url = build_ajax_url
904
+ encoded_url = URI.encode_www_form_component(ajax_url)
905
+
906
+ form_data = form_fields.dup
907
+ form_data[dropdown_input_id] = court_id
908
+ form_data.merge!(
909
+ 'javax.faces.partial.ajax' => 'true',
910
+ 'javax.faces.source' => dropdown_source,
911
+ 'javax.faces.partial.execute' => dropdown_source,
912
+ 'javax.faces.partial.render' => form_id,
913
+ 'javax.faces.behavior.event' => 'change',
914
+ 'javax.faces.partial.event' => 'change',
915
+ form_id => form_id,
916
+ 'javax.faces.encodedURL' => encoded_url,
917
+ 'javax.faces.ViewState' => view_state
918
+ )
919
+
920
+ response = post_ajax(ajax_url, form_data)
921
+ if response.is_a?(Net::HTTPSuccess)
922
+ { success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
923
+ else
924
+ { success: false, error: "HTTP #{response.code}" }
925
+ end
926
+ end
927
+
928
+ def change_court_area_ajax(form_id, view_state, area_id, form_fields)
929
+ ajax_url = build_ajax_url
930
+ encoded_url = URI.encode_www_form_component(ajax_url)
931
+
932
+ form_data = form_fields.dup
933
+ form_data["#{form_id}:activityAreaId"] = area_id
934
+ form_data.merge!(
935
+ 'javax.faces.partial.ajax' => 'true',
936
+ 'javax.faces.source' => form_id,
937
+ 'javax.faces.partial.execute' => form_id,
938
+ 'javax.faces.partial.render' => form_id,
939
+ form_id => form_id,
940
+ 'javax.faces.encodedURL' => encoded_url,
941
+ 'javax.faces.ViewState' => view_state
942
+ )
943
+
944
+ response = post_ajax(ajax_url, form_data)
945
+ if response.is_a?(Net::HTTPSuccess)
946
+ { success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
947
+ else
948
+ { success: false, error: "HTTP #{response.code}" }
949
+ end
950
+ end
951
+
952
+ def change_date_ajax(form_id, view_state, date_str, form_data)
953
+ return { success: false, error: 'No form_id' } unless form_id
954
+
955
+ date_picker = "#{form_id}:j_idt57"
956
+ ajax_url = build_ajax_url
957
+ encoded_url = URI.encode_www_form_component(ajax_url)
958
+
959
+ # Use form_data as-is (already built correctly by build_minimal_form_data)
960
+ # Just add our date values and AJAX control parameters
961
+ form_data = form_data.dup
962
+ form_data["#{form_id}:sheetDate"] = date_str
963
+ form_data["#{form_id}:j_idt57_input"] = date_str
964
+ form_data["#{form_id}:showAllAreasOrTrainers"] = 'true' # Show all courts!
965
+
966
+ form_data.merge!(
967
+ 'javax.faces.partial.ajax' => 'true',
968
+ 'javax.faces.source' => date_picker,
969
+ 'javax.faces.partial.execute' => date_picker,
970
+ 'javax.faces.partial.render' => form_id,
971
+ 'javax.faces.behavior.event' => 'dateSelect',
972
+ 'javax.faces.partial.event' => 'dateSelect',
973
+ form_id => form_id,
974
+ 'javax.faces.encodedURL' => encoded_url,
975
+ 'javax.faces.ViewState' => view_state
976
+ )
977
+
978
+ response = post_ajax(ajax_url, form_data)
979
+ if response.is_a?(Net::HTTPSuccess)
980
+ { success: true, view_state: extract_view_state_from_ajax(response.body), body: response.body }
981
+ else
982
+ { success: false, error: "HTTP #{response.code}" }
983
+ end
984
+ end
985
+
632
986
  def select_slot_ajax(form_id, view_state, slot, components, form_fields)
633
987
  source_id = components['showReservationScreen'] || "#{form_id}:j_idt146"
634
988
  ajax_url = build_ajax_url
@@ -681,10 +1035,22 @@ module Truenorth
681
1035
 
682
1036
  response = post_ajax(ajax_url, form_data)
683
1037
  if response.is_a?(Net::HTTPSuccess)
684
- body = response.body.downcase
685
- if body.include?('success') || body.include?('confirmed') || body.include?('booked')
1038
+ body = response.body
1039
+ body_lower = body.downcase
1040
+
1041
+ # Check for explicit error indicators
1042
+ if body_lower.include?('error') || body_lower.include?('failed') ||
1043
+ body_lower.include?('unable') || body_lower.include?('invalid')
1044
+ log "Booking save returned error indicators in response"
1045
+ { success: false, error: 'Booking save failed' }
1046
+ # Check for success indicators OR assume success if reasonable response
1047
+ elsif body_lower.include?('success') || body_lower.include?('confirmed') ||
1048
+ body_lower.include?('booked') || body_lower.include?('reserved') ||
1049
+ (body.length < 5000 && !body_lower.include?('exception'))
1050
+ log "Booking save appears successful (HTTP 200)"
686
1051
  { success: true, confirmation: 'Booking confirmed' }
687
1052
  else
1053
+ log "Uncertain booking result, response length: #{body.length}"
688
1054
  { success: false, error: 'No confirmation in response' }
689
1055
  end
690
1056
  else
@@ -8,6 +8,7 @@ module Truenorth
8
8
  CONFIG_DIR = File.expand_path('~/.config/truenorth')
9
9
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
10
10
  CREDENTIALS_FILE = File.join(CONFIG_DIR, 'credentials.yml')
11
+ COOKIES_FILE = File.join(CONFIG_DIR, 'cookies.yml')
11
12
 
12
13
  class << self
13
14
  def load
@@ -62,8 +63,42 @@ module Truenorth
62
63
  CONFIG_DIR
63
64
  end
64
65
 
66
+ def cookies
67
+ load_cookies
68
+ end
69
+
70
+ def save_cookies(cookies_hash)
71
+ FileUtils.mkdir_p(CONFIG_DIR)
72
+ File.write(COOKIES_FILE, YAML.dump({
73
+ 'cookies' => cookies_hash,
74
+ 'timestamp' => Time.now.to_i
75
+ }))
76
+ FileUtils.chmod(0o600, COOKIES_FILE)
77
+ end
78
+
79
+ def clear_cookies
80
+ File.delete(COOKIES_FILE) if File.exist?(COOKIES_FILE)
81
+ end
82
+
65
83
  private
66
84
 
85
+ def load_cookies
86
+ return {} unless File.exist?(COOKIES_FILE)
87
+
88
+ data = YAML.safe_load(File.read(COOKIES_FILE)) || {}
89
+ timestamp = data['timestamp']
90
+
91
+ # Cookies expire after 24 hours
92
+ if timestamp && (Time.now.to_i - timestamp) < 86400
93
+ data['cookies'] || {}
94
+ else
95
+ clear_cookies
96
+ {}
97
+ end
98
+ rescue StandardError
99
+ {}
100
+ end
101
+
67
102
  def load_config
68
103
  return {} unless File.exist?(CONFIG_FILE)
69
104
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.2.8'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/truenorth.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'truenorth/version'
4
4
  require_relative 'truenorth/client'
5
+ require_relative 'truenorth/browser_client'
5
6
  require_relative 'truenorth/config'
6
7
 
7
8
  module Truenorth
data/truenorth.gemspec CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'lib/truenorth/version'
4
+
3
5
  Gem::Specification.new do |spec|
4
6
  spec.name = 'truenorth'
5
- spec.version = '0.2.8'
7
+ spec.version = Truenorth::VERSION
6
8
  spec.authors = ['usiegj00']
7
9
  spec.email = ['112138+usiegj00@users.noreply.github.com']
8
10
 
@@ -28,6 +30,7 @@ Gem::Specification.new do |spec|
28
30
  spec.require_paths = ['lib']
29
31
 
30
32
  spec.add_dependency 'base64', '~> 0.2'
33
+ spec.add_dependency 'ferrum', '~> 0.15'
31
34
  spec.add_dependency 'nokogiri', '~> 1.15'
32
35
  spec.add_dependency 'thor', '~> 1.3'
33
36
  spec.add_dependency 'tty-table', '~> 0.12'
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.8
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - usiegj00
@@ -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: ferrum
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.15'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.15'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: nokogiri
29
43
  requirement: !ruby/object:Gem::Requirement