calview 1.1.1 → 2.0.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/bin/calview.rb +544 -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: c593e00e6e69942ca929719892ef94b5b391952fad5aa228443ca8419c774ea4
4
+ data.tar.gz: 0e5dedbbe3470498b69a7f40c0d32abe50056b75e42c0b4f26ee2df4fc5d0a0c
5
5
  SHA512:
6
- metadata.gz: eff91f4d2a2aa624452133ff97f50c529763a17251520f1fd878e434ee68fde0ec6d60c2119d84f215b351c2ed7d05b7cc01652ada30fb607d1427711766dc8b
7
- data.tar.gz: f5029527c2519dea31172ae0af69cd7714381e345a0a2de8f86e968ded7801e5378d955a62bc44871fbf34042c86850fa2a32772c55918d0c680eac271732b19
6
+ metadata.gz: 69e3dc27dcfe6bf26700d6a4ac51fc11e49ea1e367758d0beaa2acbce4cdb4c2b5ee283d1d2ace278f7e1d140ef8f003420d0a8c11281c16b5f8955f28e9a5af
7
+ data.tar.gz: 224b275173003561e4dacb4f7ab325b4838bc9d134e2a07115d6c00bd624f1ea686615ef8793736c614b0175b5f2d6da03f8bd6b943322179aab3554946139b1
data/bin/calview.rb CHANGED
@@ -1,92 +1,563 @@
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{6}))?$/,
19
+ dtend_tzid: /^DTEND;TZID=(.*?):(.*?)(?:T(\d{6}))?$/,
20
+ dtstart_date: /^DTSTART;VALUE=DATE:(.*)$/,
21
+ dtend_date: /^DTEND;VALUE=DATE:(.*)$/,
22
+ dtstart_utc: /^DTSTART:(.*?)(?:T(\d{6}))?$/,
23
+ dtend_utc: /^DTEND:(.*?)(?:T(\d{6}))?$/,
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
+ # Handle both 4-digit (HHMM) and 6-digit (HHMMSS) formats
288
+ "#{time_str[0,2]}:#{time_str[2,2]}"
289
+ end
290
+
291
+ def adjust_time_with_offset(time_str, offset)
292
+ return time_str unless time_str && time_str[2] == ':'
293
+ hour = time_str[0,2].to_i + offset
294
+ "#{hour}:#{time_str[3,2]}"
295
+ end
296
+
297
+ def convert_timezone(tz_name)
298
+ return nil unless tz_name && @tzinfo_loaded
299
+
300
+ # Check for Windows timezone mapping
301
+ tz_name = WINDOWS_TZ[tz_name] || tz_name
302
+
303
+ begin
304
+ TZInfo::Timezone.get(tz_name)
305
+ rescue
306
+ nil
307
+ end
308
+ end
309
+
310
+ def store_date_time_info(sdate, edate, stime, etime)
311
+ @event[:start_date] = sdate
312
+ @event[:end_date] = edate
313
+ @event[:dates] = sdate == edate ? sdate : "#{sdate} - #{edate}"
314
+
315
+ if sdate
316
+ begin
317
+ dobj = Time.parse(sdate)
318
+ @event[:weekday] = dobj.strftime('%A')
319
+ @event[:week] = dobj.strftime('%-V')
320
+ rescue
321
+ # Skip weekday/week on parse error
322
+ end
323
+ end
324
+
325
+ if stime.is_a?(Time) && etime.is_a?(Time)
326
+ @event[:times] = stime == etime ? stime.strftime("%H:%M") : "#{stime.strftime("%H:%M")} - #{etime.strftime("%H:%M")}"
327
+ elsif stime == "All day"
328
+ @event[:times] = "All day"
329
+ else
330
+ @event[:times] = stime == etime || stime == stime.to_s ? stime : "#{stime} - #{etime}"
331
+ end
332
+ end
333
+
334
+ def parse_organizer
335
+ if match = @vcal.match(PATTERNS[:organizer_cn])
336
+ org = match[1].sub(/:mailto:/i, ' <') + ">"
337
+ elsif match = @vcal.match(PATTERNS[:organizer])
338
+ org = match[1].sub(/MAILTO:/i, '<') + ">"
339
+ else
340
+ org = "(None set)"
341
+ end
342
+ @event[:organizer] = org
343
+ end
344
+
345
+ def parse_participants
346
+ participants = @vcal.scan(PATTERNS[:attendee])
347
+ if participants.any?
348
+ # Use join with newline directly instead of multiple gsubs
349
+ part = participants.flatten
350
+ .map { |p| p.gsub(/\n /, '').sub(/:mailto:/i, " <") + ">" }
351
+ .join("\n ")
352
+ @event[:participants] = " #{part}"
353
+ else
354
+ @event[:participants] = ""
355
+ end
356
+ end
357
+
358
+ def parse_summary
359
+ match = @vcal.match(PATTERNS[:summary_param]) || @vcal.match(PATTERNS[:summary])
360
+ @event[:summary] = clean_text(match[1]) if match
361
+ end
362
+
363
+ def parse_description
364
+ match = @vcal.match(PATTERNS[:description_uid]) ||
365
+ @vcal.match(PATTERNS[:description_summary]) ||
366
+ @vcal.match(PATTERNS[:description_generic])
367
+
368
+ if match
369
+ desc = match[1]
370
+ .gsub(/\n /, '')
371
+ .gsub(/\\n/, "\n")
372
+ .gsub(/\n\n+/, "\n")
373
+ .gsub(/ \| /, "\n")
374
+ .strip
375
+ @event[:description] = desc
376
+ else
377
+ @event[:description] = ""
378
+ end
379
+ end
380
+
381
+ def parse_location
382
+ match = @vcal.match(PATTERNS[:location])
383
+ @event[:location] = clean_text(match[1]) if match
384
+ end
385
+
386
+ def parse_uid
387
+ match = @vcal.match(PATTERNS[:uid])
388
+ @event[:uid] = match[1].strip if match
389
+ end
390
+
391
+ def parse_recurrence
392
+ # Only look for RRULE within the VEVENT section
393
+ vevent_section = @vcal[/BEGIN:VEVENT.*?END:VEVENT/m]
394
+ return unless vevent_section
395
+
396
+ match = vevent_section.match(PATTERNS[:rrule])
397
+ @event[:recurrence] = parse_rrule(match[1]) if match
398
+ end
399
+
400
+ def parse_rrule(rrule)
401
+ # Use StringScanner for faster parsing
402
+ parts = {}
403
+ rrule.split(';').each do |part|
404
+ key, value = part.split('=', 2)
405
+ parts[key] = value
406
+ end
407
+
408
+ freq = parts['FREQ']
409
+ interval = parts['INTERVAL'] || '1'
410
+ count = parts['COUNT']
411
+ until_date = parts['UNTIL']
412
+
413
+ recurrence = case freq
414
+ when 'DAILY'
415
+ interval == '1' ? 'Daily' : "Every #{interval} days"
416
+ when 'WEEKLY'
417
+ interval == '1' ? 'Weekly' : "Every #{interval} weeks"
418
+ when 'MONTHLY'
419
+ interval == '1' ? 'Monthly' : "Every #{interval} months"
420
+ when 'YEARLY'
421
+ interval == '1' ? 'Yearly' : "Every #{interval} years"
422
+ else
423
+ freq
424
+ end
425
+
426
+ recurrence += " (#{count} times)" if count
427
+ recurrence += " (until #{extract_date(until_date)})" if until_date
428
+
429
+ recurrence
430
+ end
431
+
432
+ def parse_status
433
+ match = @vcal.match(PATTERNS[:status])
434
+ @event[:status] = match[1].strip.capitalize if match
435
+ end
436
+
437
+ def parse_priority
438
+ match = @vcal.match(PATTERNS[:priority])
439
+ if match
440
+ priority = match[1].strip
441
+ @event[:priority] = case priority
442
+ when '1', '2' then 'High'
443
+ when '3', '4', '5' then 'Normal'
444
+ when '6', '7', '8', '9' then 'Low'
445
+ else priority
446
+ end
447
+ end
448
+ end
449
+
450
+ def clean_text(text)
451
+ return nil unless text
452
+ text.gsub(/\n /, '').gsub(/\\n/, "\n").strip
58
453
  end
