calview 1.1.1 → 2.0.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/bin/calview.rb +539 -73
  3. metadata +58 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0635d33b07e92c452f380c106267de39e5b041dd02ef7398fd2fb363ad124d2
4
- data.tar.gz: c2d09f43a7788f5b98c3442923f869fc4617f89e13f4d0cebee8219d6ba0ea52
3
+ metadata.gz: d2f77c86874981a696b27cb95c9deade07b24f769d0ce6e793b9bac01ca17aec
4
+ data.tar.gz: fc2282ea3930cc787b3fca553562fcbb67edd90710c9d85e76acc2e5ea478bde
5
5
  SHA512:
6
- metadata.gz: eff91f4d2a2aa624452133ff97f50c529763a17251520f1fd878e434ee68fde0ec6d60c2119d84f215b351c2ed7d05b7cc01652ada30fb607d1427711766dc8b
7
- data.tar.gz: f5029527c2519dea31172ae0af69cd7714381e345a0a2de8f86e968ded7801e5378d955a62bc44871fbf34042c86850fa2a32772c55918d0c680eac271732b19
6
+ metadata.gz: 9c9da13224b12982dfb824f3a1739c912f4be59794cd2d1f357975c7adf17eac4d420f38d3c3c99e5319a2b1b3b155e8a15fd31978b590e7dafd128684f6b4e8
7
+ data.tar.gz: 15e6d4182ee4098ae112e2e183638552318af2683501c52bc6afcbca81614c82e8a094943fda8f26ce91ded6cc70185ee9b6c8d1d1389a3bb7a3b1bd6dde7713
data/bin/calview.rb CHANGED
@@ -1,92 +1,558 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # This is a simple script that takes vcal attachments and displays them in
4
- # pure text. This is suitable for displaying callendar invitations in mutt.
3
+ # VcalView - A simple VCAL/ICS viewer for terminal and mutt
5
4
  #
6
- # Add this to your to your .mailcap:
5
+ # Add this to your .mailcap:
7
6
  # text/calendar; /<pathto>/calview.rb '%s'; copiousoutput
8
7
  #
9
8
  # Created by Geir Isene <g@isene.com> in 2020 and released into Public Domain.
10
9
 
11
- require "date"
10
+ require "time"
11
+ require "optparse"
12
+ require "strscan"
12
13
 
13
- vcal = ARGF.read
14
+ class VcalParser
15
+ # Precompile all regex patterns for better performance
16
+ PATTERNS = {
17
+ multiline_attendee: /(^ATTENDEE.*)\n^ (.*)/,
18
+ dtstart_tzid: /^DTSTART;TZID=(.*?):(.*?)(?:T(\d{4}))?$/,
19
+ dtend_tzid: /^DTEND;TZID=(.*?):(.*?)(?:T(\d{4}))?$/,
20
+ dtstart_date: /^DTSTART;VALUE=DATE:(.*)$/,
21
+ dtend_date: /^DTEND;VALUE=DATE:(.*)$/,
22
+ dtstart_utc: /^DTSTART:(.*?)(?:T(\d{4}))?$/,
23
+ dtend_utc: /^DTEND:(.*?)(?:T(\d{4}))?$/,
24
+ organizer_cn: /^ORGANIZER;CN=(.*)$/,
25
+ organizer: /^ORGANIZER:(.*)$/,
26
+ attendee: /^ATTENDEE.*CN=([\s\S]*?@.*)\n/,
27
+ summary_param: /^SUMMARY;.*:(.*)$/,
28
+ summary: /^SUMMARY:(.*)$/,
29
+ description_uid: /^DESCRIPTION;.*?:(.*)^UID/m,
30
+ description_summary: /^DESCRIPTION:(.*)^SUMMARY/m,
31
+ description_generic: /^DESCRIPTION:(.*?)^[A-Z]/m,
32
+ location: /^LOCATION:(.*)$/,
33
+ uid: /^UID:(.*)$/,
34
+ rrule: /^RRULE:(.*)$/,
35
+ status: /^STATUS:(.*)$/,
36
+ priority: /^PRIORITY:(.*)$/,
37
+ tzoffset: /^TZOFFSET/,
38
+ date_format: /(\d{4})(\d{2})(\d{2})/,
39
+ time_format: /(\d{2})(\d{2})/,
40
+ begin_vcal: /BEGIN:VCALENDAR/,
41
+ begin_event: /BEGIN:VEVENT/
42
+ }.freeze
14
43
 
