truenorth 0.1.2 → 0.2.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: 12c44289ec6c12de5f8262c64c09d6fa0880ffbad055a652a746730bd67870d5
4
- data.tar.gz: 2cac3facaf4f7d30bfefb26fddf82961ade02d2793ddfd9794ddbbbc1200c523
3
+ metadata.gz: 947c02ebdef94353524d6b1c62e4be1283aed2a296f1712257a1d2b183b241d9
4
+ data.tar.gz: 670aa44189ee7935e117d9a4b5936a4441caaf7c8618734b43683667f6bf99d0
5
5
  SHA512:
6
- metadata.gz: ccd4397aeb191711b34979df7a952572d5840cdaf6d8966359bd08fc3e8dcc1e42f3a1b35c880913ff718bbbb678705091eaf3188a93fc5145bcad0578e6f908
7
- data.tar.gz: dc6948684f2077bb93934f38e682b0dfc20b213277eb9d3d0858d68964a149f8c7261b7f5b1ff5c31bd9cc1326daf5b2c13401e56c407afa508650608219f46b
6
+ metadata.gz: ab5f4ce7a698a3419d1278d64f50cff501960508b47d2d176e3ce845c22ad0329de6256a545354a8046535eda31e710f554faf81c0df62789155fcebeaac9bf8
7
+ data.tar.gz: 51808fa039dcdf6c7a49eff96da6554ae7ad3de6193f8d1ddac191d51a49d73cab491862fad094d746c7d06a2cf532ffa3e8e32817daae33cb7d1797cd0fa510
data/lib/truenorth/cli.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'date'
5
+ require 'tty-table'
6
+ require 'io/console'
5
7
  require_relative '../truenorth'
6
8
 
7
9
  module Truenorth
@@ -107,11 +109,7 @@ module Truenorth
107
109
  if results.empty?
108
110
  say 'No reservations found.', :yellow
109
111
  else
110
- say "\nYour reservations:", :green
111
- results.each do |res|
112
- parts = [res[:date], res[:time], res[:activity], res[:court], res[:member]].compact
113
- say " #{parts.join(' - ')}"
114
- end
112
+ display_reservations_table(results)
115
113
  end
116
114
  end
117
115
  rescue Error => e
@@ -119,15 +117,41 @@ module Truenorth
119
117
  exit 1
120
118
  end
121
119
 
122
- desc 'cancel RESERVATION_ID', 'Cancel a reservation'
120
+ desc 'cancel INDEX', 'Cancel a reservation by index (from reservations list)'
123
121
  option :dry_run, type: :boolean, aliases: '-n', desc: 'Test without actually canceling'
124
- def cancel(reservation_id)
122
+ def cancel(index)
125
123
  client = Client.new
126
124
 
127
- say "Canceling reservation #{reservation_id}...", :cyan
128
- client.cancel(reservation_id, dry_run: options[:dry_run])
125
+ say 'Fetching reservations...', :cyan
126
+ results = client.reservations
127
+
128
+ idx = index.to_i - 1
129
+ if idx < 0 || idx >= results.length
130
+ say "Invalid index. Must be between 1 and #{results.length}", :red
131
+ exit 1
132
+ end
133
+
134
+ reservation = results[idx]
135
+ unless reservation[:cancel_id]
136
+ say 'This reservation cannot be cancelled (no cancel button found)', :red
137
+ exit 1
138
+ end
139
+
140
+ say "\nCancelling:", :cyan
141
+ say " #{format_reservation_line(reservation)}"
129
142
 
130
- say 'Reservation canceled.', :green
143
+ result = client.cancel(reservation[:cancel_id], dry_run: options[:dry_run])
144
+
145
+ if result[:success]
146
+ if result[:dry_run]
147
+ say "\nDry run successful - would cancel this reservation", :yellow
148
+ else
149
+ say "\n✓ Reservation cancelled!", :green
150
+ end
151
+ else
152
+ say "\nFailed: #{result[:error]}", :red
153
+ exit 1
154
+ end
131
155
  rescue Error => e
132
156
  say "Error: #{e.message}", :red
133
157
  exit 1
@@ -164,6 +188,89 @@ module Truenorth
164
188
 
165
189
  private
166
190
 
