icalPal 3.7.1 → 3.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3334419bc5644203db9bde2b96e1cb3d213bf767150a58e9adabc7238755b76
4
- data.tar.gz: 8dfa347e6bf5bd9ef02a4512a72573719a4a241d2d8660954c3b9c286e02445a
3
+ metadata.gz: 5f6b4e0abaf6ebfaceb51c92f393411df9f5edf2669bdef2121a2c6a09f7443f
4
+ data.tar.gz: 68297ddc631da32b6d4bdd5d25726892ce271b50a122a922f84ff4839e0f9500
5
5
  SHA512:
6
- metadata.gz: ca0cd2fc4f82278fe2ff9a09f3aebf7f6a24ad9c548fb4810e3368454fc51aa4c964da2751fb29ee2ff4bdb65ea62462259ceb47cc96e86cb861d01c9c2ca3c9
7
- data.tar.gz: efc907d25a32059e1b99920f608acae7233808072d5a946c6df7daa78fc182bb8749720b2aa1130997d0f04fb07bca5629a1c619d49d406a17f399c76919c59e
6
+ metadata.gz: db0d030f84bd00891f3bd3fbece35dabb6d43b429e94bfe04aa6323979a0382b8ea0bcaceddb662faa55f6008f813af004f3f2778a1d029ae99d037bb8fb7bb0
7
+ data.tar.gz: e4fc2f8e55ac0db6a9175c836499fe1cc3b5e24d10d2d71964c033e18904d9beb24f3731da2e94feddd1797d8059e09b0f9685f383684f4df1492236fcc35c78
data/README.md CHANGED
@@ -10,14 +10,13 @@ on any system with [Ruby](https://www.ruby-lang.org/) and access to a
10
10
  Calendar or Reminders database.
11
11
 
12
12
  <!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
13
-
14
13
  **Table of Contents**
15
14
 
16
15
  - [Installation](#installation)
17
16
  - [Features](#features)
18
- - [Compatability with icalBuddy](#compatability-with-icalbuddy)
19
17
  - [Additional commands](#additional-commands)
20
18
  - [Additional options](#additional-options)
19
+ - [Additional properties](#additional-properties)
21
20
  - [Usage](#usage)
22
21
  - [Output formats](#output-formats)
23
22
  - [History](#history)
@@ -67,24 +66,61 @@ as *Stores*; you can run ```icalPal stores``` instead.
67
66
 
68
67
  Shows only reminders that have a due date.
69
68
 
69
+ ```icalPal reminders```
70
+
71
+ *reminders*, *datedReminders*, and *undatedReminders* can be used instead of *tasks*
72
+
70
73
  ### Additional options
71
74
 
72
75
  * Options can be abbreviated, so long as they are unique. Eg., ```icalPal -c ev --da 3``` is the same as ```icalPal -c events --days 3```.
73
76
  * The ```-c``` part is optional, but you cannot abbreviate the command if you leave it off.
74
77
  * Use ```-o``` to print the output in different formats. CSV or JSON are intertesting choices.
75
- * Copy your Calendar database file and use ```--db``` on it.
78
+ * Copy your Calendar or Reminders database file and use ```--db``` on it.
76
79
  * ```--it``` and ```--et``` will filter by Calendar *type*. Types are **Local**, **Exchange**, **CalDAV**, **MobileMe**, **Subscribed**, **Birthdays**, and **Reminders**
77
80
  * ```--il``` and ```-el``` will filter by Reminder list
78
81
  * ```--ia``` includes *only* all-day events (opposite of ```--ea```)
79
82
  * ```--aep``` is like ```--iep```, but *adds* to the default property list instead of replacing it.
80
83
  * ```--sep``` to separate by any property, not just calendar (```--sc```) or date (```--sd```)
81
- * ```--color``` uses a wider color palette. Calendar colors are what you have chosen in the Calendar app. Not supported in all terminals, but looks great in [iTerm2](https://iterm2.com/).
84
+ * ```--color``` uses a wider color palette. Colors are what you have chosen in the Calendar and Reminders apps, including custom colors
82
85
  * ```--match``` lets you filter the results of any command to items where a *FIELD* matches a regular expression. Eg., ```--match notes=zoom.us``` to show only Zoom meeetings
83
86
 
84
87
  Because icalPal is written in Ruby, and not a native Mac application,
85
88
  you can run it just about anywhere. It's been tested with the
86
- versions of Ruby included with macOS Sequoia (2.6.10) and
87
- [Homebrew](https://brew.sh/) (3.4.4).
89
+ versions of Ruby included with macOS Sequoia and Tahoe (2.6.10) and
90
+ [Homebrew](https://brew.sh/) (3.4.x).
91
+
92
+ ### Additional properties
93
+
94
+ Several additional properties are available for each command.
95
+
96
+ * Accounts
97
+ * account
98
+ * notes
99
+ * owner
100
+ * type
101
+ * delegations
102
+
103
+ * Calendar
104
+ * account
105
+ * shared\_owner_name, shared\_owner_address
106
+ * self\_identity_email, owner\_identity_email
107
+ * subcal_account_id, subcal_url
108
+ * published_URL
109
+ * notes
110
+ * locale
111
+
112
+ * Tasks
113
+ * id
114
+ * group
115
+ * section
116
+ * tags
117
+ * assignee
118
+ * timezone
119
+ * Notifications
120
+ * due (due_date formatted with --df and --tf options)
121
+ * alert (Early Reminder)
122
+ * location, proximity (arriving or leaving), radius (in meters)
123
+ * messaging (email addresses and phone numbers from "When Messaging")
88
124
 
89
125
  ## Usage
90
126
 
@@ -109,16 +145,16 @@ Global options:
109
145
  ```
110
146
  -c, --cmd=COMMAND Command to run
111
147
  --db=DB Use DB file instead of Calendar
112
- (default: ["/Users/user/Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb", "/Users/user/Library/Calendars/Calendar.sqlitedb"]
148
+ (default: ["$HOME/Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb", "/Users/user/Library/Calendars/Calendar.sqlitedb"]
113
149
  For the tasks commands this should be a directory containing .sqlite files
114
- (default: /Users/user/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores)
115
- --cf=FILE Set config file path (default: /Users/user/.icalpal)
150
+ (default: $HOME/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores)
151
+ --cf=FILE Set config file path (default: $HOME/.icalpal)
116
152
  --norc Ignore ICALPAL and ICALPAL_CONFIG environment variables
117
153
  -o, --output=FORMAT Print as FORMAT (default: default)
118
154
  [ansi, csv, default, hash, html, json, md, rdoc, remind, toc, xml, yaml]
119
155
  ```
120
156
 
121
- Including/excluding calendars and reminders:
157
+ Including/excluding accounts, calendars, reminders and items:
122
158
  ```
123
159
  --is=ACCOUNTS List of accounts to include
124
160
  --es=ACCOUNTS List of accounts to exclude
@@ -133,8 +169,7 @@ Including/excluding calendars and reminders:
133
169
  --il=LISTS List of reminder lists to include
134
170
  --el=LISTS List of reminder lists to exclude
135
171
 
136
- --match=FIELD=REGEXP
137
- Include only items whose FIELD matches REGEXP (ignoring case)
172
+ --match=FIELD=REGEX Include only items whose FIELD matches REGEXP (ignoring case)
138
173
  ```
139
174
 
140
175
  Choosing dates:
data/bin/icalPal CHANGED
@@ -56,6 +56,7 @@ end
56
56
  $opts = ICalPal::Options.new.parse_options
57
57
 
58
58
  $rows = [] # Rows from the database
59
+ $sections = [] # Calendar list sections
59
60
  $items = [] # Items to be printed
60
61
 
61
62
 
@@ -93,19 +94,31 @@ $opts[:db].each do |db|
93
94
  # Load all .sqlite files
94
95
  $log.debug("Loading *.sqlite in #{db}")
95
96
  Dir.glob("#{db}/*.sqlite").each do |d|
96
- $rows += ICalPal.load_data(d, klass::QUERY)
97
97
  success = true
98
98
 
99
+ rows = klass.load_data(d, klass::QUERY)
100
+ $rows += rows
101
+
102
+ sections = klass.load_data(d, klass::SECTIONS_QUERY)
103
+ $sections += sections
104
+
105
+ $log.info("Loaded #{rows.length} rows and #{sections.length} sections from #{d}")
99
106
  rescue SQLite3::CantOpenException
100
107
  # Non-fatal exception, try the next one
101
108
  end
109
+
110
+ rescue Errno::EPERM
111
+ # Probably need Full Disk Access
102
112
  end
103
113
  else
104
114
  # Load database
105
115
  begin
106
- $rows += ICalPal.load_data(db, klass::QUERY)
116
+ rows = ICalPal.load_data(db, klass::QUERY)
117
+ $rows += rows
118
+
107
119
  success = true
108
120
 
121
+ $log.info("Loaded #{rows.length} rows from #{db}")
109
122
  rescue SQLite3::CantOpenException
110
123
  # Non-fatal exception, try the next one
111
124
  end
@@ -140,8 +153,7 @@ unless success
140
153
  abort
141
154
  end
142
155
 
143
- $log.info("Loaded #{$rows.count} #{klass} rows")
144
- $log.info("Window is #{$opts[:from]} to #{$opts[:to]}")
156
+ $log.info("Window is #{$opts[:from]} to #{$opts[:to]}") if $opts[:from]
145
157
 
146
158
 
147
159
  ##################################################
@@ -191,8 +203,8 @@ $rows.each do |row|
191
203
  else
192
204
  # Check for dated reminders
193
205
  if ICalPal::Reminder === item
194
- next if $opts[:dated] == 1 && item['due_date'].positive?
195
- next if $opts[:dated] == 2 && item['due_date'].zero?
206
+ next if $opts[:dated] == 1 && item['due_date'] && item['due_date'].positive?
207
+ next if $opts[:dated] == 2 && (!item['due_date'] || item['due_date'].zero?)
196
208
  end
197
209
 
198
210
  add(item)
@@ -211,9 +223,14 @@ end
211
223
 
212
224
  # Sort the rows
213
225
  begin
214
- $log.info("Sorting #{$items.count} items by #{[ $opts[:sep], $opts[:sort], 'sdate' ]}, reverse #{$opts[:reverse].inspect}")
226
+ sort = []
227
+ sort.push $opts[:sep] if $opts[:sep]
228
+ sort.push $opts[:sort] if $opts[:sort]
229
+ sort.push 'sdate'
230
+
231
+ $log.info("Sorting #{$items.count} items by #{sort}, reverse #{$opts[:reverse].inspect}")
215
232
 
216
- $items.sort_by! { |i| [ i[$opts[:sep]], i[$opts[:sort]], i['sdate'] ] }
233
+ $items.sort_by! { |i| sort }
217
234
  $items.reverse! if $opts[:reverse]
218
235
  rescue Exception => e
219
236
  $log.info("Sorting failed: #{e}\n")
data/bin/icalpal CHANGED
@@ -56,6 +56,7 @@ end
56
56
  $opts = ICalPal::Options.new.parse_options
57
57
 
58
58
  $rows = [] # Rows from the database
59
+ $sections = [] # Calendar list sections
59
60
  $items = [] # Items to be printed
60
61
 
61
62
 
@@ -93,19 +94,31 @@ $opts[:db].each do |db|
93
94
  # Load all .sqlite files
94
95
  $log.debug("Loading *.sqlite in #{db}")
95
96
  Dir.glob("#{db}/*.sqlite").each do |d|
96
- $rows += ICalPal.load_data(d, klass::QUERY)
97
97
  success = true
98
98
 
99
+ rows = klass.load_data(d, klass::QUERY)
100
+ $rows += rows
101
+
102
+ sections = klass.load_data(d, klass::SECTIONS_QUERY)
103
+ $sections += sections
104
+
105
+ $log.info("Loaded #{rows.length} rows and #{sections.length} sections from #{d}")
99
106
  rescue SQLite3::CantOpenException
100
107
  # Non-fatal exception, try the next one
101
108
  end
109
+
110
+ rescue Errno::EPERM
111
+ # Probably need Full Disk Access
102
112
  end
103
113
  else
104
114
  # Load database
105
115
  begin
106
- $rows += ICalPal.load_data(db, klass::QUERY)
116
+ rows = ICalPal.load_data(db, klass::QUERY)
117
+ $rows += rows
118
+
107
119
  success = true
108
120
 
121
+ $log.info("Loaded #{rows.length} rows from #{db}")
109
122
  rescue SQLite3::CantOpenException
110
123
  # Non-fatal exception, try the next one
111
124
  end
@@ -140,8 +153,7 @@ unless success
140
153
  abort
141
154
  end
142
155
 
143
- $log.info("Loaded #{$rows.count} #{klass} rows")
144
- $log.info("Window is #{$opts[:from]} to #{$opts[:to]}")
156
+ $log.info("Window is #{$opts[:from]} to #{$opts[:to]}") if $opts[:from]
145
157
 
146
158
 
147
159
  ##################################################
@@ -191,8 +203,8 @@ $rows.each do |row|
191
203
  else
192
204
  # Check for dated reminders
193
205
  if ICalPal::Reminder === item
194
- next if $opts[:dated] == 1 && item['due_date'].positive?
195
- next if $opts[:dated] == 2 && item['due_date'].zero?
206
+ next if $opts[:dated] == 1 && item['due_date'] && item['due_date'].positive?
207
+ next if $opts[:dated] == 2 && (!item['due_date'] || item['due_date'].zero?)
196
208
  end
197
209
 
198
210
  add(item)
@@ -211,9 +223,14 @@ end
211
223
 
212
224
  # Sort the rows
213
225
  begin
214
- $log.info("Sorting #{$items.count} items by #{[ $opts[:sep], $opts[:sort], 'sdate' ]}, reverse #{$opts[:reverse].inspect}")
226
+ sort = []
227
+ sort.push $opts[:sep] if $opts[:sep]
228
+ sort.push $opts[:sort] if $opts[:sort]
229
+ sort.push 'sdate'
230
+
231
+ $log.info("Sorting #{$items.count} items by #{sort}, reverse #{$opts[:reverse].inspect}")
215
232
 
216
- $items.sort_by! { |i| [ i[$opts[:sep]], i[$opts[:sort]], i['sdate'] ] }
233
+ $items.sort_by! { |i| sort }
217
234
  $items.reverse! if $opts[:reverse]
218
235
  rescue Exception => e
219
236
  $log.info("Sorting failed: #{e}\n")
data/lib/EventKit.rb CHANGED
@@ -30,13 +30,36 @@ class EventKit
30
30
  yearly
31
31
  ].freeze
32
32
 
33
- EKReminderProperty = [
33
+ EKReminderPriority = [
34
34
  'none', # 0
35
35
  'high', nil, nil, nil, # 1
36
36
  'medium', nil, nil, nil, # 5
37
37
  'low', # 9
38
38
  ].freeze
39
39
 
40
+ EKReminderDueDateDeltaUnit = %w[
41
+ minutes
42
+ hours
43
+ days
44
+ weeks
45
+ months
46
+ ].freeze
47
+
48
+ EKReminderProximity = [
49
+ nil,
50
+ 'arriving',
51
+ 'leaving'
52
+ ].freeze
53
+
54
+ EKReminderAccessLevel = [
55
+ { value: 2, level: 'can add' }
56
+ ].freeze
57
+
58
+ EKReminderSharingStatus = [
59
+ { value: 1, status: 'accepted' },
60
+ { value: 5, status: 'invited' }
61
+ ].freeze
62
+
40
63
  # EKSourceType (with color)
41
64
  EKSourceType = [
42
65
  { name: 'Local', color: '#FFFFFF' }, # White
data/lib/calendar.rb CHANGED
@@ -3,20 +3,41 @@ module ICalPal
3
3
  class Calendar
4
4
  include ICalPal
5
5
 
6
+ def [](k)
7
+ case k
8
+ when 'name', 'title' # Aliases
9
+ @self['calendar']
10
+
11
+ else @self[k]
12
+ end
13
+ end
14
+
6
15
  QUERY = <<~SQL.freeze
7
16
  SELECT DISTINCT
8
17
 
9
- Store.name AS account,
10
- Calendar.title AS calendar,
11
- *
18
+ s1.name AS account,
19
+
20
+ c1.UUID,
21
+ c1.title AS calendar,
22
+
23
+ c1.shared_owner_name,
24
+ c1.shared_owner_address,
25
+
26
+ c1.published_URL,
27
+ c1.self_identity_email,
28
+ c1.owner_identity_email,
29
+ c1.notes,
30
+ c1.subcal_account_id,
31
+ c1.subcal_url,
32
+ c1.locale
12
33
 
13
- FROM #{self.name.split('::').last}
34
+ FROM #{self.name.split('::').last} c1
14
35
 
15
- JOIN Store ON store_id = Store.rowid
36
+ JOIN Store s1 ON c1.store_id = s1.rowid
16
37
 
17
- WHERE Store.disabled IS NOT 1
18
- AND Store.display_order IS NOT -1
19
- AND Calendar.flags IS NOT 519
38
+ WHERE s1.disabled IS NOT 1
39
+ AND s1.display_order IS NOT -1
40
+ AND c1.flags IS NOT 519
20
41
  SQL
21
42
 
22
43
  end
data/lib/defaults.rb CHANGED
@@ -8,7 +8,6 @@ $defaults = {
8
8
  common: {
9
9
  ab: '!',
10
10
  aep: [],
11
- atp: [],
12
11
  bullet: '•',
13
12
  cf: "#{Dir.home}/.icalpal",
14
13
  color: false,
@@ -20,7 +19,6 @@ $defaults = {
20
19
  df: '%b %-d, %Y',
21
20
  ec: [],
22
21
  eep: [],
23
- etp: [],
24
22
  el: [],
25
23
  es: [],
26
24
  et: [],
@@ -45,25 +43,28 @@ $defaults = {
45
43
  tasks: {
46
44
  dated: 0,
47
45
  db: [ ICalPal::Reminder::DB_PATH ],
48
- itp: %w[ title notes due priority ],
46
+ iep: %w[ title notes due priority ],
47
+ ps: [ "\n " ],
49
48
  sort: 'prio',
50
49
  },
51
50
 
52
51
  undatedTasks: {
53
52
  dated: 1,
54
53
  db: [ ICalPal::Reminder::DB_PATH ],
55
- itp: %w[ title notes due priority ],
54
+ iep: %w[ title notes due priority ],
55
+ ps: [ "\n " ],
56
56
  sort: 'prio',
57
57
  },
58
58
 
59
59
  datedTasks: {
60
60
  dated: 2,
61
61
  db: [ ICalPal::Reminder::DB_PATH ],
62
- itp: %w[ title notes due priority ],
62
+ iep: %w[ title notes due priority ],
63
+ ps: [ "\n " ],
63
64
  sort: 'prio',
64
65
  },
65
66
 
66
- stores: {
67
+ accounts: {
67
68
  iep: %w[ account type ],
68
69
  sort: 'account',
69
70
  },
data/lib/event.rb CHANGED
@@ -55,7 +55,7 @@ module ICalPal
55
55
  when 'status' # Integer -> String
56
56
  EventKit::EKEventStatus.select { |_k, v| v == @self['status'] }.keys[0]
57
57
 
58
- when 'title' # title[ (age N)]
58
+ when 'event', 'name', 'title' # title[ (age N)]
59
59
  @self['title'] + ((@self['calendar'] == 'Birthdays')? " (age #{self['age']})" : '')
60
60
 
61
61
  when 'uid' # for icalBuddy
@@ -80,8 +80,7 @@ module ICalPal
80
80
  'title' => 'Nothing.',
81
81
  } if DateTime === obj
82
82
 
83
- @self = {}
84
- obj.each_key { |k| @self[k] = obj[k] }
83
+ super
85
84
 
86
85
  # Convert JSON arrays to Arrays
87
86
  @self['attendees'] = JSON.parse(obj['attendees'])
@@ -106,12 +105,7 @@ module ICalPal
106
105
  @self["#{k[0]}date"] = RDT.from_time(Time.at(ctime, in: zone))
107
106
  end
108
107
 
109
- # Type of calendar event is from
110
- obj['type'] = EventKit::EKSourceType.find_index { |i| i[:name] == 'Subscribed' } if obj['subcal_url']
111
- type = EventKit::EKSourceType[obj['type']]
112
-
113
- @self['symbolic_color_name'] ||= @self['color']
114
- @self['type'] = type[:name]
108
+ @self.delete('unique_identifier')
115
109
  end
116
110
 
117
111
  # Check non-recurring events
data/lib/icalPal.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  %w[ EventKit ToICalPal calendar event rdt reminder store ].each { |l| rr l }
2
2
 
3
3
  # Encapsulate the _Store_ (accounts), _Calendar_ and _CalendarItem_
4
- # tables of a Calendar database, and the _Reminder_ table of a
4
+ # tables of a Calendar database, and _Reminders_ (tasks) of a
5
5
  # Reminders database
6
6
  module ICalPal
7
7
  attr_reader :self
@@ -9,11 +9,11 @@ module ICalPal
9
9
  # Dynamic instantiation of our classes based on the command being
10
10
  # run
11
11
  #
12
- # @param klass [String] One of +accounts+, +stores+, +calendars+, +events+, or +tasks+
12
+ # @param klass [String] One of +accounts+, +calendars+, +events+, or +tasks+
13
13
  # @return [Class] The subclass of ICalPal
14
14
  def self.call(klass)
15
15
  case klass
16
- when 'accounts', 'stores' then Store
16
+ when 'accounts' then Store
17
17
  when 'calendars' then Calendar
18
18
  when 'events' then Event
19
19
  when 'tasks' then Reminder
@@ -23,7 +23,11 @@ module ICalPal
23
23
  end
24
24
  end
25
25
 
26
- # Load data
26
+ # Load data from a database
27
+ #
28
+ # @param db_file [String] Path to the database file
29
+ # @param q [String] The query to run
30
+ # @return [Array<Hash>] Array of rows returned by the query
27
31
  def self.load_data(db_file, q)
28
32
  $log.debug(q.gsub("\n", ' '))
29
33
 
@@ -40,7 +44,7 @@ module ICalPal
40
44
  $opts[:props] = stmt.columns - $opts[:eep] if $opts[:props].any? 'all'
41
45
 
42
46
  # Iterate the SQLite3::ResultSet once
43
- stmt.execute.each_with_index { |i, j| rows[j] = i }
47
+ stmt.execute.each { |i| rows.push(i) }
44
48
  stmt.close
45
49
 
46
50
  # Close the database
@@ -66,25 +70,30 @@ module ICalPal
66
70
  rows
67
71
  end
68
72
 
69
- # @param obj [ICalPal] A +Store+ or +Calendar+
73
+ # Initialize fields common to all ICalPal classes
74
+ #
75
+ # @param obj [ICalPal] An +Store+, +Calendar+, +Event+, or +Reminder+
70
76
  def initialize(obj)
71
- obj['type'] = EventKit::EKSourceType.find_index { |i| i[:name] == 'Subscribed' } if obj['subcal_url']
72
- type = EventKit::EKSourceType[obj['type']]
77
+ @self = obj
73
78
 
74
79
  obj['store'] = obj['account']
75
80
 
81
+ obj['type'] = EventKit::EKSourceType.find_index { |i| i[:name] == 'Subscribed' } if obj['subcal_url']
82
+ return unless obj['type']
83
+
84
+ type = EventKit::EKSourceType[obj['type']]
85
+
76
86
  obj['type'] = type[:name]
77
87
  obj['color'] ||= type[:color]
78
88
  obj['symbolic_color_name'] ||= type[:color]
79
-
80
- @self = obj
81
89
  end
82
90
 
83
91
  # Create a new CSV::Row with values from +self+. Newlines are
84
92
  # replaced with '\n' to ensure each Row is a single line of text.
85
93
  #
86
94
  # @param headers [Array] Key names used as the header row in a CSV::Table
87
- # @return [CSV::Row] The +Store+, +Calendar+, or +CalendarItem+ as a CSV::Row
95
+ # @return [CSV::Row] The +Store+, +Calendar+, +CalendarItem+, or
96
+ # +Reminder+ as a CSV::Row
88
97
  def to_csv(headers)
89
98
  values = headers.map { |h| (@self[h].respond_to?(:gsub))? @self[h].gsub("\n", '\n') : @self[h] }
90
99
 
data/lib/options.rb CHANGED
@@ -79,7 +79,7 @@ module ICalPal
79
79
  @op.on('--el=LISTS', Array, 'List of reminder lists to exclude')
80
80
 
81
81
  @op.separator('')
82
- @op.on('--match=FIELD=REGEXP', String, 'Include only items whose FIELD matches REGEXP (ignoring case)')
82
+ @op.on('--match=FIELD=REGEX', String, 'Include only items whose FIELD matches REGEX (ignoring case)')
83
83
 
84
84
  # dates
85
85
  @op.separator("\nChoosing dates:\n\n")
@@ -233,10 +233,15 @@ module ICalPal
233
233
  rescue StandardError
234
234
  end unless cli[:norc]
235
235
 
236
+ # Find command
236
237
  cli[:cmd] ||= @op.default_argv[0]
237
238
  cli[:cmd] ||= env[:cmd] if env[:cmd]
238
239
  cli[:cmd] ||= cf[:cmd] if cf[:cmd]
239
- cli[:cmd] = 'stores' if cli[:cmd] == 'accounts'
240
+
241
+ # Handle command aliases
242
+ cli[:cmd] = 'accounts' if cli[:cmd] == 'stores'
243
+ cli[:cmd] = cli[:cmd].sub('reminders', 'tasks')
244
+ cli[:cmd] = cli[:cmd].sub('datedReminders', 'datedTasks')
240
245
 
241
246
  # Parse eventsNow and eventsToday commands
242
247
  cli[:cmd].match('events(Now|Today|Remaining)(\+[0-9]+)?') do |m|
@@ -282,9 +287,11 @@ module ICalPal
282
287
  opts[:version] = @op.version
283
288
 
284
289
  # From the Department of Redundancy Department
285
- (opts[:cmd].include? 'tasks')?
286
- opts[:props] = (opts[:itp] + opts[:atp] - opts[:etp]).uniq :
287
- opts[:props] = (opts[:iep] + opts[:aep] - opts[:eep]).uniq
290
+ opts[:iep] = opts[:itp] if opts[:itp]
291
+ opts[:eep] = opts[:etp] if opts[:etp]
292
+ opts[:aep] = opts[:atp] if opts[:atp]
293
+
294
+ opts[:props] = (opts[:iep] + opts[:aep] - opts[:eep]).uniq
288
295
 
289
296
  # From, to, days
290
297
  if opts[:from]
@@ -325,7 +332,7 @@ module ICalPal
325
332
  end
326
333
 
327
334
  # Commands that can be run
328
- COMMANDS = %w[events eventsToday eventsNow eventsRemaining tasks datedTasks undatedTasks calendars accounts stores].freeze
335
+ COMMANDS = %w[events eventsToday eventsNow eventsRemaining tasks datedTasks undatedTasks calendars accounts].freeze
329
336
 
330
337
  # Supported output formats
331
338
  OUTFORMATS = %w[ansi csv default hash html json md rdoc remind toc xml yaml].freeze
data/lib/reminder.rb CHANGED
@@ -1,20 +1,59 @@
1
- r 'open3'
2
- r 'plist'
1
+ r 'timezone'
2
+
3
+ # Images
4
+ # Section
3
5
 
4
6
  module ICalPal
5
7
  # Class representing items from the <tt>Reminders</tt> database
6
8
  class Reminder
7
9
  include ICalPal
8
10
 
11
+ def self.load_data(db_file, q)
12
+ # Load items
13
+ ICalPal.load_data(db_file, q)
14
+ end
15
+
9
16
  def [](k)
10
17
  case k
11
- when 'notes' # Skip empty notes
12
- (@self['notes'].empty?)? nil : @self['notes']
18
+ when 'alert' # -N (minutes|hours|days|weeks|months)
19
+ if @self['alert']
20
+ alert = JSON.parse(@self['alert'])['dueDateDeltaAlerts'][0]
21
+ count = alert['dueDateDeltaCount'] * -1
22
+ unit = EventKit::EKReminderDueDateDeltaUnit[alert['dueDateDeltaUnit']]
23
+ "#{count} #{unit}"
24
+ end
25
+
26
+ when 'assignee' # [ nickname, firstname, lastname, address ]
27
+ if @self['assignee']
28
+ a = @self['assignee']
29
+ t = (a[0])? a[0] : "#{a[1]} #{a[2]}"
30
+ t += " (#{a[3][7..]})" if a[3]
31
+ t
32
+ end
33
+
34
+ when 'due' # date[ at time]
35
+ if @self['due']
36
+ t = @self['due'].to_s
37
+ t += " at #{@self['due'].strftime($opts[:tf])}" unless @self['all_day'] == 1
38
+ t
39
+ end
40
+
41
+ when 'group' # (group|"(no group)")
42
+ (@self['group'])? @self['group'] : '(no group)'
13
43
 
14
44
  when 'priority' # Integer -> String
15
- EventKit::EKReminderProperty[@self['priority']] if @self['priority'].positive?
45
+ EventKit::EKReminderPriority[@self['priority']] if @self['priority'].positive?
46
+
47
+ when 'proximity' # (arriving|leaving)
48
+ EventKit::EKReminderProximity[@self['proximity']] if @self['proximity']
49
+
50
+ when 'radius' # Float -> Integer
51
+ "#{Integer(@self['radius'])}m" if @self['radius']
16
52
 
17
53
  when 'sdate' # For sorting
54
+ @self['due_date']
55
+
56
+ when 'name', 'reminder', 'task' # Aliases
18
57
  @self['title']
19
58
 
20
59
  else @self[k]
@@ -22,8 +61,24 @@ module ICalPal
22
61
  end
23
62
 
24
63
  def initialize(obj)
25
- @self = {}
26
- obj.each_key { |k| @self[k] = obj[k] }
64
+ super
65
+
66
+ # Convert JSON arrays to Arrays
67
+ @self['tags'] = JSON.parse(obj['tags']) if obj['tags']
68
+ @self['location'] = JSON.parse(obj['location']).compact.uniq[0] if obj['location']
69
+ @self['proximity'] = JSON.parse(obj['proximity']).compact.uniq[0] if obj['proximity']
70
+ @self['radius'] = JSON.parse(obj['radius']).compact.uniq[0] if obj['radius']
71
+ @self['assignee'] = JSON.parse(obj['assignee']) if obj['assignee']
72
+
73
+ # Section
74
+ if @self['members']
75
+ j = JSON.parse(@self['members']).select { |i| i['memberID'] == @self['id'] }
76
+ s = $sections.select { |i| i['id'] == j[0]['groupID'] } if j[0]
77
+ @self['section'] = s[0]['name'] if s && s[0]
78
+
79
+ @self.delete('members')
80
+ @self.delete('id')
81
+ end
27
82
 
28
83
  # Priority
29
84
  # rubocop: disable Style/NumericPredicate
@@ -33,14 +88,21 @@ module ICalPal
33
88
  @self['prio'] = 3 if @self['priority'] == 0 # none
34
89
  # rubocop: enable Style/NumericPredicate
35
90
 
36
- @self['long_priority'] = LONG_PRIORITY[@self['prio']]
91
+ @self['long_priority'] = LONG_PRIORITY[@self['prio']] if @self['prio']
37
92
 
38
93
  # For sorting
39
94
  @self['sdate'] = (@self['title'])? @self['title'] : ''
40
95
 
41
96
  # Due date
42
- @self['due'] = RDT.new(*Time.at(@self['due_date'] + ITIME).to_a.reverse[4..]) if @self['due_date']
43
- @self['due_date'] = 0 unless @self['due_date']
97
+ if @self['due_date']
98
+ begin
99
+ zone = Timezone.fetch(@self['timezone'])
100
+ rescue Timezone::Error::InvalidZone
101
+ zone = '+00:00'
102
+ end
103
+
104
+ @self['due'] = RDT.from_time(Time.at(@self['due_date'] + ITIME, in: zone))
105
+ end
44
106
 
45
107
  # Notes
46
108
  @self['notes'] = '' unless @self['notes']
@@ -49,22 +111,33 @@ module ICalPal
49
111
  @self['color'] = nil unless $opts[:palette]
50
112
 
51
113
  if @self['color']
52
- # Run command
53
- stdin, stdout, _stderr, _e = Open3.popen3(PL_CONVERT)
54
-
55
- # Send color bplist
56
- stdin.write(@self['color'])
57
- stdin.close
114
+ plist = plconvert(@self['color'])
58
115
 
59
- # Read output
60
- plist = Plist.parse_xml(stdout.read)['$objects']
61
-
62
- @self['color'] = plist[3]
63
- @self['symbolic_color_name'] = (plist[2] == 'custom')? plist[4] : plist[2]
116
+ # Get color and symbolic color name
117
+ plist.each do |p|
118
+ @self['color'] = plist[p['daHexString']['CF$UID']] if p['daHexString']
119
+ @self['symbolic_color_name'] = plist[p['ckSymbolicColorName']['CF$UID']] if p['ckSymbolicColorName']
120
+ end
64
121
  else
65
122
  @self['color'] = DEFAULT_COLOR
66
123
  @self['symbolic_color_name'] = DEFAULT_SYMBOLIC_COLOR
67
124
  end
125
+
126
+ # Contacts
127
+ messaging = []
128
+
129
+ plist = plconvert(@self['messaging'])
130
+ plist.each do |p|
131
+ %w[ emails phones ].each do |field|
132
+ next unless p[field]
133
+
134
+ offset = p[field].values[0]
135
+ targets = plist[offset]['NS.objects']
136
+ targets.each { |t| messaging.push(plist[t['CF$UID']]) }
137
+ end
138
+ end if plist
139
+
140
+ @self['messaging'] = messaging
68
141
  end
69
142
 
70
143
  DEFAULT_COLOR = '#1BADF8'.freeze
@@ -77,32 +150,105 @@ module ICalPal
77
150
  'No priority',
78
151
  ].freeze
79
152
 
80
- PL_CONVERT = '/usr/bin/plutil -convert xml1 -o - -'.freeze
81
-
82
153
  DB_PATH = "#{Dir.home}/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores".freeze
83
154
 
84
155
  QUERY = <<~SQL.freeze
85
156
  SELECT DISTINCT
86
157
 
87
- zremcdReminder.zAllday as all_day,
88
- zremcdReminder.zDuedate as due_date,
89
- zremcdReminder.zFlagged as flagged,
90
- zremcdReminder.zNotes as notes,
91
- zremcdReminder.zPriority as priority,
92
- zremcdReminder.zTitle as title,
158
+ r1.zTitle as title,
159
+ r1.zAllday as all_day,
160
+ r1.zDueDate as due_date,
161
+ r1.zFlagged as flagged,
162
+ r1.zNotes as notes,
163
+ r1.zPriority as priority,
164
+ r1.zContactHandles as messaging,
165
+ r1.zDueDateDeltaAlertsData as alert,
166
+ r1.zTimezone as timezone,
167
+ r1.zckIdentifier as id,
168
+
169
+ bl1.zBadgeEmblem as badge,
170
+ bl1.zColor as color,
171
+ bl1.zName as list_name,
172
+ bl1.zParentList as parent,
173
+ bl1.zSharingStatus as shared,
174
+
175
+ -- section members
176
+ json(bl1.ZMembershipsOfRemindersInSectionsAsData) -> '$.memberships' AS members,
177
+
178
+ -- group
179
+ (SELECT zName
180
+ FROM zremcdBaseList bl2
181
+ WHERE bl2.z_pk = bl1.zParentList) AS 'group',
182
+
183
+ -- location
184
+ (SELECT json_group_array(zremcdObject.zTitle)
185
+ FROM zremcdObject
186
+ WHERE zremcdObject.z_pk IN (
187
+ SELECT zTrigger
188
+ FROM zremcdObject
189
+ WHERE zremcdObject.zReminder = r1.z_pk
190
+ )) AS location,
191
+
192
+ -- proximity
193
+ (SELECT json_group_array(zremcdObject.zProximity)
194
+ FROM zremcdObject
195
+ WHERE zremcdObject.z_pk IN (
196
+ SELECT zTrigger
197
+ FROM zremcdObject
198
+ WHERE zremcdObject.zReminder = r1.z_pk
199
+ )) AS proximity,
93
200
 
94
- zremcdBaseList.zBadgeEmblem as badge,
95
- zremcdBaseList.zColor as color,
96
- zremcdBaseList.zName as list_name,
97
- zremcdBaseList.zParentList as parent,
98
- zremcdBaseList.zSharingStatus as shared
201
+ -- radius
202
+ (SELECT json_group_array(zremcdObject.zRadius)
203
+ FROM zremcdObject
204
+ WHERE zremcdObject.z_pk IN (
205
+ SELECT zTrigger
206
+ FROM zremcdObject
207
+ WHERE zremcdObject.zReminder = r1.z_pk
208
+ )) AS radius,
99
209
 
100
- FROM zremcdReminder
210
+ -- tags
211
+ (SELECT json_group_array(zName)
212
+ FROM zremcdHashtagLabel
213
+ WHERE zremcdHashtagLabel.z_pk IN (
214
+ SELECT zremcdObject.zHashtagLabel
215
+ FROM zremcdObject
216
+ JOIN zremcdreminder ON zremcdObject.zReminder3 = r1.z_pk
217
+ WHERE zremcdObject.zReminder3 = r1.z_pk
218
+ )) AS tags,
219
+
220
+ -- assignee
221
+ (SELECT
222
+ json_array(zNickname, zFirstName, zLastName, zAddress1)
223
+ FROM zremcdObject
224
+ WHERE z_pk = (
225
+ SELECT zAssignee
226
+ FROM zremcdObject
227
+ WHERE zReminder1 = r1.z_pk
228
+ )) AS assignee,
229
+
230
+ -- url
231
+ (SELECT zURL
232
+ FROM zremcdObject
233
+ WHERE zReminder2 = r1.z_pk) AS url
234
+
235
+ FROM zremcdReminder r1
236
+
237
+ LEFT OUTER JOIN zremcdBaseList bl1 ON r1.zList = bl1.z_pk
238
+
239
+ WHERE r1.zCompleted = 0
240
+ AND r1.zMarkedForDeletion = 0
241
+
242
+ SQL
243
+
244
+ # Load sections
245
+ SECTIONS_QUERY = <<~SQL.freeze
246
+ SELECT DISTINCT
101
247
 
102
- JOIN zremcdBaseList ON zremcdReminder.zList = zremcdBaseList.z_pk
248
+ zckIdentifier AS id,
249
+ zDisplayName AS name
103
250
 
104
- WHERE zremcdReminder.zCompleted = 0
105
- AND zremcdReminder.zMarkedForDeletion = 0
251
+ FROM zremcdBaseSection
106
252
 
107
253
  SQL
108
254
 
data/lib/store.rb CHANGED
@@ -3,16 +3,42 @@ module ICalPal
3
3
  class Store
4
4
  include ICalPal
5
5
 
6
+ def [](k)
7
+ case k
8
+ when 'name', 'title' # Aliases
9
+ @self['account']
10
+
11
+ when 'owner' # Owner iff there is an account
12
+ (@self['owner'] == @self['account'])? nil : @self['owner']
13
+
14
+ else @self[k]
15
+ end
16
+ end
17
+
18
+ def initialize(obj)
19
+ super
20
+
21
+ # Convert JSON arrays to Arrays
22
+ @self['delegations'] = JSON.parse(obj['delegations']).sort if obj['delegations']
23
+ end
24
+
6
25
  QUERY = <<~SQL.freeze
7
26
  SELECT DISTINCT
8
27
 
9
- Store.name AS account,
10
- *
28
+ s1.name AS account,
29
+ s1.owner_name AS owner,
30
+ s1.notes,
31
+ s1.type,
32
+
33
+ (SELECT json_group_array(name)
34
+ FROM #{self.name.split('::').last} s2
35
+ WHERE s2.delegated_account_owner_store_id == s1.external_id
36
+ ) AS delegations
37
+
38
+ FROM #{self.name.split('::').last} s1
11
39
 
12
- FROM #{self.name.split('::').last}
40
+ WHERE s1.delegated_account_owner_store_id IS NULL
13
41
 
14
- WHERE Store.disabled IS NOT 1
15
- AND Store.display_order IS NOT -1
16
42
  SQL
17
43
 
18
44
  end
data/lib/utils.rb CHANGED
@@ -1,3 +1,29 @@
1
+ PL_CONVERT = '/usr/bin/plutil -convert xml1 -o - -'.freeze
2
+
3
+ # Load a plist
4
+ #
5
+ # @param obj [String] Data that can be converted by +/usr/bin/plutil+
6
+ # @return [Array] Objects representing nodes in the plist
7
+ def plconvert(obj)
8
+ r 'open3'
9
+ r 'plist'
10
+
11
+ # Run PL_CONVERT command
12
+ sin, sout, _serr, _e = Open3.popen3(PL_CONVERT)
13
+
14
+ # Send obj
15
+ sin.write(obj)
16
+ sin.close
17
+
18
+ # Read output
19
+ begin
20
+ plist = Plist.parse_xml(sout.read)
21
+ plist['$objects'] if plist
22
+ rescue Plist::UnimplementedElementError
23
+ nil
24
+ end
25
+ end
26
+
1
27
  # Convert a key/value pair to XML. The value should be +nil+, +String+,
2
28
  # +Integer+, +Array+, or +ICalPal::RDT+
3
29
  #
data/lib/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module ICalPal
2
2
  NAME = 'icalPal'.freeze
3
- VERSION = '3.7.1'.freeze
3
+ VERSION = '3.8.0'.freeze
4
4
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: icalPal
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.1
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Rosen
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-07-27 00:00:00.000000000 Z
10
+ date: 2025-08-06 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |
13
13
  Inspired by icalBuddy and maintains close compatability. Includes