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.
- checksums.yaml +4 -4
- data/bin/calview.rb +539 -73
- metadata +58 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d2f77c86874981a696b27cb95c9deade07b24f769d0ce6e793b9bac01ca17aec
|
4
|
+
data.tar.gz: fc2282ea3930cc787b3fca553562fcbb67edd90710c9d85e76acc2e5ea478bde
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
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
|
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 "
|
10
|
+
require "time"
|
11
|
+
require "optparse"
|
12
|
+
require "strscan"
|
12
13
|
|
13
|
-
|
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
|
-
#
|
16
|
-
|
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
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
puts
|
90
|
-
|
91
|
-
|
92
|
-
|
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:
|
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:
|
12
|
-
dependencies:
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
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:
|
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.
|
48
|
-
signing_key:
|
89
|
+
rubygems_version: 3.4.20
|
90
|
+
signing_key:
|
49
91
|
specification_version: 4
|
50
|
-
summary: VCAL viewer for
|
92
|
+
summary: VCAL/iCalendar viewer for terminal and mutt with multiple output formats
|
51
93
|
test_files: []
|