15
- # Fix multiline participants
16
- vcal.gsub!( /(^ATTENDEE.*)\n^ (.*)/, '\1\2' )
44
+ # Windows timezone mappings - only loaded when needed
45
+ WINDOWS_TZ = {
46
+ "AUS Central Standard Time"=>"Australia/Darwin",
47
+ "AUS Eastern Standard Time"=>"Australia/Melbourne",
48
+ "Afghanistan Standard Time"=>"Asia/Kabul",
49
+ "Alaskan Standard Time"=>"America/Juneau",
50
+ "Arab Standard Time"=>"Asia/Kuwait",
51
+ "Arabian Standard Time"=>"Asia/Muscat",
52
+ "Arabic Standard Time"=>"Asia/Baghdad",
53
+ "Argentina Standard Time"=>"America/Argentina/Buenos_Aires",
54
+ "Atlantic Standard Time"=>"America/Halifax",
55
+ "Azerbaijan Standard Time"=>"Asia/Baku",
56
+ "Azores Standard Time"=>"Atlantic/Azores",
57
+ "Bahia Standard Time"=>"America/Bahia",
58
+ "Bangladesh Standard Time"=>"Asia/Dhaka",
59
+ "Belarus Standard Time"=>"Europe/Minsk",
60
+ "Canada Central Standard Time"=>"America/Regina",
61
+ "Cape Verde Standard Time"=>"Atlantic/Cape_Verde",
62
+ "Caucasus Standard Time"=>"Asia/Yerevan",
63
+ "Cen. Australia Standard Time"=>"Australia/Adelaide",
64
+ "Central America Standard Time"=>"America/Guatemala",
65
+ "Central Asia Standard Time"=>"Asia/Almaty",
66
+ "Central Brazilian Standard Time"=>"America/Cuiaba",
67
+ "Central Europe Standard Time"=>"Europe/Belgrade",
68
+ "Central European Standard Time"=>"Europe/Warsaw",
69
+ "Central Pacific Standard Time"=>"Pacific/Guadalcanal",
70
+ "Central Standard Time (Mexico)"=>"America/Mexico_City",
71
+ "Central Standard Time"=>"America/Chicago",
72
+ "China Standard Time"=>"Asia/Shanghai",
73
+ "Dateline Standard Time"=>"Etc/GMT+12",
74
+ "E. Africa Standard Time"=>"Africa/Nairobi",
75
+ "E. Australia Standard Time"=>"Australia/Brisbane",
76
+ "E. Europe Standard Time"=>"Etc/GMT-2",
77
+ "E. South America Standard Time"=>"America/Sao_Paulo",
78
+ "Eastern Standard Time (Mexico)"=>"America/Cancun",
79
+ "Eastern Standard Time"=>"America/New_York",
80
+ "Egypt Standard Time"=>"Africa/Cairo",
81
+ "Ekaterinburg Standard Time"=>"Asia/Yekaterinburg",
82
+ "FLE Standard Time"=>"Europe/Helsinki",
83
+ "Fiji Standard Time"=>"Pacific/Fiji",
84
+ "GMT Standard Time"=>"Etc/GMT",
85
+ "GTB Standard Time"=>"Europe/Bucharest",
86
+ "Georgian Standard Time"=>"Asia/Tbilisi",
87
+ "Greenland Standard Time"=>"America/Godthab",
88
+ "Greenwich Standard Time"=>"Etc/GMT",
89
+ "Hawaiian Standard Time"=>"Pacific/Honolulu",
90
+ "India Standard Time"=>"Asia/Kolkata",
91
+ "Iran Standard Time"=>"Asia/Tehran",
92
+ "Israel Standard Time"=>"Asia/Jerusalem",
93
+ "Jordan Standard Time"=>"Asia/Amman",
94
+ "Kaliningrad Standard Time"=>"Europe/Kaliningrad",
95
+ "Korea Standard Time"=>"Asia/Seoul",
96
+ "Libya Standard Time"=>"Africa/Tripoli",
97
+ "Line Islands Standard Time"=>"Pacific/Kiritimati",
98
+ "Magadan Standard Time"=>"Asia/Magadan",
99
+ "Mauritius Standard Time"=>"Indian/Mauritius",
100
+ "Middle East Standard Time"=>"Asia/Beirut",
101
+ "Montevideo Standard Time"=>"America/Montevideo",
102
+ "Morocco Standard Time"=>"Africa/Casablanca",
103
+ "Mountain Standard Time (Mexico)"=>"America/Mazatlan",
104
+ "Mountain Standard Time"=>"America/Denver",
105
+ "Myanmar Standard Time"=>"Asia/Rangoon",
106
+ "N. Central Asia Standard Time"=>"Asia/Novosibirsk",
107
+ "Namibia Standard Time"=>"Africa/Windhoek",
108
+ "Nepal Standard Time"=>"Asia/Kathmandu",
109
+ "New Zealand Standard Time"=>"Pacific/Auckland",
110
+ "Newfoundland Standard Time"=>"America/St_Johns",
111
+ "North Asia East Standard Time"=>"Asia/Irkutsk",
112
+ "North Asia Standard Time"=>"Asia/Krasnoyarsk",
113
+ "Pacific SA Standard Time"=>"America/Santiago",
114
+ "Pacific Standard Time (Mexico)"=>"America/Santa_Isabel",
115
+ "Pacific Standard Time"=>"America/Los_Angeles",
116
+ "Pakistan Standard Time"=>"Asia/Karachi",
117
+ "Paraguay Standard Time"=>"America/Asuncion",
118
+ "Romance Standard Time"=>"Europe/Paris",
119
+ "Russia Time Zone 10"=>"Asia/Srednekolymsk",
120
+ "Russia Time Zone 11"=>"Asia/Kamchatka",
121
+ "Russia Time Zone 3"=>"Europe/Samara",
122
+ "Russian Standard Time"=>"Europe/Moscow",
123
+ "SA Eastern Standard Time"=>"America/Cayenne",
124
+ "SA Pacific Standard Time"=>"America/Bogota",
125
+ "SA Western Standard Time"=>"America/Guyana",
126
+ "SE Asia Standard Time"=>"Asia/Bangkok",
127
+ "Samoa Standard Time"=>"Pacific/Apia",
128
+ "Singapore Standard Time"=>"Asia/Singapore",
129
+ "South Africa Standard Time"=>"Africa/Johannesburg",
130
+ "Sri Lanka Standard Time"=>"Asia/Colombo",
131
+ "Syria Standard Time"=>"Asia/Damascus",
132
+ "Taipei Standard Time"=>"Asia/Taipei",
133
+ "Tasmania Standard Time"=>"Australia/Hobart",
134
+ "Tokyo Standard Time"=>"Asia/Tokyo",
135
+ "Tonga Standard Time"=>"Pacific/Tongatapu",
136
+ "Turkey Standard Time"=>"Europe/Istanbul",
137
+ "US Eastern Standard Time"=>"America/Indiana/Indianapolis",
138
+ "US Mountain Standard Time"=>"America/Phoenix",
139
+ "UTC"=>"Etc/UTC",
140
+ "Ulaanbaatar Standard Time"=>"Asia/Ulaanbaatar",
141
+ "Venezuela Standard Time"=>"America/Caracas",
142
+ "Vladivostok Standard Time"=>"Asia/Vladivostok",
143
+ "W. Australia Standard Time"=>"Australia/Perth",
144
+ "W. Central Africa Standard Time"=>"Africa/Algiers",
145
+ "W. Europe Standard Time"=>"Europe/Berlin",
146
+ "West Asia Standard Time"=>"Asia/Tashkent",
147
+ "West Pacific Standard Time"=>"Pacific/Guam",
148
+ "Yakutsk Standard Time"=>"Asia/Yakutsk",
149
+ }.freeze
17
150
 
