icalPal 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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