191
+ def display_reservations_table(results)
192
+ # Get terminal width
193
+ term_width = begin
194
+ IO.console.winsize[1]
195
+ rescue StandardError
196
+ 80
197
+ end
198
+
199
+ # Prepare table data
200
+ rows = results.each_with_index.map do |res, idx|
201
+ [
202
+ (idx + 1).to_s,
203
+ format_compact_date(res[:date]),
204
+ format_compact_time(res[:time]),
205
+ truncate_text(res[:activity] || '', 25),
206
+ truncate_text(res[:member] || '', 15)
207
+ ]
208
+ end
209
+
210
+ # Create table
211
+ table = TTY::Table.new(
212
+ header: ['#', 'Date', 'Time', 'Activity', 'Member'],
213
+ rows: rows
214
+ )
215
+
216
+ # Render with auto-width based on terminal
217
+ renderer = if term_width < 100
218
+ :basic
219
+ else
220
+ :unicode
221
+ end
222
+
223
+ puts table.render(renderer, padding: [0, 1])
224
+ end
225
+
226
+ def format_reservation_line(res)
227
+ "#{format_compact_date(res[:date])} #{format_compact_time(res[:time])} - #{res[:activity]} - #{res[:member]}"
228
+ end
229
+
230
+ def format_compact_date(date_str)
231
+ # Convert "02/11/2026" to "02-11"
232
+ return '' unless date_str
233
+
234
+ date = Date.strptime(date_str, '%m/%d/%Y')
235
+ date.strftime('%m-%d')
236
+ rescue StandardError
237
+ date_str
238
+ end
239
+
240
+ def format_compact_time(time_str)
241
+ # Convert "09:00 AM - 09:45 AM" to "09:00 (45min)"
242
+ return '' unless time_str
243
+
244
+ times = time_str.scan(/(\d{1,2}):(\d{2})\s*([AP]M)/)
245
+ return time_str if times.length < 2
246
+
247
+ start_h, start_m, start_period = times[0]
248
+ end_h, end_m, end_period = times[1]
249
+
250
+ # Convert to 24h format for calculation
251
+ start_24h = start_h.to_i
252
+ start_24h += 12 if start_period == 'PM' && start_24h != 12
253
+ start_24h = 0 if start_period == 'AM' && start_24h == 12
254
+
255
+ end_24h = end_h.to_i
256
+ end_24h += 12 if end_period == 'PM' && end_24h != 12
257
+ end_24h = 0 if end_period == 'AM' && end_24h == 12
258
+
259
+ # Calculate duration in minutes
260
+ duration = (end_24h * 60 + end_m.to_i) - (start_24h * 60 + start_m.to_i)
261
+
262
+ "#{start_h.rjust(2, '0')}:#{start_m} (#{duration}min)"
263
+ rescue StandardError
264
+ time_str
265
+ end
266
+
267
+ def truncate_text(text, max_length)
268
+ return '' unless text
269
+ return text if text.length <= max_length
270
+
271
+ "#{text[0...max_length - 1]}…"
272
+ end
273
+
167
274
  def parse_date(date_str)
168
275
  return Date.today if date_str.nil? || date_str.empty?
169
276
 
@@ -217,7 +217,7 @@ module Truenorth
217
217
  html.css('script').remove
218
218
 
219
219
  # Reservations are grouped by member in dt.ui-datalist-item elements
220
- html.css('dt.ui-datalist-item').each do |member_section|
220
+ html.css('dt.ui-datalist-item').each_with_index do |member_section, member_idx|
221
221
  # Extract member name from the header
222
222
  # Format: "Siegel, Jonathan's Reservations (50)" or "My Reservations(5)"
223
223
  header_text = member_section.text.gsub(/\s+/, ' ').strip
@@ -229,7 +229,7 @@ module Truenorth
229
229
  end
230
230
 
231
231
  # Now find all tables within this member section
232
- member_section.css('table tbody tr').each do |row|
232
+ member_section.css('table tbody tr').each_with_index do |row, row_idx|
233
233
  cells = row.css('td')
234
234
  next if cells.length < 2
235
235
 
@@ -237,10 +237,17 @@ module Truenorth
237
237
  text_parts = cells.map { |cell| clean_cell_text(cell) }
238
238
  next if text_parts.all?(&:empty?)
239
239
 
240
+ # Find the cancel button link (title="Cancel Reservation")
241
+ cancel_link = row.at_css('a[title="Cancel Reservation"]')
242
+ cancel_id = cancel_link['id'] if cancel_link
243
+
240
244
  # Parse the reservation data
241
245
  reservation = parse_reservation_row(text_parts)
