imsg-grep 0.1.2-darwin

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.
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # Core iMessage database processing - builds views with decoded messages, expanded contacts, a cache
4
+
5
+ require 'sqlite3'
6
+ require 'fileutils'
7
+ require_relative 'apple/keyed_archive'
8
+ require_relative 'apple/attr_str'
9
+
10
+ module Messages
11
+ APPLE_EPOCH = 978307200
12
+
13
+ ADDY_DB = Dir[File.expand_path("~/Library/Application Support/AddressBook/Sources/*/AddressBook-*.abcddb")][0]
14
+ IMSG_DB = File.expand_path("~/Library/Messages/chat.db")
15
+ CACHE_DB = File.expand_path("~/.cache/imsg-grep/cache.db")
16
+
17
+ def self.reset_cache = FileUtils.rm_f(CACHE_DB)
18
+ def self.db = @db
19
+
20
+ def self.init
21
+ ################################################################################
22
+ ### DB Setup ###################################################################
23
+ ################################################################################
24
+ FileUtils.mkdir_p File.dirname(CACHE_DB)
25
+ FileUtils.touch(CACHE_DB)
26
+
27
+ [ADDY_DB, IMSG_DB, CACHE_DB].each do |db|
28
+ raise "Database not found: #{db}" unless File.exist?(db)
29
+ raise "Database not readable: #{db}" unless File.readable?(db)
30
+ end
31
+
32
+ @db = SQLite3::Database.new(":memory:")
33
+ @db.execute "ATTACH DATABASE '#{ADDY_DB}' AS _addy"
34
+ @db.execute "ATTACH DATABASE '#{IMSG_DB}' AS _imsg"
35
+ @db.execute "ATTACH DATABASE '#{CACHE_DB}' AS _cache"
36
+
37
+ def @db.select_hashes(sql) = prepare(sql).execute.enum_for(:each_hash).map{ it.transform_keys(&:to_sym) }
38
+ def @db.ƒ(f, &) = define_function_with_flags(f.to_s, SQLite3::Constants::TextRep::UTF8 | SQLite3::Constants::TextRep::DETERMINISTIC, &)
39
+
40
+ regex_cache = Hash.new { |h,src| h[src] = Regexp.new(src) }
41
+ @db.ƒ(:regexp) { |rx, text| regex_cache[rx].match?(text) ? 1 : 0 }
42
+ @db.ƒ(:apple2unix) { |time| (time / 1_000_000_000) + APPLE_EPOCH }
43
+ @db.ƒ(:unarchive_keyed) { |data| KeyedArchive.unarchive(data).to_json if data }
44
+ @db.ƒ(:unarchive_string) { |data| AttributedStringExtractor.extract(data) if data }
45
+ # othan than regexp, the simpler versions above are no longer used because caching, but useful when doing other sql stuff
46
+
47
+ ################################################################################
48
+ ### Contacts/handles setup #####################################################
49
+ ################################################################################
50
+ # Contacts table with:
51
+ # - contact_id: id in address book
52
+ # - handle_id: handle.rowid in chat.db
53
+ # - handle: email or phone number (formatted as handle)
54
+ # - is_me: (unused atm)
55
+ # one row per handle.
56
+ # only contacts that exist in chat.db, but all handles even those not in chat.db
57
+ # so can be used when matching contact info
58
+ @db.ƒ(:normalize_phone) { |text| text =~ /[^\p{Punct}\p{Space}\d+]/ ? text : text.delete("^0-9+") } # remove punctuation from normal phones but keep weird phones intact
59
+ @db.execute <<~SQL
60
+ CREATE TEMP TABLE contacts AS
61
+ WITH contact_handles AS ( -- handles from _addy per contact_id
62
+ SELECT
63
+ ZOWNER as contact_id,
64
+ ZADDRESS as handle
65
+ FROM _addy.ZABCDEMAILADDRESS
66
+ UNION ALL
67
+ SELECT
68
+ ZOWNER as contact_id,
69
+ normalize_phone(ZFULLNUMBER) as handle
70
+ FROM _addy.ZABCDPHONENUMBER
71
+ ),
72
+ matched_handles AS ( -- handles from _addy for matched handles in _imsg
73
+ SELECT DISTINCT
74
+ r.Z_PK as contact_id,
75
+ ih.ROWID as handle_id,
76
+ ch.handle as matched_handle,
77
+ (r.ZCONTAINERWHERECONTACTISME IS NOT NULL) as is_me
78
+ FROM _addy.ZABCDRECORD r
79
+ JOIN contact_handles ch ON r.Z_PK = ch.contact_id
80
+ JOIN _imsg.handle ih ON ih.id = ch.handle -- only handles in _imsg
81
+ )
82
+ SELECT
83
+ mh.contact_id,
84
+ mh.handle_id,
85
+ ch.handle, -- all handles for this contact
86
+ mh.is_me
87
+ FROM matched_handles mh
88
+ JOIN contact_handles ch ON ch.contact_id = mh.contact_id -- get ALL handles for matched contact
89
+ SQL
90
+
91
+ @db.ƒ(:computed_name) do |first, maiden, middle, last, nick, org|
92
+ names = [first, maiden, middle, last].compact.reject(&:empty?)
93
+ names << "(#{nick})" if nick && !nick.empty?
94
+ names.empty? ? org.to_s : names.join(" ")
95
+ end
96
+
97
+ # Handle groups table:
98
+ # - handle_id: handle.rowid in chat.db
99
+ # - searchable: JSON array of searchable terms: handles + names
100
+ # - name: contact display name computed from address book
101
+ # - contact_ids: JSON array of contact IDs from address book (unused atm)
102
+ # one row per handle_id. includes handles without contact entries
103
+ @db.execute <<~SQL
104
+ CREATE TEMP TABLE handle_groups AS
105
+ WITH computed AS (
106
+ SELECT
107
+ c.handle_id,
108
+ computed_name(r.zfirstname, r.zmaidenname, r.zmiddlename, r.zlastname, r.znickname, r.zorganization)
109
+ as name,
110
+ r.Z_PK as contact_id
111
+ FROM _addy.zabcdrecord r
112
+ JOIN contacts c ON c.contact_id = r.Z_PK -- get all contact->handle mappings with computed names
113
+ ),
114
+ searchables AS (
115
+ SELECT c.handle_id, c2.handle as term -- get ALL handles for this contact
116
+ FROM computed c
117
+ JOIN contacts c2 ON c2.contact_id = c.contact_id
118
+ UNION ALL
119
+ SELECT handle_id, name as term -- flatten: handle_id -> each computed name
120
+ FROM computed
121
+ )
122
+ SELECT
123
+ c.handle_id,
124
+ ( SELECT json_group_array(DISTINCT term) -- collect all searchable terms per handle
125
+ FROM searchables s
126
+ WHERE s.handle_id = c.handle_id) as searchable, -- result: ["handle1","handle2","Name"]
127
+ MIN(c.name) as name, -- pick first computed name when duplicates
128
+ json_group_array(DISTINCT c.contact_id) as contact_ids -- collect all contact_ids as JSON array
129
+ FROM computed c
130
+ GROUP BY c.handle_id -- collapse duplicate contact entries per handle
131
+ UNION
132
+ SELECT -- add handles without any contact entry
133
+ h.ROWID as handle_id,
134
+ json_array(h.id) as searchable, -- only handle is searchable
135
+ h.id as name, -- handle as display name
136
+ null as contact_ids -- no contacts for this handle
137
+ FROM _imsg.handle h
138
+ WHERE h.ROWID NOT IN (SELECT handle_id FROM contacts) -- exclude handles already processed above
139
+ ORDER BY name
140
+ SQL
141
+
142
+ ################################################################################
143
+ ### Caching ####################################################################
144
+ ################################################################################
145
+ @db.execute_batch <<~SQL
146
+ CREATE TABLE IF NOT EXISTS _cache.texts (guid TEXT PRIMARY KEY, value TEXT) STRICT;
147
+ CREATE TABLE IF NOT EXISTS _cache.payloads (guid TEXT PRIMARY KEY, value TEXT) STRICT;
148
+ CREATE TABLE IF NOT EXISTS _cache.links (guid TEXT PRIMARY KEY, value TEXT) STRICT;
149
+ SQL
150
+
151
+ @cache = { texts: {}, payload_data: {}, payloads: {}, links: {} }
152
+
153
+ cache_text = ->(guid, attr) { @cache[:texts][guid] ||= AttributedStringExtractor.extract(attr) }
154
+ cache_payload = ->(guid, data) do
155
+ @cache[:payload_data][guid] = KeyedArchive.unarchive(data) unless @cache[:payload_data].key? guid
156
+ @cache[:payloads][guid] ||= @cache[:payload_data][guid]&.to_json
157
+ end
158
+
159
+ # The `computed` CTE in `message_view` calls these functions which generate the data on demand
160
+ # and populate a cache for a next run. The CTE joins against that cache, and calls these functions
161
+ # for rows where the join is empty.
162
+ # the at_exit block below stores the cache from this run after the program has done its thing.
163
+
164
+ @db.ƒ(:cache_text) { |guid, attr| cache_text.(guid, attr) }
165
+ @db.ƒ(:cache_payload_json) { |guid, data| cache_payload.(guid, data) }
166
+
167
+ end_mark = '\uFFFC\p{Space}' # \uFFFC is the attributed string object marker
168
+ noallow = Regexp.escape('\|^"<>{}[]') + end_mark
169
+ rx_url = %r(\bhttps?://[^#{noallow}]{4,}?(?=["':;,\.\)]{0,3}(?:[#{end_mark}]|$)))i
170
+
171
+ @db.ƒ(:cache_link_metadata) do |guid, data, text, attr|
172
+ next @cache[:links][guid] if @cache[:links][guid]
173
+ text = cache_text.(guid, attr)
174
+ cache_payload.(guid, data) # force @cache[:payload_data] to be set
175
+ payload = @cache[:payload_data][guid]
176
+
177
+ rich_link = payload&.dig "richLinkMetadata"
178
+ found_url = text[rx_url] if text # manual extraction, in case no rich link data
179
+ rich_url = rich_link&.dig "URL" # canonical or resolved
180
+ orig_url = rich_link&.dig "originalURL" # extracted by imessage from text, adds protocol etc
181
+ title = rich_link&.dig "title"
182
+ summary = rich_link&.dig "summary"
183
+ image = rich_link&.dig "imageMetadata", "URL"
184
+ image_idx = rich_link&.dig "image", "richLinkImageAttachmentSubstituteIndex"
185
+ ci_idx = rich_link&.dig("contentImages")&.at(0)&.dig("richLinkImageAttachmentSubstituteIndex")
186
+ image_idx = ci_idx if ci_idx
187
+ url = rich_url || orig_url || found_url
188
+ # i'm not sure the mapping richLinkImageAttachmentSubstituteIndex to
189
+ # attachment.rowid sort order is fully reliable, but i can't find any
190
+ # other way to go about it, nothing else the index could map to
191
+ # the other option is sort link files by total_bytes and pick the largest :/
192
+
193
+ link = { url:, title:, summary:, image:, image_idx:, original_url: orig_url } if url
194
+ @cache[:links][guid] = link.to_json
195
+ end
196
+
197
+ at_exit do
198
+ # next # disable saving cache
199
+ next if @cache.values.all?(&:empty?)
200
+ quote = ->v{ v == nil ? "NULL" : "'#{SQLite3::Database.quote v}'" }
201
+ batch_size = 50_000
202
+ @db.transaction do
203
+ @cache.except(:payload_data).each do |table, rows|
204
+ rows.each_slice(batch_size) do |rows|
205
+ values = rows.inject(String.new){|s, (guid, v)| s << "('#{guid}', #{quote[v]})," }.chop!
206
+ @db.execute <<~SQL
207
+ INSERT INTO _cache.#{table} (guid, value) VALUES #{values}
208
+ ON CONFLICT(guid) DO UPDATE SET value = COALESCE(excluded.value, _cache.#{table}.value)
209
+ SQL
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ ################################################################################
216
+ ### Main message view ##########################################################
217
+ ################################################################################
218
+
219
+ unix_time = "((m.date / 1000000000) + #{APPLE_EPOCH})"
220
+ @db.execute <<~SQL
221
+ CREATE TEMP VIEW message_view AS
222
+ WITH chat_members AS (
223
+ SELECT
224
+ c.ROWID as chat_id,
225
+ (COUNT(DISTINCT hg.handle_id) > 1) as is_group_chat,
226
+ json_group_array(DISTINCT hg.name) as member_names,
227
+ json_group_array(DISTINCT term.value) as members_searchable
228
+ FROM _imsg.chat c
229
+ JOIN _imsg.chat_handle_join chj ON c.ROWID = chj.chat_id
230
+ LEFT JOIN handle_groups hg ON chj.handle_id = hg.handle_id
231
+ CROSS JOIN json_each(hg.searchable) as term -- flatten each member's searchable array
232
+ GROUP BY c.ROWID
233
+ ),
234
+ computed AS (
235
+ SELECT
236
+ m.ROWID,
237
+ COALESCE(m.text, ct.value, IIF(ct.guid IS NULL AND m.attributedBody IS NOT NULL, cache_text(m.guid, m.attributedBody)))
238
+ as text,
239
+ COALESCE(cp.value, IIF(cp.guid IS NULL AND m.payload_data IS NOT NULL, cache_payload_json(m.guid, m.payload_data)))
240
+ as payload_json,
241
+ COALESCE(cl.value, IIF(cl.guid IS NULL AND (
242
+ m.payload_data IS NOT NULL OR instr(m.text, 'http') OR instr(m.attributedBody, 'http')
243
+ ), cache_link_metadata(m.guid, m.payload_data, m.text, m.attributedBody)))
244
+ as link
245
+ FROM message m
246
+ LEFT JOIN _cache.texts ct ON m.guid = ct.guid
247
+ LEFT JOIN _cache.payloads cp ON m.guid = cp.guid
248
+ LEFT JOIN _cache.links cl ON m.guid = cl.guid
249
+ )
250
+ SELECT
251
+ m.ROWID as message_id,
252
+ m.guid,
253
+ m.associated_message_type,
254
+ m.service,
255
+ m.cache_has_attachments as has_attachments,
256
+ mc.text,
257
+ mc.payload_json,
258
+ c.display_name as chat_name,
259
+ #{unix_time} as unix_time,
260
+ strftime('%Y-%m-%d', #{unix_time}, 'unixepoch') as utc_date,
261
+ strftime('%Y-%m-%d', #{unix_time}, 'unixepoch', 'localtime') as local_date,
262
+ datetime(#{unix_time}, 'unixepoch') as utc_time,
263
+ datetime(#{unix_time}, 'unixepoch', 'localtime') as local_time,
264
+ m.is_from_me as is_from_me,
265
+ m.payload_data as payload_data,
266
+ mc.link,
267
+ cm.is_group_chat,
268
+
269
+ -- 1. Direct message **from me** → show recipient
270
+ -- 2. Group chat or message **from me** → show all members
271
+ -- 3. Direct message **to me** → null recipients (it me!)
272
+ -- i prefer using these funny conditions as closer to the source than is_group_chat, is_from_me
273
+ CASE
274
+ WHEN hg_recip.name IS NOT NULL -- is not group chat (is DM)
275
+ THEN json_array(hg_recip.name) -- fake an array with single recipient
276
+ -- sender null == is from me, as hg_sender joins on is_from_me=0 → recipients = members
277
+ -- sender != members means more members → group chat (all chat members are recipients (incl sender))
278
+ WHEN hg_sender.searchable IS NULL OR hg_sender.searchable != cm.members_searchable
279
+ THEN cm.member_names
280
+ -- recipient is null and is not from me and sender == members = 'tis I the recipient, so null
281
+ ELSE NULL
282
+ END as recipient_names, -- as recipient_names, (repeated here for visibility)
283
+
284
+ CASE -- same logic
285
+ WHEN hg_recip.searchable IS NOT NULL
286
+ THEN hg_recip.searchable
287
+ WHEN hg_sender.searchable IS NULL OR hg_sender.searchable != cm.members_searchable
288
+ THEN cm.members_searchable
289
+ ELSE NULL
290
+ END as recipients_searchable, -- as recipients_searchable,
291
+
292
+ hg_sender.name as sender_name,
293
+ hg_recip.name as recipient_name,
294
+ COALESCE(cm.member_names, json_array()) as member_names, -- all chat members
295
+ hg_sender.searchable as sender_searchable, -- for optional filtering
296
+ hg_recip.searchable as recipient_searchable, -- for optional filtering
297
+ cm.members_searchable as members_searchable -- for optional filtering
298
+
299
+ FROM _imsg.message m
300
+ JOIN computed mc ON m.ROWID = mc.ROWID
301
+ LEFT JOIN _imsg.chat_message_join cmj ON m.ROWID = cmj.message_id
302
+ LEFT JOIN _imsg.chat c ON cmj.chat_id = c.ROWID
303
+ LEFT JOIN handle_groups hg_sender ON m.handle_id = hg_sender.handle_id AND m.is_from_me = 0
304
+ LEFT JOIN handle_groups hg_recip ON m.handle_id = hg_recip.handle_id AND m.is_from_me = 1
305
+ LEFT JOIN chat_members cm ON c.ROWID = cm.chat_id
306
+ WHERE
307
+ ((associated_message_type IS NULL OR associated_message_type < 1000) -- Exclude associated reaction messages 1000: stickers, 20xx: reactions; 30xx: remove reactions
308
+ AND (balloon_bundle_id IS NULL OR balloon_bundle_id = 'com.apple.messages.URLBalloonProvider') -- Exclude all payload msgs that are not links. Digital touch lol, Find My, etc
309
+ )
310
+ SQL
311
+
312
+ return @db
313
+ end
314
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ require "time"
3
+
4
+ # DateArg - Flexible date/time parser supporting multiple input formats
5
+ #
6
+ # Supported formats:
7
+ #
8
+ # Absolute dates:
9
+ # 2024-01-01 => Date object
10
+ # 2024-1-1 => Date object (relaxed format)
11
+ # 2024-12-31 => Date object
12
+ #
13
+ # Absolute dates with time (inherits timezone from 'now' parameter):
14
+ # 2024-01-01 10:30 => Time object
15
+ # 2024-01-01T10:30 => Time object
16
+ # 2024-01-01 10:30:45 => Time object with seconds
17
+ # 2024-1-1 9:5 => Time object (relaxed format)
18
+ #
19
+ # Absolute dates with explicit timezone:
20
+ # 2024-01-01T10:30Z => Time object in UTC
21
+ # 2024-01-01T10:30+05:30 => Time object with timezone
22
+ # 2024-01-01T10:30-08:00 => Time object with timezone
23
+ # 2024-01-01T10:30-0800 => Time object (compact timezone)
24
+ # 2024-01-01T10:30-08 => Time object (short timezone)
25
+ #
26
+ # Time only (uses current date from 'now' parameter):
27
+ # 1:34 => Time object (24-hour format)
28
+ # 23:59 => Time object (24-hour format)
29
+ # 10a, 10am => Time object (10 AM)
30
+ # 10p, 10pm => Time object (10 PM)
31
+ # 12:34a => Time object (12:34 AM, converts 12 to 0)
32
+ # 12:45p => Time object (12:45 PM, keeps 12)
33
+ # 0:34a => Time object (12:34 AM)
34
+ # 0p => Time object (12:00 PM, converts 0 to 12)
35
+ #
36
+ # Relative dates (going back in time from 'now'):
37
+ # 1d => Date 1 day ago
38
+ # 7d => Date 7 days ago
39
+ # 1w => Date 1 week (7 days) ago
40
+ # 2w => Date 2 weeks (14 days) ago
41
+ # 1m => Date 1 month ago
42
+ # 6m => Date 6 months ago
43
+ # 1y => Date 1 year (12 months) ago
44
+ # 2y => Date 2 years (24 months) ago
45
+ #
46
+ # Relative date combinations:
47
+ # 1y6m => Date 1 year 6 months ago
48
+ # 1y6m2w => Date 1 year 6 months 2 weeks ago
49
+ # 1y6m2w3d => Date 1 year 6 months 2 weeks 3 days ago
50
+ # 6m1w => Date 6 months 1 week ago
51
+ #
52
+ # Relative time (going back from 'now'):
53
+ # 1h => Time 1 hour ago
54
+ # 30M, 30min => Time 30 minutes ago
55
+ # 45s => Time 45 seconds ago
56
+ # 2h30M => Time 2 hours 30 minutes ago
57
+ # 1h30M45s => Time 1 hour 30 minutes 45 seconds ago
58
+ #
59
+ # Mixed relative date and time:
60
+ # 3d2h => Time 3 days 2 hours ago
61
+ # 1d12h30M => Time 1 day 12 hours 30 minutes ago
62
+ # 5d3h15M30s => Time 5 days 3 hours 15 minutes 30 seconds ago
63
+ # 1min2d => Time 2 days 1 minute ago (order doesn't matter)
64
+ # 3w2d4h => Time 3 weeks 2 days 4 hours ago
65
+ #
66
+ # Optional minus prefix (treated same as without):
67
+ # -1d => Same as 1d
68
+ # -3d2h => Same as 3d2h
69
+ #
70
+ # Notes:
71
+ # - Case insensitive for timezone (Z/z) and am/pm suffixes
72
+ # - 'M' = minutes, 'm' = months in relative formats
73
+ # - 12-hour time: 12am = midnight, 12pm = noon
74
+ # - UTC flag converts 'now' to UTC before processing
75
+ # - Invalid formats raise DateArg::Error
76
+
77
+ module DateArg
78
+ RX_DATE = /\A(?<year>\d{4})-(?<month>\d{1,2})-(?<day>\d{1,2})(?<time>[ T](?<hour>\d{1,2}):(?<min>\d{1,2})(?::(?<sec>\d{1,2}))?(?<zone>Z|[+-]\d{2}(?::?\d{2})?)?)?\z/i # mix of iso8601 and rfc3339 with some allowances for lazy typists¯\_(ツ)_/¯
79
+ RX_TIME = /\A(?:(?<h>0?\d|1[0-2])(?::(?<m>[0-5]\d))?(?<ampm>[ap]m?)|(?<h>[01]?\d|2[0-3]):(?<m>[0-5]\d))\z/i
80
+ RX_REL_TIME_PART = /(\d+)([hMs]|min)/
81
+ RX_REL_DATE_PART = /(\d+)([ywd]|m(?!in))/
82
+ RX_REL_DATE = /\A-?(#{RX_REL_DATE_PART}|#{RX_REL_TIME_PART})+\z/ # 5y7m4d style (before now); allow optional - in case -23d reads more intuitively than 23d (in the past), but treat both the same
83
+
84
+ class Error < StandardError; end
85
+
86
+ def self.parse(str, utc = false, now = Time.now)
87
+ now = now.utc if utc
88
+ case str
89
+ in RX_DATE if $~[:zone] then Time.new(*$~.values_at(:year, :month, :day, :hour, :min, :sec), $~[:zone].then{|z| z == ?z ? ?Z : z })
90
+ in RX_DATE if $~[:time] then Time.new(*$~.values_at(:year, :month, :day, :hour, :min, :sec), now.strftime("%z"))
91
+ in RX_DATE then Date.new(*$~.values_at(:year, :month, :day).map(&:to_i))
92
+ in RX_REL_DATE
93
+ date = str.scan(RX_REL_DATE_PART).inject(now.to_date) do |d, (n, u)|
94
+ op = u =~ /[wd]/ ? :- : :<< # - for days, weeks, << for months, years
95
+ n = n.to_i
96
+ n = n * 7 if u == ?w # week = 7d
97
+ n = n * 12 if u == ?y # year = 12mo
98
+ d.send(op, n)
99
+ end
100
+ return date unless RX_REL_TIME_PART =~ str
101
+ t = Time.new(date.year, date.month, date.day, now.hour, now.min, now.sec, now.strftime("%z"))
102
+ str.scan(RX_REL_TIME_PART).inject(t){ |t, (n, u)| t - n.to_i * { s:1, m:60, h:60*60 }[u[0].downcase.to_sym] }
103
+ in RX_TIME
104
+ $~ => h:, m:, ampm:
105
+ m = m.to_i
106
+ h = h.to_i
107
+ h = 0 if ampm =~ /a/i && h == 12
108
+ h += 12 if ampm =~ /p/i && h < 12
109
+ Time.new(now.year, now.month, now.day, h, m, 0, now.strftime("%z"))
110
+ else raise ArgumentError
111
+ end
112
+ rescue ArgumentError # from Time/Date.new too
113
+ raise Error
114
+ end
115
+
116
+ def parse_date(...) = DateArg.parse(...)
117
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Strop::Result
4
+
5
+ # Ensures that the specified option is used alone, without any other options.
6
+ # Raises an OptionError if the label is present alongside other options.
7
+ # Example: result.standalone(:help) # Ensures --help cannot be used with other options
8
+ # raises OptionError if the label is present alongside other options
9
+ def standalone(label)
10
+ labels = opts.map(&:label)
11
+ return unless labels.include?(label) && (labels - [label]).any?
12
+ raise OptionError, "cannot use #{self[label]._name} with other options"
13
+ end
14
+
15
+ # Ensures that options from different groups are not used together.
16
+ # Each group represents a set of related options, the groups are mutually exclusive.
17
+ # Examples:
18
+ # result.incompatible(:verbose, :quiet) # --verbose and --quiet can't be used together
19
+ # result.incompatible([:json, :pretty], :binary, [:xml, :doctype]) # options from each group don't mix with from others
20
+ # raises OptionError if options from different groups are used together
21
+ def incompatible(*groups)
22
+ labels = opts.map(&:label)
23
+ conflicts = groups.map{ [*it].map{ Strop.name_from_symbol it } & labels }.reject(&:empty?)
24
+ return unless conflicts.size > 1
25
+ raise OptionError, "cannot use together: #{conflicts.flatten.map{self[it]._name}.join(', ')}"
26
+ end
27
+
28
+ # Compacts duplicate single-occurrence options by keeping only the last occurrence.
29
+ # Alters the result set in-place.
30
+ # Issues a warning when duplicates are found and removed.
31
+ # Example:
32
+ # $ cmd -a1 -b2 -a3 -b4 -x
33
+ # > result.compact_singles!(:a, :b)
34
+ # # result keeps only -a3 -b4 -x
35
+ def compact_singles!(*labels)
36
+ labels.flatten.filter_map{ (os = opts[[it]]).size > 1 and [it, os] }.each do |label, opts|
37
+ names = opts.map(&:_name).uniq.join(", ")
38
+ warn! "multiple #{names} options specified (last takes precedence)"
39
+ replace self - opts[...-1]
40
+ end
41
+ end
42
+
43
+ # Error when --opt= (empty value, like --opt='')
44
+ # Does not affect nils (--opt, no arg)
45
+ # when called with no args, affects all opts
46
+ def disallow_empty(*labels)
47
+ labels = labels.flatten.map{ Strop.name_from_symbol it }
48
+ candidates = labels.empty? ? opts : labels.flat_map{ opts[[it]] }
49
+ opt = candidates.find{ it.value == "" } or return
50
+ raise OptionError, "value for #{opt._name} cannot be empty"
51
+ end
52
+
53
+ end
54
+
55
+
56
+ class Strop::Opt
57
+ def _name = Strop.prefix name # shorthand for prefix
58
+ end
59
+
60
+
61
+ class Strop::Optlist
62
+ def report_usage(chars = nil)
63
+ x = true # flipper for stagger
64
+ r = ->s{ Rainbow(s) }
65
+ chars = chars&.chars || (?0..?z).to_a.grep(/[^\W_]/)
66
+ chars = chars.sort_by{|c| [c =~ /\d/ || -1, c.downcase, c =~ /[[:upper:]]/ || 1] } # A, a, B, ... 9
67
+ chars = chars.map{|c| [c] << r[c].then{ self[c]&.label ? it.black.bright : it.green.bright }}
68
+ longs = chars.map{|c, cc| l = self[c]&.label; [r["-"].black.bright, cc, r[(" --#{l}" if l)].blue].join }
69
+ longs = longs.map{ it.sub((x=!x) ? /^/ : / --/, ' \0') } # stagger
70
+ w = longs.map(&:size).max + 4 # col width
71
+ pc = [16, chars.length].min # results per column
72
+ ll = longs.length
73
+ longs.fill("", ll...((ll + pc - 1) / pc * pc)) # fill to multiple for transpose
74
+ # puts self
75
+ puts chars.map(&:last).join(" ")
76
+ puts
77
+ puts longs.map{ "%-#{w}s" % it }.each_slice(pc).to_a.transpose.map(&:join).join("\n")
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imsg-grep
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: darwin
6
+ authors:
7
+ - Caio Chassot
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rainbow
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: strop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: concurrent-ruby
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.3'
82
+ - !ruby/object:Gem::Dependency
83
+ name: minitest
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '5.26'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '5.26'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ email:
111
+ - dev@caiochassot.com
112
+ executables:
113
+ - imsg-grep
114
+ extensions:
115
+ - ext/extconf.rb
116
+ extra_rdoc_files: []
117
+ files:
118
+ - LICENSE
119
+ - README.md
120
+ - bin/img2png
121
+ - bin/imsg-grep
122
+ - bin/msg-info
123
+ - bin/sql-shell
124
+ - doc/HELP
125
+ - doc/HELP_DATES
126
+ - ext/extconf.rb
127
+ - ext/img2png.swift
128
+ - lib/imsg-grep/VERSION
129
+ - lib/imsg-grep/apple/attr_str.rb
130
+ - lib/imsg-grep/apple/bplist.rb
131
+ - lib/imsg-grep/apple/keyed_archive.rb
132
+ - lib/imsg-grep/dev/print_query.rb
133
+ - lib/imsg-grep/dev/timer.rb
134
+ - lib/imsg-grep/images/imaginator.rb
135
+ - lib/imsg-grep/images/img2png.dylib
136
+ - lib/imsg-grep/images/img2png.rb
137
+ - lib/imsg-grep/messages.rb
138
+ - lib/imsg-grep/utils/date.rb
139
+ - lib/imsg-grep/utils/strop_utils.rb
140
+ homepage: https://github.com/kch/imsg-grep
141
+ licenses:
142
+ - MIT
143
+ metadata: {}
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 3.4.0
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 4.0.0
159
+ specification_version: 4
160
+ summary: iMessage database search
161
+ test_files: []