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.
data/bin/imsg-grep ADDED
@@ -0,0 +1,749 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # Search and filter iMessage database entries with regex patterns, date ranges, and participant filters
4
+
5
+ require "bundler/setup"
6
+
7
+ require "date"
8
+ require "json"
9
+ require "sqlite3"
10
+ require "rainbow"
11
+ require "io/console"
12
+ require "strop"
13
+
14
+ require_relative "../lib/imsg-grep/utils/date"
15
+ require_relative "../lib/imsg-grep/utils/strop_utils"
16
+ require_relative "../lib/imsg-grep/messages"
17
+
18
+
19
+ include Strop::Exports
20
+
21
+ ### initial values and defaults
22
+ PROG = File.basename($0)
23
+ @utc = false
24
+ @case = :smart
25
+ @mode = :regexp
26
+ @exact = false
27
+ @negate = false
28
+ @next_bool = nil
29
+ @conditions = []
30
+
31
+ class Cond < Array # basically a tuple with named accessors for positions
32
+ %i[ key term bin_op not_op opt ].zip(0..).each do |m, i|
33
+ define_method(m){ self[i] }
34
+ define_method("#{m}="){|v| self[i] = v }
35
+ end
36
+ end
37
+
38
+ class << @conditions
39
+ def on(key) = filter{|k,| k == key }
40
+ def atomics(key) = filter{|k,_,b,n,| k == key && b.nil? && !n } # conds without a connective operator
41
+ def positive(key) = filter{|k,_,_,n,| k == key && !n } # conds without negation
42
+ end
43
+
44
+ ### tiny helpers
45
+
46
+ def consume(ivarname) = instance_variable_get(ivarname).tap{ instance_variable_set(ivarname, nil) }
47
+
48
+ # wrap and indent text, respecting SGR sequences; only if tty
49
+ def wrap(s, indent=0, maxw = IO.console.winsize[1] - 2, disable: (!$stdout.tty?||@no_wrap), wrap_all: false)
50
+ return s if disable
51
+ sgr = /\e\[[\d;]*m/
52
+ pad = "\e[#{indent}C"
53
+
54
+ s.lines.map(&:chomp).each_with_object([]) do |line, acc|
55
+ next acc << pad+line if line =~ %r[^#{sgr}*https?://\S+\z] unless wrap_all # don't wrap link lines
56
+ w = maxw - indent
57
+ line.scan(sgr) { $~.begin(0) > w ? break : w += it.bytesize } # increase w to account for SGR
58
+ ix = (line.rindex(" ", w) || w-1 if line.size > w) # find last space before w, or use w, or nil when short enough
59
+ acc << pad + line.slice!(..ix).chomp(" ")
60
+ redo unless line.empty?
61
+ end*?\n
62
+ end
63
+
64
+ def info!(s) = $stderr.puts("#{PROG}: #{s}")
65
+ def err!(s) = info!("error: #{s}")
66
+ def warn!(s) = (info!("warning: #{s}") unless @no_warn)
67
+
68
+ ################################################################################
69
+ ### parse helpers ##############################################################
70
+ ################################################################################
71
+
72
+ def parse_date(str, utc = @utc)
73
+ DateArg.parse(str, utc)
74
+ rescue DateArg::Error
75
+ raise OptionError, "invalid date #{str} (format: yyyy-mm-dd or 1y4m2d; see --help-dates)"
76
+ end
77
+
78
+ def parse_p_int(str)
79
+ Integer(str).tap{ raise ArgumentError unless it > 0 }
80
+ rescue ArgumentError
81
+ raise OptionError, "not a positive integer: #{str}"
82
+ end
83
+
84
+ def parse_capture(str)
85
+ case str
86
+ when nil then '\0'
87
+ when /\A\d(,\d)*\z/ then str.gsub(/(?=\d)/, ?\\).gsub(",", " ") # 1 or 1,2; "1,2" => "\1 \2"; 0 == $&
88
+ when /(?<!\\)\\[&\d`'&+]/ then str
89
+ else raise OptionError, "invalid capture expression: #{str} (format: '1,2' or '\\1 \\2')"
90
+ end
91
+ end
92
+
93
+ def rx_abbr(pat) = /^#{pat.chars.map{ Regexp.escape it }.join(".{,8}?")}/i
94
+
95
+ def parse_service(str)
96
+ srvs = %w[ any imessage rcs sms ]
97
+ str.split(?,).map do |s|
98
+ m = srvs.grep(rx_abbr s).first # safe to assume .first as /^./ all distinct
99
+ # warn but let through, if \W, assume regexp and dont warn
100
+ warn! "unknown service: #{s} (will try to match anyway)" if m.nil? && s !~ /\W/
101
+ m += ?$ if srvs.include? m # full match for known services
102
+ m ||= s # fallback to str if unknown
103
+ m = "" if m == "any" # empty str will match anything
104
+ m
105
+ end.join(?|).then{ /^(?:#{it})/i }
106
+ end
107
+
108
+ # LIKE is too much of a mess. ASCII-insensitive, only sensitive option is GLOB.
109
+ # We'll be regexing everything, even literals, at maybe some perf cost.
110
+ def mk_regexp(src)
111
+ pattern = src
112
+ case_ = @case
113
+ case_ = /\p{Upper}/ =~ pattern ? :sensitive : :ignore if case_ == :smart
114
+ pattern = Regexp.escape(pattern) if @mode == :literal
115
+ pattern = "\\A#{pattern}\\z" if consume :@exact
116
+ flags = [Regexp::IGNORECASE] if case_ == :ignore
117
+ Regexp.new(pattern, *flags)
118
+ rescue RegexpError => e
119
+ raise OptionError, "invalid regular expression: #{src} (#{e.message})"
120
+ end
121
+
122
+ def add_cond(key, term, negate: false, via:)
123
+ bin_op = consume(:@next_bool)
124
+ if bin_op
125
+ raise OptionError, "no previous condition to apply --#{bin_op} to" unless @conditions.last
126
+ @conditions.last.bin_op ||= "" # generates no sql but evals true
127
+ end
128
+ @conditions << Cond[key, term, bin_op, (:not if negate), via]
129
+ end
130
+
131
+ def repeat_cond(term, negate: false, via:)
132
+ last = @conditions.last
133
+ raise OptionError, "no previous condition to apply --#{@next_bool} to" unless last
134
+ raise OptionError, "--#{@next_bool} attemped with non-pattern condition #{last.opt._name}" unless Regexp === last.term
135
+ add_cond last.key, term, negate:, via:
136
+ end
137
+
138
+ def next_bool op
139
+ raise OptionError, "--#{@next_bool} with no argument followed by --#{op}" if @next_bool
140
+ @next_bool = op
141
+ end
142
+
143
+ ################################################################################
144
+ ### begin option parsing #######################################################
145
+ ################################################################################
146
+ begin
147
+ def read_doc(f) = IO.read(__dir__ + "/../doc/#{f}")
148
+ HELP = read_doc("HELP").gsub("PROG", PROG)
149
+ HELP_OPTS = HELP[/.*?(?=~~)/m].strip
150
+ optlist = Strop.parse_help HELP_OPTS
151
+ optlist << Optdecl[:D, :debug] # hidden opt
152
+ optlist << Optdecl[:opts?] if File.directory? __dir__ + "/../.git" # only available during development
153
+
154
+ # custom opt shorthands for max and relative dates: -1 == -n1, -1d == -d1d
155
+ # strop can't handle this (yet?) so we transform ahead
156
+ argvl, argvr = [ARGV, ARGV.index("--")].then{|a, i| [a[...i], i ? a[i..] : []] }
157
+ argvl.map! do |x|
158
+ x =~ /\A-((\d+)|(#{DateArg::RX_REL_DATE_PART}|#{DateArg::RX_REL_TIME_PART}))\z/
159
+ case [$2, $3]
160
+ in String, nil then "-n#{$1}"
161
+ in nil, String then "-d#{$1}"
162
+ in nil, nil then x
163
+ end
164
+ end
165
+
166
+ result = Strop.parse optlist, argvl + argvr
167
+
168
+ (puts HELP_OPTS; exit 1) if result.empty?
169
+
170
+ capture_opts = %w[ capture ]
171
+ text_opts = %w[ no-meta no-wrap count preview img tiny short-names urls color count ]
172
+ json_opts = %w[ json payload ]
173
+ result.incompatible json_opts, text_opts
174
+ result.incompatible json_opts, capture_opts
175
+ result.incompatible :no_wrap, :preview
176
+ result.incompatible %w[ one-line tiny no-meta ], %w[ list-files ]
177
+
178
+ result.compact_singles! %w[ max capture ]
179
+
180
+ result.disallow_empty # for all
181
+
182
+ for opt in result
183
+ case opt
184
+ in Sep # ignore; `arg:` will handle .rest just fine
185
+ in label: "since", value: then add_cond :since, parse_date(value), via: opt
186
+ in label: "until", value: then add_cond :until, parse_date(value), via: opt
187
+ in label: "to", value: then add_cond :to, mk_regexp(value), negate: consume(:@negate), via: opt
188
+ in label: "from", value: then add_cond :from, mk_regexp(value), negate: consume(:@negate), via: opt
189
+ in label: "with", value: then add_cond :with, mk_regexp(value), negate: consume(:@negate), via: opt
190
+ in label: "chat", value:nil then add_cond :chat, true, negate: consume(:@negate), via: opt
191
+ in label: "chat", value: then add_cond :chat, mk_regexp(value), negate: consume(:@negate), via: opt
192
+ in label: "sent" then add_cond :from_me, true, via: opt
193
+ in label: "received" then add_cond :from_me, false, via: opt
194
+ in label: "links", value:nil then add_cond :links, true, via: opt
195
+ in label: "links", value: then add_cond :links, mk_regexp(value), negate: consume(:@negate), via: opt
196
+ in label: "youtube" then add_cond :links, :youtube, via: opt
197
+ in label: "soundcloud" then add_cond :links, :soundcloud, via: opt
198
+ in label: "twitter" then add_cond :links, :twitter, via: opt
199
+ in label: "service", value: then srv = parse_service(value) and add_cond :service, srv, via: opt
200
+ in label: "and", value:nil then next_bool :and
201
+ in label: "or", value:nil then next_bool :or
202
+ in label: "and", value: then next_bool :and; repeat_cond mk_regexp(value), negate: consume(:@negate), via: opt
203
+ in label: "or", value: then next_bool :or; repeat_cond mk_regexp(value), negate: consume(:@negate), via: opt
204
+ in label: "smart-case" then @case = :smart
205
+ in label: "ignore-case" then @case = :ignore
206
+ in label: "no-ignore-case" then @case = :sensitive
207
+ in label: "literal" then @mode = :literal
208
+ in label: "regexp" then @mode = :regexp
209
+ in label: "capture", value: then @capture = parse_capture(value)
210
+ in label: "exact" then @exact = opt
211
+ in label: "invert-match" then @negate = true
212
+ in label: "max", value: then @allow_all = true; @max = parse_p_int(value)
213
+ in label: "all" then @allow_all = true
214
+ in label: "urls" then @urls = true
215
+ in label: "no-wrap" then @no_wrap = true
216
+ in label: "one-line" then @one_line = true
217
+ in label: "count" then @count = true
218
+ in label: "reverse" then @reverse = true
219
+ in label: "utc" then @utc = true
220
+ in label: "json" then @json = true
221
+ in label: "no-meta" then @no_meta = true; @list_files = false
222
+ in label: "short-names" then @short_names = true
223
+ in label: "payload" then @json = @payload = true
224
+ in label: "tiny" then @short_names = @urls = @one_line = true
225
+ in label: "list-files" then @list_files = true
226
+ in label: "no-list-files" then @list_files = false
227
+ in label: "color" then Rainbow.enabled = opt.yes?
228
+ in label: "img" then @preview ||= []; @preview|= ["images"]; add_cond :files, :images, via: opt
229
+ in label: "silence-warnings" then @no_warn = true
230
+ in label: "version" then puts "imsg-grep v#{ IO.read("#{__dir__}/../lib/imsg-grep/VERSION").chomp }"; exit
231
+ in label: "debug" then @debug = true
232
+ in label: "opts", value: then optlist.report_usage(value); exit # dev stuff: Used to see what options are still available
233
+
234
+ in label: "help-dates" then puts read_doc("HELP_DATES"); exit
235
+ in label: "help" then
236
+ long = opt.name != ?h || result[[:help]].size > 1 # ! -h || -hh
237
+ if long
238
+ puts HELP.sub(/^~~.*\n/, '')
239
+ puts "\nFor a shorter help summary, use -h"
240
+ else
241
+ puts HELP_OPTS
242
+ puts "\nSee --help for more"
243
+ end
244
+ exit
245
+
246
+ in label: "preview", value:
247
+ previewables = %w[ images links ]
248
+ next @preview = previewables if value.nil?
249
+ @preview = value.split(?,).map do |v|
250
+ previewables.grep(rx_abbr v).first or raise OptionError, "invalid preview target: #{v}"
251
+ end
252
+
253
+ in label: "files", value:
254
+ value = "any" if value.nil?
255
+ value.split(/(?!<\\),/).map{ it.split(?:, 2) }.each do |spec|
256
+ _add_cond = ->cond{ add_cond :files, cond, negate: consume(:@negate), via: opt }
257
+ case spec
258
+ in [/^a(ny)?$/] then _add_cond.call :any
259
+ in [/^i(mg?|mages?)?$/] then _add_cond.call :images
260
+ in [/^n(ame)?$/, v] then _add_cond.call [:name, mk_regexp(v)]
261
+ in [/^m(ime)?$/, v] then _add_cond.call [:mime, mk_regexp(v)]
262
+ in [/^e(xt)?$/, v] then _add_cond.call [:ext, mk_regexp(v)]
263
+ else raise OptionError, "invalid file specifier: #{[*spec]*?:} (in #{value})"
264
+ end
265
+ end
266
+
267
+ in Arg(arg: _) | Opt(label: "message", value: _)
268
+ add_cond :message, mk_regexp(opt.value), negate: consume(:@negate), via: opt
269
+
270
+ in label: "reset"
271
+ Messages.reset_cache
272
+ exit if (result.opts.map(&:label) - ["reset"]).empty? && result.args.empty? # exit if opt used alone
273
+
274
+ end
275
+ end
276
+
277
+ @list_files = !@one_line && !@json if @list_files.nil?
278
+
279
+ def preview?(kind=nil) = kind ? @preview&.include?(kind.to_s) : @preview&.any?
280
+
281
+
282
+ ################################################################################
283
+ ### checking conflicting options ###############################################
284
+ ################################################################################
285
+
286
+ if @conditions.empty? && !@allow_all
287
+ raise OptionError, "no filter conditions given (use --all or --max to allow)" # too much stuff
288
+ end
289
+
290
+ if @conditions.atomics(:from_me).map(&:term).uniq.size > 1
291
+ raise OptionError, "cannot use --sent and --received together" # empty result
292
+ end
293
+
294
+ # warn multiple dates
295
+ d0, d1 = ds = [@conditions.atomics(:since), @conditions.atomics(:until)]
296
+ warn! "multiple since dates given (most recent date wins)" if d0.size > 1
297
+ warn! "multiple until dates given (least recent date wins)" if d1.size > 1
298
+
299
+ # try to warn date conflicts: since > until
300
+ begin # if a mix of Time & Date, sorting/comparison will fail, so we just bail
301
+ d0, d1 = ds.map{ it.map(&:term).sort }
302
+ d0, d1 = d0.last, d1.first
303
+ bad_dates = d0 && d1 && d0 > d1
304
+ rescue ArgumentError
305
+ bad_dates = nil
306
+ end
307
+ raise OptionError, "since date must be before until date" if bad_dates
308
+
309
+ # ignore request for all links when already getting more specific site links
310
+ # -L(noarg) + -[YSX]|Larg, kinda useless
311
+ any_links, site_links = @conditions.atomics(:links).partition{ it.term == true }
312
+ if any_links.any? && site_links.any?
313
+ any_links.each{ @conditions.delete it }
314
+ overrides = site_links.map{ [it.opt._name, it.opt.value].compact*' ' }
315
+ warn! "ignoring #{any_links[0].opt._name} (overriden by #{overrides.join(', ')})"
316
+ end
317
+
318
+ # require mag pattern for -o
319
+ # warn -o with multiple msg patterns
320
+ # ignore -v patterns
321
+ if @capture
322
+ aconds = @conditions.atomics(:message)
323
+ *pats, @capture_pat = aconds.map(&:term) # take last pattern for capture refs, rest for warn
324
+ bool_note = (@conditions.on(:message) - aconds).any?
325
+ raise OptionError, "#{result[:capture]._name} requires a message pattern#{' outside boolean expressions' if bool_note}" unless @capture_pat
326
+ warn! "#{result[:capture]._name} used with multiple message patterns (will capture from last)" unless pats.empty?
327
+ end
328
+
329
+ @negate and warn! "last #{result[[:invert_match]].last._name} disregarded (not followed by pattern)"
330
+ @exact and warn! "last #{result[[:exact]].last._name} disregarded (not followed by pattern)"
331
+ @next_bool and warn! "last #{result.opts.reverse_each.find{ %w[ and or ].include? it.label }._name} disregarded (not followed by pattern)"
332
+
333
+ require_relative "../lib/imsg-grep/images/imaginator" if preview?
334
+ if preview? && !Imaginator.image_tooling?
335
+ @preview = nil
336
+ e = "previews disabled: "
337
+ case
338
+ when !Imaginator::EXTENSION_AVAILABLE then warn! e + "img2png extension not compiled"
339
+ when !Imaginator.term_image_protocol then warn! e + "no terminal support for images"
340
+ when !Imaginator.cell_size then warn! e + "cannot determine cell dimensions"
341
+ end
342
+ end
343
+
344
+ ################################################################################
345
+ ### build sql query ############################################################
346
+ ################################################################################
347
+
348
+ def date_cmp(op, dt)
349
+ case [dt, @utc]
350
+ in Date, true then "utc_date #{op} '#{dt.strftime('%Y-%m-%d')}'"
351
+ in Date, false then "local_date #{op} '#{dt.strftime('%Y-%m-%d')}'"
352
+ in Time, _ then "unix_time #{op} #{dt.to_i}"
353
+ end
354
+ end
355
+
356
+ def match(x, r) = "#{x} REGEXP '#{SQLite3::Database.quote r.to_s}'"
357
+ def match_searchable(x, r) = "EXISTS (SELECT 1 FROM json_each(#{x}) WHERE #{match(:value, r)})"
358
+
359
+ groups = @conditions.slice_before{ !(Symbol === it.bin_op) }
360
+
361
+ default_or = %i[ links service from chat files ] # these conds do OR by default (unless negated)
362
+ singles, grouped = groups.partition{|group| group in [[_, _, nil, *]] } # singles = a group with single elem, no bin op
363
+ keyed_singles = singles.flatten(1).group_by(&:first)
364
+ keyed_singles.each do |k, group|
365
+ op = default_or.include?(k) ? :or : :and
366
+ group[1..]&.each{ it.bin_op = it.not_op ? :and : op } # first member doest get op; negated exprs on base expr always AND
367
+ end
368
+
369
+ # put the easy filters first for performance
370
+ filter_order = %i[ from_me since until service files links ]
371
+ sorted_singles = keyed_singles.sort_by{|k,| filter_order.index(k) || Float::INFINITY }.map(&:last)
372
+ groups = sorted_singles + grouped # put it back together
373
+
374
+ Messages.init
375
+
376
+ @file_filters = []
377
+ if @conditions.on(:files).any?{ it.term in [_, Regexp] }
378
+ Messages.db.ƒ(:filename){ it[(it.rindex(?/) || -1)+1..] }
379
+ Messages.db.ƒ(:fileext) { i = it.rindex(?.) and it[i+1..] or "" }
380
+ def add_file_filter(sel) = "file_filter_#{@file_filters.size}".tap{|as| @file_filters << [sel, as].join(" as ") }
381
+ end
382
+
383
+ sql_cond = groups.map do |group|
384
+ group.map do |key, term, bin_op, not_op|
385
+ sql = case [key, term]
386
+ in :since, _ then date_cmp(:>=, term)
387
+ in :until, _ then date_cmp(:<=, term)
388
+ in :from_me, _ then "is_from_me IS #{term}"
389
+ in :links, true then "link IS NOT NULL"
390
+ in :links, :youtube then match(:link, %r["https?://((www\.)?youtube\.com|youtu\.be)/]i) # matching inside json, " acts as \A sort of
391
+ in :links, :soundcloud then match(:link, %r["https?://(on\.)?soundcloud\.com/]i)
392
+ in :links, :twitter then match(:link, %r["https?://(pic\.)?(twitter|x)\.com/]i)
393
+ in :links, Regexp then match(:link, term)
394
+ in :from, Regexp then match_searchable(:sender_searchable, term)
395
+ in :to, Regexp then match_searchable(:recipients_searchable, term)
396
+ in :with, Regexp then match_searchable(:members_searchable, term)
397
+ in :chat, Regexp then match(:chat_name, term)
398
+ in :chat, (true | false) then "(is_group_chat IS #{'NOT' unless term} TRUE)" # so nulls also match -vc
399
+ in :message, Regexp then match(:text, term)
400
+ in :service, Regexp then match(:service, term)
401
+ in :files, :any then "(has_attachments AND has_files IS TRUE)" # has_attachments first should be a faster check
402
+ in :files, :images then "(has_attachments AND has_images IS TRUE)"
403
+ in :files, [:mime, Regexp => r] then add_file_filter(match("a.mime_type", r)).then{ "#{it} IS TRUE" }
404
+ in :files, [:name, Regexp => r] then add_file_filter(match("filename(a.filename)", r)).then{ "#{it} IS TRUE" }
405
+ in :files, [:ext, Regexp => r] then add_file_filter(match("fileext(a.filename)", r)).then{ "#{it} IS TRUE" }
406
+
407
+ end
408
+ ops = [bin_op, not_op].compact.join(" ").upcase
409
+ sql = "#{ops} #{sql}".lstrip
410
+ end.join("\n ").then{ "(#{it})" }
411
+ end.join("\nAND ")
412
+
413
+ limit = "LIMIT #{@max}" if @max
414
+ time_col = @utc ? :utc_time : :local_time
415
+
416
+ sql = <<~SQL
417
+ WITH attachment_flags AS (
418
+ SELECT
419
+ m.ROWID as message_id,
420
+ #{[*@file_filters, ""].join(",\n")}
421
+ MAX(CASE WHEN a.mime_type LIKE 'image/%' THEN 1 ELSE 0 END) as has_images,
422
+ MAX(a.mime_type IS NOT NULL) as has_files -- this excludes the uti like 'dyn.%' files
423
+ FROM _imsg.message m
424
+ JOIN _imsg.message_attachment_join maj ON m.ROWID = maj.message_id
425
+ JOIN _imsg.attachment a ON maj.attachment_id = a.ROWID
426
+ WHERE m.cache_has_attachments
427
+ GROUP BY m.ROWID
428
+ )
429
+ SELECT
430
+ #{'payload_json,' if @payload}
431
+ m.message_id,
432
+ guid,
433
+ has_attachments,
434
+ af.has_images IS TRUE as has_images,
435
+ af.has_files IS TRUE as has_files,
436
+ sender_name as "from",
437
+ recipient_names as "to",
438
+ is_group_chat,
439
+ chat_name,
440
+ text,
441
+ link,
442
+ service,
443
+ utc_time,
444
+ #{time_col} as time
445
+ FROM message_view m
446
+ LEFT JOIN attachment_flags af ON m.message_id = af.message_id
447
+ #{"WHERE\n" + sql_cond unless sql_cond.empty?}
448
+ ORDER BY unix_time #{@reverse ? 'ASC' : 'DESC'}
449
+ #{limit}
450
+ SQL
451
+
452
+ if @debug
453
+ bat = `command -v bat >/dev/null 2>&1` && $?.success? ? %w[bat -l sql] : "cat"
454
+ IO.popen(bat, 'w', out: :err) { it << sql }
455
+ end
456
+
457
+ slow = unless @no_warn || result[:since] || Messages.db.execute("SELECT 1 FROM _cache.texts LIMIT 1").empty? # no cache is meant to be slow
458
+ Thread.new do
459
+ sleep 1
460
+ info! "hint: taking too long? try --since with a short time distance"
461
+ end
462
+ end
463
+
464
+ messages = Messages.db.select_hashes(sql)
465
+
466
+ if @list_files || preview?(:links)
467
+ ids = messages.select{ it[:has_attachments] == 1 }.map{ it[:message_id] }.join(", ")
468
+ all_files = Messages.db.select_hashes(<<~SQL).group_by{ it[:message_id] }
469
+ SELECT a.ROWID, a.filename, a.mime_type, a.uti, m.ROWID as message_id
470
+ FROM _imsg.attachment a
471
+ JOIN _imsg.message_attachment_join maj ON a.ROWID = maj.attachment_id
472
+ JOIN _imsg.message m ON maj.message_id = m.ROWID
473
+ WHERE m.ROWID IN (#{ids})
474
+ ORDER BY m.ROWID, a.ROWID
475
+ SQL
476
+ messages.each{ it[:files] = all_files[it[:message_id]] || [] }
477
+ end
478
+
479
+
480
+ ################################################################################
481
+ ### Massage results ############################################################
482
+ ################################################################################
483
+
484
+ messages.each do |msg|
485
+ msg[:from] ||= "me"
486
+ msg[:to] = JSON.parse(msg[:to] || '["me"]')
487
+ msg[:link] = JSON.parse(msg[:link] || "null")&.transform_keys(&:to_sym)
488
+ msg[:payload] = JSON.parse(msg.delete(:payload_json) || "null") if msg.key? :payload_json
489
+ msg[:is_group_chat] = msg[:is_group_chat] == 1
490
+ end
491
+
492
+ slow&.kill
493
+
494
+ if @json
495
+ puts JSON.send(@one_line ? :generate : :pretty_generate, messages)
496
+ exit
497
+ end
498
+
499
+ class String
500
+ # 'xxx'.cleave(nil) # => %w[ xxx ]
501
+ # 'a1b2c3d'.cleave(/\d/) # => %w[ a 1 b 2 c 3 d ]
502
+ # (cant use split(/(...)/) bc inner regexen may have captures)
503
+ def cleave(rx, &)
504
+ return [self] unless rx
505
+ r, i = [], 0
506
+ scan(rx) { r << self[i...$~.begin(0)] << $&; i = $~.end(0) }
507
+ r << self[i..]
508
+ end
509
+ end
510
+
511
+ # stylesheets :P
512
+ @c = c = Object.new
513
+ def c.pass(s) = s
514
+ def c.link(s) = Rainbow(s).green
515
+ def c.link!(s) = Rainbow(s).green.bright
516
+ def c.match!(s) = Rainbow(s).magenta.bright
517
+ def c.capture!(s) = Rainbow(s).red.bright
518
+ def c.l_time(s) = Rainbow(s).bright.blue
519
+ def c.l_from(s) = Rainbow(s).yellow
520
+ def c.l_to(s) = Rainbow(s).yellow.bright
521
+ def c.label(s) = Rainbow(s).yellow
522
+ def c.value(s) = Rainbow(s).yellow.bright
523
+ def c.link_title(s) = Rainbow(s).cyan.bright
524
+ def c.link_summary(s) = Rainbow(s).cyan
525
+ def c.count(s) = Rainbow(s).cyan
526
+ def c.files(s) = Rainbow(s).cyan
527
+ def c.file_path_bad(s)= Rainbow(s).red
528
+ def c.file_path(s) = Rainbow(s).cyan.faint
529
+ def c.file_name(s) = Rainbow(s).cyan.bright
530
+
531
+ # to highlight all matches in text, inside links
532
+ m_conds = @conditions.positive(:message).map(&:term)
533
+ l_conds = @conditions.positive(:links).map(&:term).select{ Regexp===it } + m_conds
534
+ rx_highlight = /#{m_conds*?|}/ if m_conds.any?
535
+ rx_link_highlight = /#{l_conds*?|}/ if l_conds.any?
536
+
537
+ def highlight(s, r, a, b)
538
+ return unless s
539
+ s.cleave(r).each_slice(2).flat_map do |sa, sb|
540
+ [ (@c.send(a, sa) if sa && !sa.empty?),
541
+ (@c.send(b, sb) if sb && !sb.empty?)]
542
+ end.join
543
+ end
544
+
545
+
546
+ ################################################################################
547
+ ## link/image preview setup ####################################################
548
+ ################################################################################
549
+ if preview?
550
+ # suppress CFPropertyList warnings on stderr. They're harmless but affect image rendering
551
+ ENV["OS_ACTIVITY_MODE"] = ENV["OS_ACTIVITY_DT_MODE"] = "disable"
552
+
553
+ POOL = Concurrent::FixedThreadPool.new(Concurrent.processor_count - 1)
554
+ cols = IO.console.winsize[1]
555
+ @link_preview_cols = [(cols - 2) / 2, 20].min
556
+ @link_preview_min_rows = 6
557
+ @img_rows = 8
558
+ @link_images = {}
559
+ @images = {}
560
+
561
+ # because we need to retain the imgs for resizing, too many links will use too much memory
562
+ # so instead we release and reload them if need to resize again
563
+ release_links = preview?(:links) && messages.count{it[:link]} > 1000
564
+
565
+ messages.each do |msg|
566
+ preview?(:links) and
567
+ link = msg[:link] and
568
+ @link_images[link] = Concurrent::Promises.future_on(POOL, msg) do |msg|
569
+ ix = link[:image_idx] or next
570
+ file = msg.dig(:files, ix) or next
571
+ path = File.expand_path file[:filename]
572
+ img = Imaginator::Image.new(path).load or next
573
+ fit, fits = img.fit @link_preview_cols, @link_preview_min_rows
574
+ png = img.png_transform w:fit.w, h:fit.h, pad_w:fits[:cfit].pad_w
575
+ if release_links
576
+ img.release
577
+ img = path
578
+ end
579
+ # if safe enough to not release: don't release img yet, might reuse. released after png use
580
+ [fit, png, img]
581
+ end
582
+ msg[:images] = msg[:files].select{ it[:mime_type]&.start_with?('image/') }
583
+ preview?(:images) and msg[:images].each do |im|
584
+ @images[im] = Concurrent::Promises.future_on(POOL, im) do |im|
585
+ path = File.expand_path im[:filename]
586
+ img = Imaginator::Image.new(path).load or next
587
+ fit, = img.fit 25, @img_rows
588
+ png = img.png_transform w:fit.w, h:fit.h
589
+ img.release
590
+ [fit, png]
591
+ end # promise
592
+ end # images
593
+ end # messages
594
+ end # preview
595
+
596
+ ################################################################################
597
+ ### Print messages #############################################################
598
+ ################################################################################
599
+
600
+ for msg in messages
601
+ msg => guid:, from:, to:, chat_name:, text:, link:, time:, is_group_chat:, has_attachments:
602
+ files = msg[:files]
603
+
604
+ if @short_names
605
+ rename = ->s{ s.sub(/^(\p{Alpha}\S*)(?:.*\s+(\p{Alpha})\S*)?(?: \(.+\))?$/){ [$1, $2].compact.join(" ") } }
606
+ from = rename.(from)
607
+ to.map!(&rename)
608
+ chat_name = chat_name&.sub(/^.{8}\K(.+)/m, "…") if @one_line
609
+ end
610
+
611
+ # highlight matches inside urls
612
+ url, ourl = link&.values_at(:url, :original_url)
613
+ curl, courl = [url, ourl].map { highlight(it, rx_link_highlight, :link, :link!) }
614
+
615
+ text = text.to_s.dup
616
+ if @urls && url # -U captured links replace text
617
+ text = curl
618
+ elsif @capture # replace text with eval'd capture output string
619
+ m1 = @capture_pat.match text
620
+ m2 = { ?` => $`, ?' => $', ?& => $&, ?+ => $+ }
621
+ refs = /\\(?:(\d)|([&`'+]))|\\(\\)/ # 3 = \, 2 = `'&+ refs, 1 = digits
622
+ text = @capture.gsub(refs){ $3 || c.capture!(m2[$2] || m1[$1.to_i]) }
623
+ else
624
+ rx_links = (/#{[ourl, url].compact.map{Regexp.escape it}*?|}/ if link)
625
+ has_link = link && text =~ rx_links
626
+ # hightlight matches in text, first replacing links with colored versions from above
627
+ text = text.cleave(rx_links).each_slice(2).flat_map do |s, u|
628
+ msg_text = highlight(s, rx_highlight, :pass, :match!) # hilight the message part that is not link
629
+ msg_url = { ourl => courl, url => curl}[u] # urls highlighted earlier, use that
630
+ [msg_text, msg_url]
631
+ end.join
632
+ # append whole url if not fully present in text (missing protocol etc)
633
+ text << "\n\n" << curl if link && !has_link
634
+ end
635
+
636
+ oneline = ->s { s.gsub(/\p{Space}*\R\p{Space}*/, " ").strip }
637
+ text = oneline[text] if @one_line
638
+
639
+ if has_attachments && text =~ /\A\uFFFC*\z/ # if text is empty or only object marker
640
+ text = @list_files && !@one_line ? "" : c.files("[files]")
641
+ end
642
+
643
+ text = text.gsub(/\uFFFC/, c.files(?░)) if @list_files && has_attachments
644
+
645
+ unless @no_meta
646
+ nn = "\n\n"
647
+ title = (link[:title] if link)
648
+ summary = (link[:summary] if link)
649
+ link_image_result = (@link_images.delete(link)&.value! if link && preview?(:links))
650
+ end
651
+
652
+ if @no_meta # oneline included, already onelined above
653
+ puts text
654
+ elsif @one_line
655
+ text << " #{c.link_summary(oneline[title])}" if title
656
+ to = chat_name.to_s.empty? ? to*', ' : chat_name
657
+ buf = "#{c.l_time time} [#{c.l_from from} -> #{c.l_to to}]: #{text}"
658
+ buf.prepend "#{guid} " if @debug
659
+ puts buf
660
+ else
661
+ buf = {
662
+ GUID: (guid if @debug),
663
+ From: from,
664
+ "To..": (to*', ' unless is_group_chat),
665
+ With: (to*', ' if is_group_chat),
666
+ Chat: chat_name,
667
+ Date: time
668
+ }.reject{|k,v| v.to_s.empty? }.map{|k,v| "#{c.label k}: #{c.value v}" }*?\n << nn
669
+
670
+ puts buf
671
+
672
+ pad = 2
673
+ pad += 1 + @link_preview_cols if link_image_result
674
+
675
+ buf = +""
676
+ buf << wrap(text, pad, wrap_all: !!link_image_result) << nn unless text.empty?
677
+ buf << c.link_title(wrap(title, pad, wrap_all: !!link_image_result)) << nn if title
678
+ buf << c.link_summary(wrap(summary, pad, wrap_all: !!link_image_result)) << nn if summary
679
+
680
+ if link_image_result
681
+ buf_rows = buf.strip.count(?\n) + 1
682
+ img_rows = [buf_rows, @link_preview_min_rows].max
683
+ fit, png, img = link_image_result
684
+ if img_rows != @link_preview_min_rows
685
+ img = Imaginator::Image.new(img).load if img.is_a? String # it's a path, original img released
686
+ fit1, fits = img.fit @link_preview_cols, img_rows
687
+ # if the backround render image is different from what we need now that we know the text size, do it again
688
+ if fit1 != fit
689
+ fit = fit1
690
+ png = img.png_transform w:fit.w, h:fit.h, pad_w:fits[:cfit].pad_w
691
+ end
692
+ end
693
+ img.release unless img.is_a? String
694
+ print " "
695
+ Imaginator.print_image(data: png, c: @link_preview_cols)
696
+ puts
697
+ print "\e[#{fit.r}A" # go up by image rows
698
+ end
699
+
700
+ print buf
701
+ (fit.r - buf_rows).times{ puts } if link_image_result # fill newlines past img
702
+
703
+ if @list_files
704
+ for file in files.reject{ it[:mime_type].nil? }
705
+ path, _, name = file[:filename].rpartition %r_(?<=/)_
706
+ p, q, r = path.rpartition %r_/\KTemporaryItems(?=/)_
707
+ path = c.file_path(p) + c.file_path_bad(q) + c.file_path(r)
708
+ puts " " + path + c.file_name(name) + ?\n
709
+ end
710
+ puts
711
+
712
+ # gotta load them all upfront cause gotta know if the last exists for cursor move
713
+ # deleting helps GC
714
+ images = msg[:images] && msg[:images].map{ @images.delete(it)&.value! }.compact
715
+ if preview?(:images) && images.any?
716
+ maxcols = IO.console.winsize[1] - 2
717
+ (print " "; cols = 2)
718
+ for img_result, ix in images.zip(1..)
719
+ fit, png, img = img_result
720
+ cols += fit.c
721
+ if cols > maxcols
722
+ print "\n" * (fit.r+1)
723
+ (print " "; cols = 2 + fit.c)
724
+ end
725
+ Imaginator.print_image(data: png, r: fit.r)
726
+ (print "\e[#{fit.r - 1}A "; cols += 1) unless ix == images.size # move back up for next image unless last
727
+ end
728
+ puts nn
729
+ end
730
+ end
731
+
732
+ end
733
+ end # for msgs
734
+
735
+ if @count
736
+ n = messages.size
737
+ s = "#{n} message#{?s unless n == 1} found"
738
+ s << " (#{result[:max]._name} used)" if @max && n == @max
739
+ info! c.count s
740
+ end
741
+
742
+ # exiting with code seems to prevent at_exit from running, which will not populate cache, but well, it is what it is. will cache on next match
743
+ exit 1 unless messages.any?
744
+
745
+ rescue OptionError => e
746
+ err! e.message.sub(/^./){ $&.downcase } # strop using upcases but we aren't; not ready to change this on strop yet
747
+ info! "see --help for usage information"
748
+ exit 64 # cf. man 3 sysexits
749
+ end