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