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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/bin/img2png +0 -0
- data/bin/imsg-grep +749 -0
- data/bin/msg-info +133 -0
- data/bin/sql-shell +25 -0
- data/doc/HELP +181 -0
- data/doc/HELP_DATES +72 -0
- data/ext/extconf.rb +97 -0
- data/ext/img2png.swift +325 -0
- data/lib/imsg-grep/VERSION +1 -0
- data/lib/imsg-grep/apple/attr_str.rb +65 -0
- data/lib/imsg-grep/apple/bplist.rb +257 -0
- data/lib/imsg-grep/apple/keyed_archive.rb +105 -0
- data/lib/imsg-grep/dev/print_query.rb +84 -0
- data/lib/imsg-grep/dev/timer.rb +38 -0
- data/lib/imsg-grep/images/imaginator.rb +135 -0
- data/lib/imsg-grep/images/img2png.dylib +0 -0
- data/lib/imsg-grep/images/img2png.rb +84 -0
- data/lib/imsg-grep/messages.rb +314 -0
- data/lib/imsg-grep/utils/date.rb +117 -0
- data/lib/imsg-grep/utils/strop_utils.rb +79 -0
- metadata +161 -0
|
@@ -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: []
|