1 # Copyright (C) 2021 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
4 # PublicInbox::Eml to (optionally colorized) text coverter for terminals
5 # the non-HTML counterpart to PublicInbox::View
6 package PublicInbox::LeiViewText;
9 use PublicInbox::MsgIter qw(msg_part_text);
10 use PublicInbox::MID qw(references);
11 use PublicInbox::View;
12 use PublicInbox::Hval;
13 use PublicInbox::ViewDiff;
14 use PublicInbox::Spawn qw(popen_rd);
17 use PublicInbox::Address;
20 # xhtml_map works since we don't search for HTML ([&<>'"])
21 $_[0] =~ s/([\x7f\x00-\x1f])/$PublicInbox::Hval::xhtml_map{$1}/sge;
25 # mutt names, loaded from ~/.config/lei/config
28 status => 'bright_cyan', # smsg stuff
29 attachment => 'bright_red',
31 # git names and defaults, falls back to ~/.gitconfig
40 my $COLOR = qr/(?:bright)?
41 (?:normal|black|red|green|yellow|blue|magenta|cyan|white)/x;
44 my ($self, $slot, $buf) = @_;
45 my $val = $self->{"color.$slot"} //=
46 $self->{-leicfg}->{"color.$slot"} //
47 $self->{-gitcfg}->{"color.diff.$slot"} //
48 $self->{-gitcfg}->{"diff.color.$slot"} //
49 $DEFAULT_COLOR{$slot};
50 $val = $val->[-1] if ref($val) eq 'ARRAY';
53 # git doesn't use "_", Term::ANSIColor does
54 $val =~ s/\Abright([^_])/bright_$1/ig;
56 # git: "green black" => T::A: "green on_black"
57 $val =~ s/($COLOR)(.+?)($COLOR)/$1$2on_$3/;
59 # FIXME: convert git #XXXXXX to T::A-compatible colors
60 # for 256-color terminals
62 ${$self->{obuf}} .= colored($buf, $val);
64 ${$self->{obuf}} .= $buf;
68 sub uncolored { ${$_[0]->{obuf}} .= $_[2] }
71 my ($cls, $lei, $fmt) = @_;
72 my $self = bless { %{$lei->{opt}}, -colored => \&uncolored }, $cls;
73 $self->{-quote_reply} = 1 if $fmt eq 'reply';
74 return $self unless $self->{color} //= -t $lei->{1};
75 my $cmd = [ qw(git config -z --includes -l) ];
76 my ($r, $pid) = popen_rd($cmd, undef, { 2 => $lei->{2} });
77 my $cfg = PublicInbox::Config::config_fh_parse($r, "\0", "\n");
80 $lei->err("# git-config failed, no color (non-fatal)");
83 $self->{-colored} = \&my_colored;
84 $self->{-gitcfg} = $cfg;
85 $self->{-leicfg} = $lei->{cfg};
89 sub quote_hdr_buf ($$) {
90 my ($self, $eml) = @_;
92 my $to = $eml->header_raw('Reply-To') //
93 $eml->header_raw('From') //
94 $eml->header_raw('Sender');
96 for my $f (qw(To Cc)) {
97 for my $v ($eml->header_raw($f)) {
103 PublicInbox::View::fold_addresses($to);
104 PublicInbox::View::fold_addresses($cc);
107 $hbuf .= "To: $to\n" if defined $to && $to =~ /\S/;
108 $hbuf .= "Cc: $cc\n" if $cc =~ /\S/;
109 my $s = $eml->header_str('Subject') // 'your mail';
111 substr($s, 0, 0, 'Re: ') if $s !~ /\bRe:/i;
112 $hbuf .= "Subject: $s\n";
113 if (defined(my $irt = $eml->header_raw('Message-ID'))) {
115 $hbuf .= "In-Reply-To: $irt\n";
117 $self->{-colored}->($self, 'hdrdefault', $hbuf);
118 my ($n) = PublicInbox::Address::names($eml->header_str('From') //
119 $eml->header_str('Sender') //
120 $eml->header_str('Reply-To') //
122 my $d = $eml->header_raw('Date') // 'some unknown date';
125 ${delete $self->{obuf}} . "\nOn $d, $n wrote:\n";
129 my ($self, $eml) = @_;
131 for my $f (qw(From To Cc)) {
132 for my $v ($eml->header($f)) {
134 PublicInbox::View::fold_addresses($v);
139 for my $f (qw(Subject Date Newsgroups Message-ID X-Message-ID)) {
140 for my $v ($eml->header($f)) {
145 if (my @irt = $eml->header_raw('In-Reply-To')) {
148 $hbuf .= "In-Reply-To: $v\n";
151 my $refs = references($eml);
152 if (defined(my $irt = pop @$refs)) {
154 $hbuf .= "In-Reply-To: <$irt>\n";
157 my $max = $self->{-max_cols};
158 $hbuf .= 'References: ' .
159 join("\n\t", map { '<'._xs($_).'>' } @$refs) .
163 $self->{-colored}->($self, 'hdrdefault', $hbuf .= "\n");
166 sub attach_note ($$$$;$) {
167 my ($self, $ct, $p, $fn, $err) = @_;
168 my ($part, $depth, $idx) = @$p;
169 my $nl = $idx eq '1' ? '' : "\n"; # like join("\n", ...)
170 my $abuf = $err ? <<EOF : '';
171 [-- Warning: decoded text below may be mangled, UTF-8 assumed --]
173 $abuf .= "[-- Attachment #$idx: ";
175 my $size = length($part->body);
176 my $ts = "Type: $ct, Size: $size bytes";
177 my $d = $part->header('Content-Description') // $fn // '';
179 $abuf .= $d eq '' ? "$ts --]\n" : "$d --]\n[-- $ts --]\n";
180 if (my $blob = $self->{-smsg}->{blob}) {
181 $abuf .= "[-- lei blob $blob:$idx --]\n";
183 $self->{-colored}->($self, 'attachment', $abuf);
184 hdr_buf($self, $part) if $part->{is_submsg};
187 sub flush_text_diff ($$) {
188 my ($self, $cur) = @_;
189 my @top = split($PublicInbox::ViewDiff::EXTRACT_DIFFS, $$cur);
190 undef $$cur; # free memory
192 my $obuf = $self->{obuf};
193 my $colored = $self->{-colored};
194 while (defined(my $x = shift @top)) {
195 if (scalar(@top) >= 4 &&
196 $top[1] =~ $PublicInbox::ViewDiff::IS_OID &&
197 $top[0] =~ $PublicInbox::ViewDiff::IS_OID) {
200 $colored->($self, 'meta', $x);
202 # Quiet "Complex regular subexpression recursion limit"
203 # warning. Perl will truncate matches upon hitting
204 # that limit, giving us more (and shorter) scalars than
205 # would be ideal, but otherwise it's harmless.
207 # We could replace the `+' metacharacter with `{1,100}'
208 # to limit the matches ourselves to 100, but we can
209 # let Perl do it for us, quietly.
210 no warnings 'regexp';
212 for my $s (split(/((?:(?:^\+[^\n]*\n)+)|
214 (?:^@@ [^\n]+\n))/xsm, $x)) {
215 if (!defined($dctx)) {
216 ${$self->{obuf}} .= $s;
217 } elsif ($s =~ s/\A(@@ \S+ \S+ @@\s*)//) {
218 $colored->($self, 'frag', $1);
219 $colored->($self, 'func', $s);
220 } elsif ($s =~ /\A\+/) {
221 $colored->($self, 'new', $s);
222 } elsif ($s =~ /\A-- $/sm) { # email sig starts
224 ${$self->{obuf}} .= $s;
225 } elsif ($s =~ /\A-/) {
226 $colored->($self, 'old', $s);
228 $colored->($self, 'context', $s);
232 ${$self->{obuf}} .= $x;
237 sub add_text_buf { # callback for Eml->each_part
239 my ($part, $depth, $idx) = @$p;
240 my $ct = $part->content_type || 'text/plain';
241 my $fn = $part->filename;
242 my ($s, $err) = msg_part_text($part, $ct);
243 return attach_note($self, $ct, $p, $fn) unless defined $s;
244 hdr_buf($self, $part) if $part->{is_submsg};
247 $s .= "\n" unless substr($s, -1, 1) eq "\n";
248 my $diff = ($s =~ /^--- [^\n]+\n\+{3} [^\n]+\n@@ /ms);
249 my @sections = PublicInbox::MsgIter::split_quotes($s);
250 undef $s; # free memory
251 if (defined($fn) || ($depth > 0 && !$part->{is_submsg}) || $err) {
252 # badly-encoded message with $err? tell the world about it!
253 attach_note($self, $ct, $p, $fn, $err);
254 ${$self->{obuf}} .= "\n";
256 my $colored = $self->{-colored};
257 for my $cur (@sections) {
259 $colored->($self, 'quoted', $cur);
261 flush_text_diff($self, \$cur);
263 ${$self->{obuf}} .= $cur;
265 undef $cur; # free memory
269 # returns a stringref suitable for $lei->out or print
271 my ($self, $smsg, $eml) = @_;
272 local $Term::ANSIColor::EACHLINE = "\n";
273 $self->{obuf} = \(my $obuf = '');
274 $self->{-smsg} = $smsg;
275 $self->{-max_cols} = ($self->{columns} //= 80) - 8; # for header wrap
277 if ($self->{-quote_reply}) {
278 my $blob = $smsg->{blob} // 'unknown-blob';
279 my $pct = $smsg->{pct} // 'unknown';
280 my $t = POSIX::asctime(gmtime($smsg->{ts} // $smsg->{ds} // 0));
281 $h->[0] = "From $blob\@$pct $t";
283 for my $f (qw(blob pct)) {
284 push @$h, "$f:$smsg->{$f}" if defined $smsg->{$f};
286 @$h = ("# @$h\n") if @$h;
287 for my $f (qw(kw L)) {
288 my $v = $smsg->{$f} or next;
289 push @$h, "# $f:".join(',', @$v)."\n" if @$v;
293 $self->{-colored}->($self, 'status', $h);
295 if ($self->{-quote_reply}) {
296 $quote_hdr = ${delete $self->{obuf}};
297 $quote_hdr .= quote_hdr_buf($self, $eml);
299 hdr_buf($self, $eml);
301 $eml->each_part(\&add_text_buf, $self, 1);
302 if (defined $quote_hdr) {
303 ${$self->{obuf}} =~ s/^/> /sgm;
304 substr(${$self->{obuf}}, 0, 0, $quote_hdr);
306 delete $self->{obuf};