18
- # Get dates and times
19
- if vcal.match( /^DTSTART;TZID=/ )
20
- sdate = vcal[ /^DTSTART;TZID=.*:(.*)T/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
21
- edate = vcal[ /^DTEND;TZID=.*:(.*)T/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
22
- stime = vcal[ /^DTSTART;TZID=.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
23
- etime = vcal[ /^DTEND;TZID=.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
24
- elsif vcal.match( /DTSTART;VALUE=DATE:/ )
25
- sdate = vcal[ /^DTSTART;VALUE=DATE:(.*)/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
26
- edate = vcal[ /^DTEND;VALUE=DATE:(.*)/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
27
- begin
28
- stime = vcal[ /^DTSTART.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
29
- rescue
30
- stime = "All day"
151
+ def initialize(vcal_content)
152
+ @vcal = vcal_content
153
+ @event = {}
154
+ @tzinfo_loaded = false
31
155
  end
32
- begin
33
- etime = vcal[ /^DTEND.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
34
- rescue
35
- etime = stime
36
- end
37
- else
38
- sdate = vcal[ /^DTSTART:(.*)T/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
39
- edate = vcal[ /^DTEND:(.*)T/, 1 ].sub( /(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
40
- stime = vcal[ /^DTSTART.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
41
- etime = vcal[ /^DTEND.*T(\d\d\d\d)/, 1 ].sub( /(\d\d)(\d\d)/, '\1:\2')
42
- end
43
156
 
44
- # Adjust for local TZ offset
45
- unless vcal.match( /^TZOFFSET/ )
46
- stime = (stime.to_i + Time.now.getlocal.utc_offset / 3600).to_s + stime.sub( /\d\d/, '') unless stime == "All day"
47
- etime = (etime.to_i + Time.now.getlocal.utc_offset / 3600).to_s + etime.sub( /\d\d/, '') unless stime == "All day"
48
- end
157
+ def parse
158
+ return nil unless valid_vcal?
159
+
160
+ # Fix multiline participants (single gsub operation)
161
+ @vcal.gsub!(PATTERNS[:multiline_attendee], '\1\2')
162
+
163
+ # Parse in most likely order of appearance
164
+ parse_summary
165
+ parse_dates_and_times
166
+ parse_organizer
167
+ parse_location
168
+ parse_description
169
+ parse_participants
170
+ parse_uid
171
+ parse_recurrence
172
+ parse_status
173
+ parse_priority
174
+
175
+ @event
176
+ end
49
177
 
50
- # Get organizer
51
- if vcal.match( /^ORGANIZER;CN=/ )
52
- org = vcal[ /^ORGANIZER;CN=(.*)/, 1 ].sub( /:mailto:/i, ' <') + ">"
53
- else
54
- begin
55
- org = vcal[ /^ORGANIZER:(.*)/, 1 ].sub( /MAILTO:/i, ' <') + ">"
56
- rescue
57
- org = "(None set)"
178
+ private
179
+
180
+ def valid_vcal?
181
+ return false if @vcal.nil? || @vcal.empty?
182
+ return false unless @vcal.match?(PATTERNS[:begin_vcal]) || @vcal.match?(PATTERNS[:begin_event])
183
+ true
184
+ end
185
+
186
+ def load_tzinfo
187
+ return if @tzinfo_loaded
188
+ begin
189
+ require "tzinfo"
190
+ @tzinfo_loaded = true
191
+ rescue LoadError
192
+ @tzinfo_loaded = false
193
+ end
194
+ end
195
+
196
+ def parse_dates_and_times
197
+ # Check for timezone dates first (most complex)
198
+ if match = @vcal.match(PATTERNS[:dtstart_tzid])
199
+ parse_timezone_dates_fast(match)
200
+ elsif match = @vcal.match(PATTERNS[:dtstart_date])
201
+ parse_all_day_dates_fast(match)
202
+ else
203
+ parse_utc_dates_fast
204
+ end
205
+ end
206
+
207
+ def parse_timezone_dates_fast(start_match)
208
+ load_tzinfo
209
+
210
+ stz_name = start_match[1]
211
+ sdate = extract_date(start_match[2])
212
+ stime = start_match[3] ? extract_time(start_match[3]) : nil
213
+
214
+ end_match = @vcal.match(PATTERNS[:dtend_tzid])
215
+ if end_match
216
+ etz_name = end_match[1]
217
+ edate = extract_date(end_match[2])
218
+ etime = end_match[3] ? extract_time(end_match[3]) : nil
219
+ else
220
+ etz_name = stz_name
221
+ edate = sdate
222
+ etime = stime
223
+ end
224
+
225
+ if @tzinfo_loaded && stime && etime
226
+ stz = convert_timezone(stz_name)
227
+ etz = convert_timezone(etz_name)
228
+
229
+ if stz && etz
230
+ begin
231
+ stime = stz.local_to_utc(Time.parse("#{sdate} #{stime}")).localtime
232
+ etime = etz.local_to_utc(Time.parse("#{edate} #{etime}")).localtime
233
+ rescue
234
+ # Keep string times on error
235
+ end
236
+ end
237
+ end
238
+
239
+ store_date_time_info(sdate, edate, stime, etime)
240
+ end
241
+
242
+ def parse_all_day_dates_fast(start_match)
243
+ sdate = extract_date(start_match[1])
244
+
245
+ end_match = @vcal.match(PATTERNS[:dtend_date])
246
+ edate = end_match ? extract_date(end_match[1]) : sdate
247
+
248
+ store_date_time_info(sdate, edate, "All day", "All day")
249
+ end
250
+
251
+ def parse_utc_dates_fast
252
+ start_match = @vcal.match(PATTERNS[:dtstart_utc])
253
+ return unless start_match
254
+
255
+ sdate = extract_date(start_match[1])
256
+ stime = start_match[2] ? extract_time(start_match[2]) : nil
257
+
258
+ end_match = @vcal.match(PATTERNS[:dtend_utc])
259
+ if end_match
260
+ edate = extract_date(end_match[1])
261
+ etime = end_match[2] ? extract_time(end_match[2]) : nil
262
+ else
263
+ edate = sdate
264
+ etime = stime
265
+ end
266
+
267
+ # Adjust for local TZ offset if no TZOFFSET specified
268
+ unless @vcal.match?(PATTERNS[:tzoffset]) || stime == "All day"
269
+ offset = Time.now.getlocal.utc_offset / 3600
270
+ stime = adjust_time_with_offset(stime, offset) if stime
271
+ etime = adjust_time_with_offset(etime, offset) if etime
272
+ end
273
+
274
+ store_date_time_info(sdate, edate, stime, etime)
275
+ end
276
+
277
+ def extract_date(date_str)
278
+ return nil unless date_str
279
+ # Direct string manipulation is faster than regex for fixed format
280
+ return date_str if date_str.include?('-')
281
+ "#{date_str[0,4]}-#{date_str[4,2]}-#{date_str[6,2]}"
282
+ end
283
+
284
+ def extract_time(time_str)
285
+ return nil unless time_str
286
+ # Direct string manipulation is faster than regex for fixed format
287
+ "#{time_str[0,2]}:#{time_str[2,2]}"
288
+ end
289
+
290
+ def adjust_time_with_offset(time_str, offset)
291
+ return time_str unless time_str && time_str[2] == ':'
292
+ hour = time_str[0,2].to_i + offset
293
+ "#{hour}:#{time_str[3,2]}"
294
+ end
295
+
296
+ def convert_timezone(tz_name)
297
+ return nil unless tz_name && @tzinfo_loaded
298
+
299
+ # Check for Windows timezone mapping
300
+ tz_name = WINDOWS_TZ[tz_name] || tz_name
301
+
302
+ begin
303
+ TZInfo::Timezone.get(tz_name)
304
+ rescue
305
+ nil
306
+ end
307
+ end
308
+
309
+ def store_date_time_info(sdate, edate, stime, etime)
310
+ @event[:start_date] = sdate
311
+ @event[:end_date] = edate
312
+ @event[:dates] = sdate == edate ? sdate : "#{sdate} - #{edate}"
313
+
314
+ if sdate
315
+ begin
316
+ dobj = Time.parse(sdate)
317
+ @event[:weekday] = dobj.strftime('%A')
318
+ @event[:week] = dobj.strftime('%-V')
319
+ rescue
320
+ # Skip weekday/week on parse error
321
+ end
322
+ end
323
+
324
+ if stime.is_a?(Time) && etime.is_a?(Time)
325
+ @event[:times] = stime == etime ? stime.strftime("%H:%M") : "#{stime.strftime("%H:%M")} - #{etime.strftime("%H:%M")}"
326
+ elsif stime == "All day"
327
+ @event[:times] = "All day"
328
+ else
329
+ @event[:times] = stime == etime || stime == stime.to_s ? stime : "#{stime} - #{etime}"
330
+ end
331
+ end
332
+
333
+ def parse_organizer
334
+ if match = @vcal.match(PATTERNS[:organizer_cn])
335
+ org = match[1].sub(/:mailto:/i, ' <') + ">"
336
+ elsif match = @vcal.match(PATTERNS[:organizer])
337
+ org = match[1].sub(/MAILTO:/i, '<') + ">"
338
+ else
339
+ org = "(None set)"
340
+ end
341
+ @event[:organizer] = org
342
+ end
343
+
344
+ def parse_participants
345
+ participants = @vcal.scan(PATTERNS[:attendee])
346
+ if participants.any?
347
+ # Use join with newline directly instead of multiple gsubs
348
+ part = participants.flatten
349
+ .map { |p| p.gsub(/\n /, '').sub(/:mailto:/i, " <") + ">" }
350
+ .join("\n ")
351
+ @event[:participants] = " #{part}"
352
+ else
353
+ @event[:participants] = ""
354
+ end
355
+ end
356
+
357
+ def parse_summary
358
+ match = @vcal.match(PATTERNS[:summary_param]) || @vcal.match(PATTERNS[:summary])
359
+ @event[:summary] = clean_text(match[1]) if match
360
+ end
361
+
362
+ def parse_description
363
+ match = @vcal.match(PATTERNS[:description_uid]) ||
364
+ @vcal.match(PATTERNS[:description_summary]) ||
365
+ @vcal.match(PATTERNS[:description_generic])
366
+
367
+ if match
368
+ desc = match[1]
369
+ .gsub(/\n /, '')
370
+ .gsub(/\\n/, "\n")
371
+ .gsub(/\n\n+/, "\n")
372
+ .gsub(/ \| /, "\n")
373
+ .strip
374
+ @event[:description] = desc
375
+ else
376
+ @event[:description] = ""
377
+ end
378
+ end
379
+
380
+ def parse_location
381
+ match = @vcal.match(PATTERNS[:location])
382
+ @event[:location] = clean_text(match[1]) if match
383
+ end
384
+
385
+ def parse_uid
386
+ match = @vcal.match(PATTERNS[:uid])
387
+ @event[:uid] = match[1].strip if match
388
+ end
389
+
390
+ def parse_recurrence
391
+ match = @vcal.match(PATTERNS[:rrule])
392
+ @event[:recurrence] = parse_rrule(match[1]) if match
393
+ end
394
+
395
+ def parse_rrule(rrule)
396
+ # Use StringScanner for faster parsing
397
+ parts = {}
398
+ rrule.split(';').each do |part|
399
+ key, value = part.split('=', 2)
400
+ parts[key] = value
401
+ end
402
+
403
+ freq = parts['FREQ']
404
+ interval = parts['INTERVAL'] || '1'
405
+ count = parts['COUNT']
406
+ until_date = parts['UNTIL']
407
+
408
+ recurrence = case freq
409
+ when 'DAILY'
410
+ interval == '1' ? 'Daily' : "Every #{interval} days"
411
+ when 'WEEKLY'
412
+ interval == '1' ? 'Weekly' : "Every #{interval} weeks"
413
+ when 'MONTHLY'
414
+ interval == '1' ? 'Monthly' : "Every #{interval} months"
415
+ when 'YEARLY'
416
+ interval == '1' ? 'Yearly' : "Every #{interval} years"
417
+ else
418
+ freq
419
+ end
420
+
421
+ recurrence += " (#{count} times)" if count
422
+ recurrence += " (until #{extract_date(until_date)})" if until_date
423
+
424
+ recurrence
425
+ end
426
+
427
+ def parse_status
428
+ match = @vcal.match(PATTERNS[:status])
429
+ @event[:status] = match[1].strip.capitalize if match
430
+ end
431
+
432
+ def parse_priority
433
+ match = @vcal.match(PATTERNS[:priority])
434
+ if match
435
+ priority = match[1].strip
436
+ @event[:priority] = case priority
437
+ when '1', '2' then 'High'
438
+ when '3', '4', '5' then 'Normal'
439
+ when '6', '7', '8', '9' then 'Low'
440
+ else priority
441
+ end
442
+ end
443
+ end
444
+
445
+ def clean_text(text)
446
+ return nil unless text
447
+ text.gsub(/\n /, '').gsub(/\\n/, "\n").strip
58
448
  end
59
449
  end
60
450
 
61
- # Get description
62
- if vcal.match( /^DESCRIPTION;.*?:(.*)^UID/m )
63
- desc = vcal[ /^DESCRIPTION;.*?:(.*)^UID/m, 1 ].gsub( /\n /, '' ).gsub( /\\n/, "\n" ).gsub( /\n\n+/, "\n" ).gsub( / \| /, "\n" ).sub( /^\n/, '' )
64
- else
65
- begin
66
- desc = vcal[ /^DESCRIPTION:(.*)^SUMMARY/m, 1 ].gsub( /\n /, '' ).gsub( /\\n/, "\n" ).gsub( /\n\n+/, "\n" ).gsub( / \| /, "\n" ).sub( /^\n/, '' )
67
- rescue
68
- desc = ""
451
+ class CalendarViewer
452
+ def initialize(options = {})
453
+ @format = options[:format] || :text
454
+ @verbose = options[:verbose] || false
455
+ end
456
+
457
+ def display(event)
458
+ return puts "Error: Invalid or empty calendar file" unless event
459
+
460
+ case @format
461
+ when :json
462
+ display_json(event)
463
+ when :compact
464
+ display_compact(event)
465
+ else
466
+ display_text(event)
467
+ end
468
+ end
469
+
470
+ private
471
+
472
+ def display_text(event)
473
+ # Build output string first, then print once
474
+ output = []
475
+
476
+ output << "WHAT: #{event[:summary]}" if event[:summary]
477
+
478
+ if event[:dates]
479
+ time_info = event[:dates]
480
+ time_info += " (#{event[:weekday]} of week #{event[:week]})" if event[:weekday] && event[:week]
481
+ time_info += ", #{event[:times]}" if event[:times]
482
+ output << "WHEN: #{time_info}"
483
+ end
484
+
485
+ output << "WHERE: #{event[:location]}" if event[:location] && !event[:location].empty?
486
+ output << "RECURRENCE: #{event[:recurrence]}" if event[:recurrence]
487
+ output << "STATUS: #{event[:status]}" if event[:status]
488
+ output << "PRIORITY: #{event[:priority]}" if event[:priority]
489
+
490
+ output << ""
491
+ output << "ORGANIZER: #{event[:organizer]}" if event[:organizer]
492
+
493
+ if event[:participants] && !event[:participants].empty?
494
+ output << "PARTICIPANTS:"
495
+ output << event[:participants]
496
+ end
497
+
498
+ if event[:description] && !event[:description].empty?
499
+ output << ""
500
+ output << "DESCRIPTION:"
501
+ output << event[:description]
502
+ end
503
+
504
+ if @verbose && event[:uid]
505
+ output << ""
506
+ output << "UID: #{event[:uid]}"
507
+ end
508
+
509
+ puts output.join("\n")
510
+ end
511
+
512
+ def display_compact(event)
513
+ output = []
514
+ output << "#{event[:summary]} | #{event[:dates]} #{event[:times]}"
515
+ output << "Location: #{event[:location]}" if event[:location]
516
+ output << "Organizer: #{event[:organizer]}" if event[:organizer]
517
+ puts output.join("\n")
518
+ end
519
+
520
+ def display_json(event)
521
+ require 'json'
522
+ puts JSON.pretty_generate(event)
69
523
  end
70
524
  end
71
525
 
72
- sdate == edate ? dates = sdate : dates = sdate + " - " + edate
73
- dobj = DateTime.parse( sdate )
74
- wday = dobj.strftime('%A')
75
- week = dobj.strftime('%-V')
76
- stime == etime ? times = stime : times = stime + " - " + etime
77
- times += " (GST)" if vcal.match( /DTSTART;TZID=Greenwich Standard Time/ )
78
- # Get participants
79
- part = vcal.scan( /^ATTENDEE.*CN=([\s\S]*?@.*)\n/ ).join('%').gsub( /\n /, '').gsub( /%/, ">\n " ).gsub( /:mailto:/i, " <" )
80
- part = " " + part + ">" if part != ""
81
- # Get summary and description
82
- sum = vcal[ /^SUMMARY;.*:(.*)/, 1 ]
83
- sum = vcal[ /^SUMMARY:(.*)/, 1 ] if sum == nil
84
-
85
- # Print the result in a tidy fashion
86
- puts "WHAT: " + (sum)
87
- puts "WHEN: " + (dates + " (" + wday + " of week " + week + "), " + times)
88
- puts ""
89
- puts "ORGANIZER: " + org
90
- puts "PARTICIPANTS:", part
91
- puts ""
92
- puts "DESCRIPTION:", desc
526
+ # Main execution
527
+ if __FILE__ == $0
528
+ options = {}
529
+
530
+ OptionParser.new do |opts|
531
+ opts.banner = "Usage: calview.rb [options] [file]"
532
+
533
+ opts.on("-f", "--format FORMAT", [:text, :json, :compact],
534
+ "Output format (text, json, compact)") do |f|
535
+ options[:format] = f
536
+ end
537
+
538
+ opts.on("-v", "--verbose", "Verbose output") do
539
+ options[:verbose] = true
540
+ end
541
+
542
+ opts.on("-h", "--help", "Show this help message") do
543
+ puts opts
544
+ exit
545
+ end
546
+ end.parse!
547
+
548
+ begin
549
+ vcal_content = ARGF.read
550
+ parser = VcalParser.new(vcal_content)
551
+ event = parser.parse
552
+ viewer = CalendarViewer.new(options)
553
+ viewer.display(event)
554
+ rescue => e
555
+ STDERR.puts "Error: #{e.message}"
556
+ exit 1
557
+ end
558
+ end
metadata CHANGED
@@ -1,22 +1,62 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calview
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-09 00:00:00.000000000 Z
12
- dependencies: []
13
- description: 'Having used the [vcal2text](https://github.com/davebiffuk/vcal2text)
14
- for many years to view calendar invites in my mutt e-mail client, it started to
15
- fail on newer vcal attachments. It showed only parts of a calendar invite and spit
16
- out lots of errors beyond that. As it is written in perl and carries a bundle of
17
- dependencies, I decided to create my own in Ruby without dependencies. This solution
18
- is leaner (and meaner), and it works. Check Github page for more info: https://github.com/isene/calview.
19
- New in 1.1.1: Fixed time string for All Day events.'
11
+ date: 2025-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tzinfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description: A robust VCAL/iCalendar viewer that parses calendar invites and displays
56
+ them in a readable format. Perfect for mutt email client integration and terminal
57
+ viewing. Features include timezone support, recurrence parsing, multiple output
58
+ formats (text, JSON, compact), and comprehensive error handling. Originally created
59
+ to replace vcal2text with a leaner Ruby solution.
20
60
  email: g@isene.com
21
61
  executables:
22
62
  - calview.rb
@@ -29,7 +69,9 @@ licenses:
29
69
  - Unlicense
30
70
  metadata:
31
71
  source_code_uri: https://github.com/isene/calview
32
- post_install_message:
72
+ bug_tracker_uri: https://github.com/isene/calview/issues
73
+ changelog_uri: https://github.com/isene/calview/blob/master/CHANGELOG.md
74
+ post_install_message:
33
75
  rdoc_options: []
34
76
  require_paths:
35
77
  - lib
@@ -37,15 +79,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
37
79
  requirements:
38
80
  - - ">="
39
81
  - !ruby/object:Gem::Version
40
- version: '0'
82
+ version: 2.5.0
41
83
  required_rubygems_version: !ruby/object:Gem::Requirement
42
84
  requirements:
43
85
  - - ">="
44
86
  - !ruby/object:Gem::Version
45
87
  version: '0'
46
88
  requirements: []
47
- rubygems_version: 3.1.2
48
- signing_key:
89
+ rubygems_version: 3.4.20
90
+ signing_key:
49
91
  specification_version: 4
50
- summary: VCAL viewer for MUTT (can also be used to view a vcal file in a terminal)
92
+ summary: VCAL/iCalendar viewer for terminal and mutt with multiple output formats
51
93
  test_files: []