59
454
  end
60
455
 
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 = ""
456
+ class CalendarViewer
457
+ def initialize(options = {})
458
+ @format = options[:format] || :text
459
+ @verbose = options[:verbose] || false
460
+ end
461
+
462
+ def display(event)
463
+ return puts "Error: Invalid or empty calendar file" unless event
464
+
465
+ case @format
466
+ when :json
467
+ display_json(event)
468
+ when :compact
469
+ display_compact(event)
470
+ else
471
+ display_text(event)
472
+ end
473
+ end
474
+
475
+ private
476
+
477
+ def display_text(event)
478
+ # Build output string first, then print once
479
+ output = []
480
+
481
+ output << "WHAT: #{event[:summary]}" if event[:summary]
482
+
483
+ if event[:dates]
484
+ time_info = event[:dates]
485
+ time_info += " (#{event[:weekday]} of week #{event[:week]})" if event[:weekday] && event[:week]
486
+ time_info += ", #{event[:times]}" if event[:times]
487
+ output << "WHEN: #{time_info}"
488
+ end
489
+
490
+ output << "WHERE: #{event[:location]}" if event[:location] && !event[:location].empty?
491
+ output << "RECURRENCE: #{event[:recurrence]}" if event[:recurrence]
492
+ output << "STATUS: #{event[:status]}" if event[:status]
493
+ output << "PRIORITY: #{event[:priority]}" if event[:priority]
494
+
495
+ output << ""
496
+ output << "ORGANIZER: #{event[:organizer]}" if event[:organizer]
497
+
498
+ if event[:participants] && !event[:participants].empty?
499
+ output << "PARTICIPANTS:"
500
+ output << event[:participants]
501
+ end
502
+
503
+ if event[:description] && !event[:description].empty?
504
+ output << ""
505
+ output << "DESCRIPTION:"
506
+ output << event[:description]
507
+ end
508
+
509
+ if @verbose && event[:uid]
510
+ output << ""
511
+ output << "UID: #{event[:uid]}"
512
+ end
513
+
514
+ puts output.join("\n")
515
+ end
516
+
517
+ def display_compact(event)
518
+ output = []
519
+ output << "#{event[:summary]} | #{event[:dates]} #{event[:times]}"
520
+ output << "Location: #{event[:location]}" if event[:location]
521
+ output << "Organizer: #{event[:organizer]}" if event[:organizer]
522
+ puts output.join("\n")
523
+ end
524
+
525
+ def display_json(event)
526
+ require 'json'
527
+ puts JSON.pretty_generate(event)
69
528
  end
70
529
  end
71
530
 
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
531
+ # Main execution
532
+ if __FILE__ == $0
533
+ options = {}
534
+
535
+ OptionParser.new do |opts|
536
+ opts.banner = "Usage: calview.rb [options] [file]"
537
+
538
+ opts.on("-f", "--format FORMAT", [:text, :json, :compact],
539
+ "Output format (text, json, compact)") do |f|
540
+ options[:format] = f
541
+ end
542
+
543
+ opts.on("-v", "--verbose", "Verbose output") do
544
+ options[:verbose] = true
545
+ end
546
+
547
+ opts.on("-h", "--help", "Show this help message") do
548
+ puts opts
549
+ exit
550
+ end
551
+ end.parse!
552
+
553
+ begin
554
+ vcal_content = ARGF.read
555
+ parser = VcalParser.new(vcal_content)
556
+ event = parser.parse
557
+ viewer = CalendarViewer.new(options)
558
+ viewer.display(event)
559
+ rescue => e
560
+ STDERR.puts "Error: #{e.message}"
561
+ exit 1
562
+ end
563
+ 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.1
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-14 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: []