242
246
  if reservation && reservation[:date]
243
247
  reservation[:member] = member_name
248
+ reservation[:member_idx] = member_idx
249
+ reservation[:row_idx] = row_idx
250
+ reservation[:cancel_id] = cancel_id
244
251
  reservations << reservation
245
252
  end
246
253
  end
@@ -257,9 +264,56 @@ module Truenorth
257
264
  end
258
265
 
259
266
  # Cancel a reservation
267
+ # reservation_id is the cancel button ID from the reservation
260
268
  def cancel(reservation_id, dry_run: false)
261
269
  ensure_logged_in!
262
- raise BookingError, 'Cancel not yet implemented'
270
+
271
+ log "\n=== CANCEL RESERVATION ==="
272
+ log "Cancel ID: #{reservation_id}"
273
+ log 'DRY RUN MODE' if dry_run
274
+
275
+ return { success: true, dry_run: true, message: 'Dry run - would cancel reservation' } if dry_run
276
+
277
+ # Get the reservations page to extract form state
278
+ response = get(RESERVATIONS_PATH)
279
+ html = Nokogiri::HTML(response.body)
280
+
281
+ view_state = extract_view_state(html)
282
+ form_id = '_memberReservations_WAR_northstarportlet_:reservationsForm'
283
+
284
+ raise BookingError, 'Could not extract view state' unless view_state
285
+
286
+ # Build the cancel AJAX request
287
+ ajax_url = "#{@base_url}#{RESERVATIONS_PATH}?p_p_id=memberReservations_WAR_northstarportlet" \
288
+ '&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view' \
289
+ '&p_p_cacheability=cacheLevelPage' \
290
+ '&_memberReservations_WAR_northstarportlet__jsfBridgeAjax=true'
291
+
292
+ form_data = {
293
+ 'javax.faces.partial.ajax' => 'true',
294
+ 'javax.faces.source' => reservation_id,
295
+ 'javax.faces.partial.execute' => '@all',
296
+ 'javax.faces.partial.render' => form_id,
297
+ form_id => form_id,
298
+ 'javax.faces.ViewState' => view_state,
299
+ reservation_id => reservation_id
300
+ }
301
+
302
+ response = post_ajax(ajax_url, form_data)
303
+
304
+ if response.is_a?(Net::HTTPSuccess)
305
+ # Check for success indicators in response
306
+ body = response.body
307
+ if body.include?('success') || body.include?('cancel') || body.length < 1000
308
+ log 'Cancellation successful'
309
+ { success: true, message: 'Reservation cancelled' }
310
+ else
311
+ log "Unexpected response: #{body[0..200]}"
312
+ { success: false, error: 'Unexpected response from server' }
313
+ end
314
+ else
315
+ { success: false, error: "HTTP #{response.code}" }
316
+ end
263
317
  end
264
318
 
265
319
  private
@@ -349,8 +403,9 @@ module Truenorth
349
403
  cell1 = text_parts[1]
350
404
 
351
405
  # Extract activity/event type and details
352
- # Format: "Activities (Court 2 | Squash)" or "Events (Event Name | Category)"
353
- activity_match = cell1.match(/(Activities|Events)\s+\(([^)]+)\)/)
406
+ # Format: "Activities (Court 2 | Squash)" or "Events (Event Name (time) | Category)"
407
+ # Use a greedy match that stops before the date pattern
408
+ activity_match = cell1.match(/(Activities|Events)\s+\((.+?)\)\s*\d{2}\/\d{2}\/\d{4}/)
354
409
  activity = nil
355
410
  court = nil
356
411
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
data/truenorth.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = 'truenorth'
5
- spec.version = '0.1.2'
5
+ spec.version = '0.2.0'
6
6
  spec.authors = ['usiegj00']
7
7
  spec.email = ['112138+usiegj00@users.noreply.github.com']
8
8
 
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.add_dependency 'base64', '~> 0.2'
31
31
  spec.add_dependency 'nokogiri', '~> 1.15'
32
32
  spec.add_dependency 'thor', '~> 1.3'
33
+ spec.add_dependency 'tty-table', '~> 0.12'
33
34
 
34
35
  spec.add_development_dependency 'bundler', '~> 2.0'
35
36
  spec.add_development_dependency 'rake', '~> 13.0'
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.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - usiegj00
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: tty-table
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.12'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.12'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: bundler
57
71
  requirement: !ruby/object:Gem::Requirement