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
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
|