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.
- checksums.yaml +7 -0
- data/README.md +184 -0
- data/bin/icalPal +204 -0
- data/icalPal.gemspec +21 -0
- data/lib/EventKit.rb +44 -0
- data/lib/ToICalPal.rb +177 -0
- data/lib/calendar.rb +24 -0
- data/lib/defaults.rb +64 -0
- data/lib/event.rb +283 -0
- data/lib/icalPal.rb +82 -0
- data/lib/options.rb +268 -0
- data/lib/rdt.rb +58 -0
- data/lib/store.rb +19 -0
- metadata +58 -0
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
|