icalPal 1.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.
data/lib/defaults.rb ADDED
@@ -0,0 +1,64 @@
1
+ # Does anybody really know what time it is?
2
+ $now = ICalPal::RDT.now
3
+ $today = ICalPal::RDT.new(*$now.to_a[0..2])
4
+
5
+ # Defaults
6
+ $defaults = {
7
+ common: {
8
+ aep: [],
9
+ bullet: '•',
10
+ cf: "#{ENV['HOME']}/.icalPal",
11
+ color: false,
12
+ db: "#{ENV['HOME']}/Library/Calendars/Calendar.sqlitedb",
13
+ debug: Logger::WARN,
14
+ ec: [],
15
+ eep: [],
16
+ es: [],
17
+ et: [],
18
+ ic: [],
19
+ is: [],
20
+ it: [],
21
+ li: 0,
22
+ output: 'default',
23
+ ps: [ "\n " ],
24
+ r: false,
25
+ sc: false,
26
+ sd: false,
27
+ sep: false,
28
+ sort: nil,
29
+ sp: false,
30
+ },
31
+ tasks: {
32
+ bullet: '!',
33
+ iep: [ 'notes', 'due', 'priority' ],
34
+ sort: 'priority',
35
+ },
36
+ stores: {
37
+ iep: [ 'account', 'type' ],
38
+ sort: 'account',
39
+ },
40
+ calendars: {
41
+ iep: [ 'calendar', 'type', 'UUID' ],
42
+ sort: 'calendar',
43
+ },
44
+ events: {
45
+ days: nil,
46
+ df: '%b %-d, %Y',
47
+ ea: false,
48
+ eed: false,
49
+ eep: [],
50
+ from: $today,
51
+ iep: [ 'title', 'location', 'notes', 'url', 'attendees', 'datetime' ],
52
+ n: false,
53
+ nnr: "\n ",
54
+ nrd: false,
55
+ ps: [ "\n " ],
56
+ sa: false,
57
+ sed: false,
58
+ sort: 'sdate',
59
+ ss: "\n------------------------",
60
+ tf: '%-I:%M %p',
61
+ to: nil,
62
+ uid: false,
63
+ }
64
+ }
data/lib/event.rb ADDED
@@ -0,0 +1,283 @@
1
+ module ICalPal
2
+ # Class representing items from the <tt>CalendarItem</tt> table
3
+ class Event
4
+ include ICalPal
5
+
6
+ # Standard accessor with special handling for +age+,
7
+ # +availability+, +datetime+, +location+, +notes+, +status+,
8
+ # +title+, and +uid+
9
+ #
10
+ # @param k [String] Key/property name
11
+ def [](k)
12
+ case k
13
+ when 'age' then # pseudo-property
14
+ @self['sdate'].year - @self['edate'].year
15
+
16
+ when 'availability' then # Integer -> String
17
+ EventKit::EKEventAvailability.select { |k, v| v == @self['availability'] }.keys
18
+
19
+ when 'datetime' then # date[ at time[ - time]]
20
+ unless $opts[:sd] || $opts[:days] == 1
21
+ t = @self['sdate'].to_s
22
+ t += ' at ' unless @self['all_day'].positive?
23
+ end
24
+
25
+ unless @self['all_day'] && @self['all_day'].positive?
26
+ t ||= ''
27
+ t += "#{@self['sdate'].strftime($opts[:tf])}" if @self['sdate']
28
+ t += " - #{@self['edate'].strftime($opts[:tf])}" unless $opts[:eed] || !@self['edate']
29
+ end
30
+ t
31
+
32
+ when 'location' then # location[ address]
33
+ @self['location']? [ @self['location'], @self['address'] ].join(' ').chop : nil
34
+
35
+ when 'notes' then # \n -> :nnr
36
+ @self['notes']? @self['notes'].strip.gsub(/\n/, $opts[:nnr]) : nil
37
+
38
+ when 'status' then # Integer -> String
39
+ EventKit::EKEventStatus.select { |k, v| v == @self['status'] }.keys[0]
40
+
41
+ when 'title' then # title[ (age N)]
42
+ @self['title'] + ((@self['calendar'] == 'Birthdays')? " (age #{self['age']})" : "")
43
+
44
+ when 'uid' then # for icalBuddy
45
+ @self['UUID']
46
+
47
+ else @self[k]
48
+ end
49
+ end
50
+
51
+ # @overload initialize(obj)
52
+ # @param obj [SQLite3::ResultSet::HashWithTypesAndFields]
53
+ #
54
+ # @overload initialize(obj<DateTime>)
55
+ # Create a placeholder event for days with no events when using --sed
56
+ # @param obj [DateTime]
57
+ def initialize(obj)
58
+ # Placeholder for days with no events
59
+ return @self = {
60
+ $opts[:sep] => obj,
61
+ 'placeholder' => true,
62
+ 'title' => 'Nothing.',
63
+ } if DateTime === obj
64
+
65
+ @self = {}
66
+ obj.keys.each { |k| @self[k] = obj[k] }
67
+
68
+ # Convert JSON arrays to Arrays
69
+ @self['attendees'] = JSON.parse(obj['attendees'])
70
+ @self['xdate'] = JSON.parse(obj['xdate']).map do |k|
71
+ k = RDT.new(*Time.at(k + ITIME).to_a.reverse[4..]) if k
72
+ end
73
+
74
+ # Convert iCal dates to normal dates
75
+ obj.keys.select { |i| i.end_with? '_date' }.each do |k|
76
+ t = Time.at(obj[k] + ITIME) if obj[k]
77
+ @self["#{k[0]}date"] = RDT.new(*t.to_a.reverse[4..], t.zone) if t
78
+ end
79
+
80
+ obj['type'] = EventKit::EKSourceType.find_index { |i| i[:name] == 'Subscribed' } if obj['subcal_url']
81
+ type = EventKit::EKSourceType[obj['type']]
82
+
83
+ @self['sdate'] = @self['sdate'].new_offset(0) if @self['start_tz'] == '_float'
84
+ @self['symbolic_color_name'] ||= @self['color']
85
+ @self['type'] = type[:name]
86
+ end
87
+
88
+ # Check non-recurring events
89
+ #
90
+ # @return [Array<Event>]
91
+ # If an event spans multiple days, the return value will contain
92
+ # a unique {Event} for each day that falls within our window
93
+ def non_recurring
94
+ retval = []
95
+
96
+ # Repeat for multi-day events
97
+ ((self['duration'] / 86400).to_i + 1).times do |i|
98
+ retval.push(Marshal.load(Marshal.dump(self))) if in_window?(self['sdate'])
99
+ self['sdate'] += 1
100
+ end
101
+
102
+ retval
103
+ end
104
+
105
+ # Check recurring events
106
+ #
107
+ # @return [Array<Event>]
108
+ # All occurrences of a recurring event that are within our window
109
+ def recurring
110
+ retval = []
111
+
112
+ # See if event ends before we start
113
+ stop = [ $opts[:to], (self['rdate'] || $opts[:to]) ].min
114
+ return(retval) if stop < $opts[:from]
115
+
116
+ # Get changes to series
117
+ changes = $rows.select { |r| r['orig_item_id'] == self['ROWID'] }
118
+
119
+ i = 1
120
+ while self['sdate'] <= stop
121
+ unless @self['xdate'].any?(@self['sdate']) # Exceptions?
122
+ o = get_occurrences(changes)
123
+ o.each { |r| retval.push(r) if in_window?(r['sdate'], r['edate']) }
124
+
125
+ i += 1
126
+ return(retval) if self['count'].positive? && i > self['count']
127
+ end
128
+
129
+ apply_frequency!
130
+ end
131
+
132
+ retval
133
+ end
134
+
135
+ private
136
+
137
+ # @!visibility public
138
+
139
+ # Get next occurences of a recurring event
140
+ #
141
+ # @param changes [Array] Recurrence changes for the event
142
+ # @return [Array<IcalPal::Event>]
143
+ def get_occurrences(changes)
144
+ ndate = self['sdate']
145
+ odays = []
146
+ retval = []
147
+
148
+ # Deconstruct specifier(s)
149
+ if self['specifier']
150
+ self['specifier'].split(';').each do |k|
151
+ j = k.split('=')
152
+
153
+ # M=Day of the month, O=Month of the year, S=Nth
154
+ case j[0]
155
+ when 'M' then ndate = RDT.new(ndate.year, ndate.month, j[1].to_i)
156
+ when 'O' then ndate = RDT.new(ndate.year, j[1].to_i, ndate.day)
157
+ when 'S' then @self['specifier'].sub!(/D=0/, "D=+#{j[1].to_i}")
158
+ end
159
+ end
160
+
161
+ # D=Day of the week
162
+ self['specifier'].split(';').each do |k|
163
+ j = k.split('=')
164
+
165
+ odays = j[1].split(',') if j[0] == 'D'
166
+ end
167
+ end
168
+
169
+ # Deconstruct occurence day(s)
170
+ odays.each do |n|
171
+ dow = DOW[n[-2..-1].to_sym]
172
+ ndate += 1 until ndate.wday == dow
173
+ ndate = ICalPal.nth(Integer(n[0..1]), n[-2..-1], ndate) unless (n[0] == '0')
174
+
175
+ # Check for changes
176
+ changes.detect(
177
+ proc {
178
+ self['sdate'] = RDT.new(*ndate.to_a[0..2], *self['sdate'].to_a[3..])
179
+ self['edate'] = RDT.new(*ndate.to_a[0..2], *self['edate'].to_a[3..])
180
+ retval.push(Marshal.load(Marshal.dump(self)))
181
+ }) { |i| @self['sdate'].to_i == i['orig_date'] + ITIME }
182
+ end
183
+
184
+ # Check for changes
185
+ changes.detect(
186
+ proc {
187
+ retval.push(Marshal.load(Marshal.dump(self)))
188
+ }) { |i| @self['sdate'].to_i == i['orig_date'] + ITIME } unless retval.count.positive?
189
+
190
+ retval
191
+ end
192
+
193
+ # Apply frequency and interval
194
+ def apply_frequency!
195
+ # Leave edate alone for birthdays to compute age
196
+ dates = [ 'sdate' ]
197
+ dates << 'edate' unless self['calendar'].include?('Birthday')
198
+
199
+ dates.each do |d|
200
+ case EventKit::EKRecurrenceFrequency[self['frequency'] - 1]
201
+ when 'daily' then self[d] += self['interval']
202
+ when 'weekly' then self[d] += self['interval'] * 7
203
+ when 'monthly' then self[d] >>= self['interval']
204
+ when 'yearly' then self[d] >>= self['interval'] * 12
205
+ end
206
+ end if self['frequency'] && self['interval']
207
+ end
208
+
209
+ # Check if an event starts or ends between from and to, or if it's
210
+ # running now (for -n)
211
+ #
212
+ # @param s [RDT] Event start
213
+ # @param e [RDT] Event end
214
+ # @return [Boolean]
215
+ def in_window?(s, e = s)
216
+ $opts[:n]?
217
+ ($now >= s && $now < e) :
218
+ ([ s, e ].max >= $opts[:from] && s < $opts[:to])
219
+ end
220
+
221
+ QUERY = <<~SQL
222
+ SELECT DISTINCT
223
+
224
+ Store.name AS account,
225
+ Store.type,
226
+
227
+ Calendar.color,
228
+ Calendar.title AS calendar,
229
+ Calendar.subcal_url,
230
+ Calendar.symbolic_color_name,
231
+
232
+ CAST(CalendarItem.end_date AS INT) AS end_date,
233
+ CAST(CalendarItem.orig_date AS INT) AS orig_date,
234
+ CAST(CalendarItem.start_date AS INT) AS start_date,
235
+ CAST(CalendarItem.end_date - CalendarItem.start_date AS INT) AS duration,
236
+ CalendarItem.all_day,
237
+ CalendarItem.availability,
238
+ CalendarItem.conference_url_detected,
239
+ CalendarItem.description AS notes,
240
+ CalendarItem.has_recurrences,
241
+ CalendarItem.orig_item_id,
242
+ CalendarItem.rowid,
243
+ CalendarItem.start_tz,
244
+ CalendarItem.status,
245
+ CalendarItem.summary AS title,
246
+ CalendarItem.unique_identifier,
247
+ CalendarItem.url,
248
+ CalendarItem.uuid,
249
+
250
+ json_group_array(DISTINCT CAST(ExceptionDate.date AS INT)) AS xdate,
251
+
252
+ json_group_array(DISTINCT Identity.display_name) AS attendees,
253
+
254
+ Location.address AS address,
255
+ Location.title AS location,
256
+
257
+ Recurrence.count,
258
+ CAST(Recurrence.end_date AS INT) AS rend_date,
259
+ Recurrence.frequency,
260
+ Recurrence.interval,
261
+ Recurrence.specifier
262
+
263
+ FROM Store
264
+
265
+ JOIN Calendar ON Calendar.store_id = Store.rowid
266
+ JOIN CalendarItem ON CalendarItem.calendar_id = Calendar.rowid
267
+
268
+ LEFT OUTER JOIN Location ON Location.rowid = CalendarItem.location_id
269
+ LEFT OUTER JOIN Recurrence ON Recurrence.owner_id = CalendarItem.rowid
270
+ LEFT OUTER JOIN ExceptionDate ON ExceptionDate.owner_id = CalendarItem.rowid
271
+
272
+ LEFT OUTER JOIN Participant ON Participant.owner_id = CalendarItem.rowid
273
+ LEFT OUTER JOIN Identity ON Identity.rowid = Participant.identity_id
274
+
275
+ WHERE Store.disabled IS NOT 1
276
+
277
+ GROUP BY CalendarItem.rowid
278
+
279
+ ORDER BY CalendarItem.unique_identifier
280
+ SQL
281
+
282
+ end
283
+ end
data/lib/icalPal.rb ADDED
@@ -0,0 +1,82 @@
1
+ require_relative 'EventKit'
2
+ require_relative 'ToICalPal'
3
+ require_relative 'calendar'
4
+ require_relative 'event'
5
+ require_relative 'rdt'
6
+ require_relative 'store'
7
+
8
+ # Encapsulate the _Store_ (accounts), _Calendar_ and _CalendarItem_
9
+ # tables of a Calendar database
10
+
11
+ module ICalPal
12
+ attr_reader :self
13
+
14
+ # Dynamic instantiation of our classes based on the command being
15
+ # run
16
+ #
17
+ # @param klass [String] One of +accounts+, +stores+, +calendars+, or +events+
18
+ # @return [Class] The subclass of ICalPal
19
+ def self.call(klass)
20
+ case klass
21
+ when 'accounts' then Store
22
+ when 'stores' then Store
23
+ when 'calendars' then Calendar
24
+ when 'events' then Event
25
+ when 'tasks' then Task
26
+ else
27
+ $log.fatal("Unknown class: #{klass}")
28
+ exit
29
+ end
30
+ end
31
+
32
+ # @param obj [ICalPal] A +Store+ or +Calendar+
33
+ def initialize(obj)
34
+ obj['type'] = EventKit::EKSourceType.find_index { |i| i[:name] == 'Subscribed' } if obj['subcal_url']
35
+ type = EventKit::EKSourceType[obj['type']]
36
+
37
+ obj['store'] = obj['account']
38
+
39
+ obj['type'] = type[:name]
40
+ obj['color'] ||= type[:color]
41
+ obj['symbolic_color_name'] ||= type[:color]
42
+
43
+ @self = obj
44
+ end
45
+
46
+ # Get the +n+'th +dow+ in month +m+
47
+ #
48
+ # @param n [Integer] Integer between -4 and +4
49
+ # @param dow [String] Day of the week abbreviation from ICalPal::DOW
50
+ # @param m [RDT] The RDT with the year and month we're searching
51
+ # @return [RDT] The resulting day
52
+ def self.nth(n, dow, m)
53
+ # Get the number of days in the month
54
+ a = [ ICalPal::RDT.new(m.year, m.month, 1) ] # First of this month
55
+ a[1] = (a[0] >> 1) - 1 # First of next month, minus 1 day
56
+
57
+ # Reverse it if going backwards
58
+ a.reverse! if n.negative?
59
+ step = a[1] <=> a[0]
60
+
61
+ j = 0
62
+ a[0].step(a[1], step) do |i|
63
+ j += step if i.wday == DOW[dow.to_sym]
64
+ return i if j == n
65
+ end
66
+ end
67
+
68
+ # Epoch + 31 years
69
+ ITIME = 978307200
70
+
71
+ # Days of the week abbreviations used in recurrence rules
72
+ #
73
+ # <tt><i>SU, MO, TU, WE, TH, FR, SA</i></tt>
74
+ DOW = { 'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6 }
75
+
76
+ # @!group Accessors
77
+ def [](k) @self[k] end
78
+ def []=(k, v) @self[k] = v end
79
+ def keys() @self.keys end
80
+ def values() @self.values end
81
+ # @!endgroup
82
+ end
data/lib/options.rb ADDED
@@ -0,0 +1,268 @@
1
+ require 'optparse'
2
+
3
+ require_relative 'defaults'
4
+
5
+ module ICalPal
6
+ # Handle program options from all sources:
7
+ #
8
+ # * Defaults
9
+ # * Environment variables
10
+ # * Configuration file
11
+ # * Command-line arguments
12
+ #
13
+ # Many options are intentionally copied from
14
+ # icalBuddy[https://github.com/ali-rantakari/icalBuddy]. Note that
15
+ # icalPal requires two hyphens for all options, except single-letter
16
+ # options which require a single hyphen.
17
+ #
18
+ # Options can be abbreviated as long as they are unique.
19
+ class Options
20
+ # Define the OptionParser
21
+ def initialize
22
+ # prologue
23
+ @op = OptionParser.new
24
+ @op.summary_width = 23
25
+ @op.banner += " [-c] COMMAND"
26
+ @op.version = '1.0'
27
+
28
+ @op.accept(ICalPal::RDT) { |s| ICalPal::RDT.conv(s) }
29
+
30
+ # head
31
+ @op.on("\nCOMMAND must be one of the following:\n\n")
32
+
33
+ @op.on("%s%s %sPrint events" % pad('events'))
34
+ @op.on("%s%s %sPrint calendars" % pad('calendars'))
35
+ @op.on("%s%s %sPrint accounts" % pad('accounts'))
36
+
37
+ @op.separator('')
38
+ @op.on("%s%s %sPrint events occurring today" % pad('eventsToday'))
39
+ @op.on("%s%s %sPrint events occurring between today and NUM days into the future" % pad('eventsToday+NUM'))
40
+ @op.on("%s%s %sPrint events occurring at present time" % pad('eventsNow'))
41
+
42
+ # global
43
+ @op.separator("\nGlobal options:\n\n")
44
+
45
+ @op.on('-c=COMMAND', '--cmd=COMMAND', COMMANDS, 'Command to run')
46
+ @op.on('--db=DB', 'Use DB file instead of Calendar')
47
+ @op.on('--cf=FILE', "Set config file path (default: #{$defaults[:common][:cf]})")
48
+ @op.on('-o', '--output=FORMAT', OUTFORMATS,
49
+ "Print as FORMAT (default: #{$defaults[:common][:output]})", "[#{OUTFORMATS.join(', ')}]")
50
+
51
+ # include/exclude
52
+ @op.separator("\nIncluding/excluding calendars:\n\n")
53
+
54
+ @op.on('--is=ACCOUNTS', Array, 'List of accounts to include')
55
+ @op.on('--es=ACCOUNTS', Array, 'List of accounts to exclude')
56
+
57
+ @op.separator('')
58
+ @op.on('--it=TYPES', Array, 'List of calendar types to include')
59
+ @op.on('--et=TYPES', Array, 'List of calendar types to exclude',
60
+ "[#{EventKit::EKSourceType.map { |i| i[:name] }.join(', ') }]")
61
+
62
+ @op.separator('')
63
+ @op.on('--ic=CALENDARS', Array, 'List of calendars to include')
64
+ @op.on('--ec=CALENDARS', Array, 'List of calendars to exclude')
65
+
66
+ # dates
67
+ @op.separator("\nChoosing dates:\n\n")
68
+
69
+ @op.on('--from=DATE', ICalPal::RDT, 'List events starting on or after DATE')
70
+ @op.on('--to=DATE', ICalPal::RDT, 'List events starting on or before DATE',
71
+ 'DATE can be yesterday, today, tomorrow, +N, -N, or anything accepted by DateTime.parse()',
72
+ 'See https://ruby-doc.org/stdlib-2.6.1/libdoc/date/rdoc/DateTime.html#method-c-parse')
73
+ @op.separator('')
74
+ @op.on('-n', 'Include only events from now on')
75
+ @op.on('--days=N', OptionParser::DecimalInteger,
76
+ 'Show N days of events, including start date')
77
+ @op.on('--sed', 'Show empty dates with --sd')
78
+ @op.on('--ia', 'Include only all-day events')
79
+ @op.on('--ea', 'Exclude all-day events')
80
+
81
+ # properties
82
+ @op.separator("\nChoose properties to include in the output:\n\n")
83
+
84
+ @op.on('--iep=PROPERTIES', Array, 'List of properties to include')
85
+ @op.on('--eep=PROPERTIES', Array, 'List of properties to exclude')
86
+ @op.on('--aep=PROPERTIES', Array, 'List of properties to include in addition to the default list')
87
+ @op.separator('')
88
+ # @op.on('--itp=PROPERTIES', Array, 'List of task properties to include')
89
+ # @op.on('--etp=PROPERTIES', Array, 'List of task properties to exclude')
90
+ # @op.on('--atp=PROPERTIES', Array, 'List of task properties to include in addition to the default list')
91
+ # @op.separator('')
92
+
93
+ @op.on('--uid', 'Show event UIDs')
94
+ @op.on('--eed', 'Exclude end datetimes')
95
+
96
+ @op.separator('')
97
+ @op.on('--nc', 'No calendar names')
98
+ @op.on('--npn', 'No property names')
99
+ @op.on('--nrd', 'No relative dates')
100
+
101
+ @op.separator('')
102
+ @op.separator(@op.summary_indent + 'Properties are listed in the order specified')
103
+ @op.separator('')
104
+ @op.separator(@op.summary_indent +
105
+ "Use 'all' for PROPERTIES to include all available properties (except any listed in --eep)")
106
+ @op.separator(@op.summary_indent +
107
+ "Use 'list' for PROPERTIES to list all available properties and exit")
108
+
109
+ # formatting
110
+ @op.separator("\nFormatting the output:\n\n")
111
+
112
+ @op.on('--li=N', OptionParser::DecimalInteger, 'Show at most N items (default: 0 for no limit)')
113
+
114
+ @op.separator('')
115
+ @op.on('--sc', 'Separate by calendar')
116
+ @op.on('--sd', 'Separate by date')
117
+ # @op.on('--sp', 'Separate by priority')
118
+ # @op.on('--sta', 'Sort tasks by due date (ascending)')
119
+ # @op.on('--std', 'Sort tasks by due date (descending)')
120
+ # @op.separator('')
121
+ @op.on('--sep=PROPERTY', 'Separate by PROPERTY')
122
+ @op.separator('')
123
+ @op.on('--sort=PROPERTY', 'Sort by PROPERTY')
124
+ @op.on('-r', '--reverse', 'Sort in reverse')
125
+
126
+ @op.separator('')
127
+ @op.on('--ps=SEPARATORS', Array, 'List of property separators')
128
+ @op.on('--ss=SEPARATOR', String, 'Set section separator')
129
+
130
+ @op.separator('')
131
+ @op.on('--df=FORMAT', String, 'Set date format')
132
+ @op.on('--tf=FORMAT', String, 'Set time format',
133
+ 'See https://ruby-doc.org/stdlib-2.6.1/libdoc/date/rdoc/DateTime.html#method-i-strftime for details')
134
+
135
+ @op.separator('')
136
+ @op.on('-b', '--ab=STRING', String, 'Use STRING for bullets')
137
+ @op.on('--nnr=SEPARATOR', String, 'Set replacement for newlines within notes')
138
+
139
+ @op.separator('')
140
+ @op.on('-f', 'Format output using standard ANSI colors')
141
+ @op.on('--color', 'Format output using a larger color palette')
142
+
143
+ # help
144
+ @op.separator("\nHelp:\n\n")
145
+
146
+ @op.on('-h', '--help', 'Show this message') { @op.abort(@op.help) }
147
+ @op.on('-V', '-v', '--version', "Show version and exit (#{@op.version})") { @op.abort(@op.version) }
148
+ @op.on('-d', '--debug=LEVEL', /#{Regexp.union(Logger::SEV_LABEL[0..-2]).source}/i,
149
+ "Set the logging level (default: #{Logger::SEV_LABEL[$defaults[:common][:debug]].downcase})",
150
+ "[#{Logger::SEV_LABEL[0..-2].join(', ').downcase}]")
151
+
152
+ # environment variables
153
+ @op.on_tail("\nEnvironment variables:\n\n")
154
+
155
+ @op.on_tail("%s%s %sAdditional arguments" % pad('ICALPAL'))
156
+ @op.on_tail("%s%s %sAdditional arguments from a file" % pad('ICALPAL_CONFIG'))
157
+ @op.on_tail("%s%s %s(default: #{$defaults[:common][:cf]})" % pad(''))
158
+ end
159
+
160
+ # Parse options from the CLI and merge them with other sources
161
+ #
162
+ # @return [Hash] All options loaded from defaults, environment
163
+ # variables, configuration file, and the command line
164
+ def parse_options
165
+ begin
166
+ cli = {}
167
+ env = {}
168
+ cf = {}
169
+
170
+ # Load from CLI, environment, configuration file
171
+ @op.parse!(into: cli)
172
+ @op.parse!(ENV['ICALPAL'].split, into: env) rescue nil
173
+ cli[:cf] ||= ENV['ICALPAL_CONFIG'] || $defaults[:common][:cf]
174
+ @op.parse!(IO.read(File.expand_path(cli[:cf])).split, into: cf) rescue nil
175
+
176
+ cli[:cmd] ||= @op.default_argv[0]
177
+ cli[:cmd] = 'stores' if cli[:cmd] == 'accounts'
178
+
179
+ # Parse eventsNow and eventsToday commands
180
+ cli[:cmd].match('events(Now|Today)(\+[0-9]+)?') do |m|
181
+ cli[:n] = true if m[1] == 'Now'
182
+ cli[:days] = (m[1] == 'Today')? m[2].to_i + 1 : 1
183
+
184
+ cli[:from] = $today
185
+ cli[:to] = $today + cli[:days]
186
+ cli[:days] = Integer(cli[:to] - cli[:from])
187
+
188
+ cli[:cmd] = 'events'
189
+ end if cli[:cmd]
190
+
191
+ # Must have a valid command
192
+ raise(OptionParser::MissingArgument, 'COMMAND is required') unless cli[:cmd]
193
+ raise(OptionParser::InvalidArgument, "Unknown COMMAND #{cli[:cmd]}") unless (COMMANDS.any? cli[:cmd])
194
+
195
+ # Merge options
196
+ opts = $defaults[:common]
197
+ .merge($defaults[cli[:cmd].to_sym])
198
+ .merge(cf)
199
+ .merge(env)
200
+ .merge(cli)
201
+
202
+ # All kids love log!
203
+ $log.level = opts[:debug]
204
+
205
+ # From the Department of Redundancy Department
206
+ opts[:props] = (opts[:iep] + opts[:aep] - opts[:eep]).uniq
207
+
208
+ # From, to, days
209
+ if opts[:from]
210
+ opts[:to] += 1 if opts[:to]
211
+ opts[:to] ||= opts[:from] + 1 if opts[:from]
212
+ opts[:to] = opts[:from] + opts[:days] if opts[:days]
213
+ opts[:days] ||= Integer(opts[:to] - opts[:from])
214
+ opts[:from] = $now if opts[:n]
215
+ end
216
+
217
+ # Colors
218
+ opts[:palette] = 8 if opts[:f]
219
+ opts[:palette] = 24 if opts[:color]
220
+
221
+ # Sections
222
+ unless opts[:sep]
223
+ opts[:sep] = 'calendar' if opts[:sc]
224
+ opts[:sep] = 'sday' if opts[:sd]
225
+ opts[:sep] = 'priority' if opts[:sp]
226
+ end
227
+ opts[:nc] = true if opts[:sc]
228
+
229
+ # Sanity checks
230
+ raise(OptionParser::InvalidArgument, '--li cannot be negative') if opts[:li].negative?
231
+ raise(OptionParser::InvalidOption, 'Start date must be before end date') if opts[:from] && opts[:from] > opts[:to]
232
+ raise(OptionParser::MissingArgument, 'No properties to display') if opts[:props].empty?
233
+
234
+ # Open the database here so we can catch errors and print the help message
235
+ $log.debug("Opening database: #{opts[:db]}")
236
+ $db = SQLite3::Database.new(opts[:db], { readonly: true, results_as_hash: true })
237
+ $db.prepare('SELECT 1 FROM Calendar LIMIT 1').close
238
+
239
+ rescue SQLite3::SQLException => e
240
+ @op.abort("#{opts[:db]} is not a Calendar database")
241
+
242
+ rescue SQLite3::Exception => e
243
+ @op.abort("#{opts[:db]}: #{e}")
244
+
245
+ rescue StandardError => e
246
+ @op.abort("#{e}\n\n#{@op.help}\n#{e}")
247
+ end
248
+
249
+ opts.sort.to_h
250
+ end
251
+
252
+ # Commands that can be run
253
+ COMMANDS = %w{events eventsToday eventsNow calendars accounts stores}
254
+
255
+ # Supported output formats
256
+ OUTFORMATS = %w{ansi csv default hash html json md rdoc toc yaml}
257
+
258
+ private
259
+
260
+ # Pad non-options to align with options
261
+ #
262
+ # @param t [String] Text on the left side
263
+ # @return [String] Text indented by summary_indent, and padded according to summary_width
264
+ def pad(t)
265
+ [ @op.summary_indent, t, " " * (@op.summary_width - t.length) ]
266
+ end
267
+ end
268
+ end