]> Sergey Matveev's repositories - public-inbox.git/blob - script/public-inbox-edit
extindex: support --dedupe[=MSGID]
[public-inbox.git] / script / public-inbox-edit
1 #!/usr/bin/perl -w
2 # Copyright (C) 2019-2021 all contributors <meta@public-inbox.org>
3 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
4 #
5 # Used for editing messages in a public-inbox.
6 # Supports v2 inboxes only, for now.
7 use strict;
8 use warnings;
9 use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_abbrev);
10 use PublicInbox::AdminEdit;
11 use File::Temp 0.19 (); # 0.19 for TMPDIR
12 use PublicInbox::ContentHash qw(content_hash);
13 use PublicInbox::MID qw(mid_clean mids);
14 PublicInbox::Admin::check_require('-index');
15 use PublicInbox::Eml;
16 use PublicInbox::InboxWritable qw(eml_from_path);
17 use PublicInbox::Import;
18
19 my $help = <<'EOF';
20 usage: public-inbox-edit -m MESSAGE-ID [--all] [INBOX_DIRS]
21
22   destructively edit messages in a public inbox
23
24 options:
25
26   --all               edit all configured inboxes
27   -m MESSAGE-ID       edit the message with a given Message-ID
28   -F FILE             edit the message matching the contents of FILE
29   --force             forcibly edit even if Message-ID is ambiguous
30   --raw               do not perform "From " line escaping
31
32 See public-inbox-edit(1) man page for full documentation.
33 EOF
34
35 my $opt = { verbose => 1, all => 0, -min_inbox_version => 2, raw => 0 };
36 my @opt = qw(mid|m=s file|F=s raw);
37 GetOptions($opt, @PublicInbox::AdminEdit::OPT, @opt) or die $help;
38 if ($opt->{help}) { print $help; exit 0 };
39
40 my $cfg = PublicInbox::Config->new;
41 my $editor = $ENV{MAIL_EDITOR}; # e.g. "mutt -f"
42 unless (defined $editor) {
43         my $k = 'publicinbox.mailEditor';
44         $editor = $cfg->{lc($k)} if $cfg;
45         unless (defined $editor) {
46                 warn "\`$k' not configured, trying \`git var GIT_EDITOR'\n";
47                 chomp($editor = `git var GIT_EDITOR`);
48                 warn "Will use $editor to edit mail\n";
49         }
50 }
51
52 my $mid = $opt->{mid};
53 my $file = $opt->{file};
54 if (defined $mid && defined $file) {
55         die "the --mid and --file options are mutually exclusive\n";
56 }
57
58 my @ibxs = PublicInbox::Admin::resolve_inboxes(\@ARGV, $opt, $cfg);
59 PublicInbox::AdminEdit::check_editable(\@ibxs);
60
61 my $found = {}; # chash => [ [ibx, smsg] [, [ibx, smsg] ] ]
62
63 sub find_mid ($$$) {
64         my ($found, $mid, $ibxs) = @_;
65         foreach my $ibx (@$ibxs) {
66                 my $over = $ibx->over;
67                 my ($id, $prev);
68                 while (my $smsg = $over->next_by_mid($mid, \$id, \$prev)) {
69                         my $ref = $ibx->msg_by_smsg($smsg);
70                         my $mime = PublicInbox::Eml->new($ref);
71                         my $chash = content_hash($mime);
72                         my $tuple = [ $ibx, $smsg ];
73                         push @{$found->{$chash} ||= []}, $tuple
74                 }
75                 PublicInbox::InboxWritable::cleanup($ibx);
76         }
77         $found;
78 }
79
80 sub show_cmd ($$) {
81         my ($ibx, $smsg) = @_;
82         " GIT_DIR=$ibx->{inboxdir}/all.git \\\n    git show $smsg->{blob}\n";
83 }
84
85 sub show_found ($) {
86         my ($found) = @_;
87         foreach my $to_edit (values %$found) {
88                 foreach my $tuple (@$to_edit) {
89                         my ($ibx, $smsg) = @$tuple;
90                         warn show_cmd($ibx, $smsg);
91                 }
92         }
93 }
94
95 if (defined($mid)) {
96         $mid = mid_clean($mid);
97         find_mid($found, $mid, \@ibxs);
98         my $nr = scalar(keys %$found);
99         die "No message found for <$mid>\n" unless $nr;
100         if ($nr > 1) {
101                 warn <<"";
102 Multiple messages with different content found matching
103 <$mid>:
104
105                 show_found($found);
106                 die "Use --force to edit all of them\n" if !$opt->{force};
107                 warn "Will edit all of them\n";
108         }
109 } else {
110         my $eml = eml_from_path($file) or die "open($file) failed: $!";
111         my $mids = mids($eml);
112         find_mid($found, $_, \@ibxs) for (@$mids); # populates $found
113         my $chash = content_hash($eml);
114         my $to_edit = $found->{$chash};
115         unless ($to_edit) {
116                 my $nr = scalar(keys %$found);
117                 if ($nr > 0) {
118                         warn <<"";
119 $nr matches to Message-ID(s) in $file, but none matched content
120 Partial matches below:
121
122                         show_found($found);
123                 } elsif ($nr == 0) {
124                         $mids = join('', map { "  <$_>\n" } @$mids);
125                         warn <<"";
126 No matching messages found matching Message-ID(s) in $file
127 $mids
128
129                 }
130                 exit 1;
131         }
132         $found = { $chash => $to_edit };
133 }
134
135 my %tmpopt = (
136         TEMPLATE => 'public-inbox-edit-XXXX',
137         TMPDIR => 1,
138         SUFFIX => $opt->{raw} ? '.eml' : '.mbox',
139 );
140
141 foreach my $to_edit (values %$found) {
142         my $edit_fh = File::Temp->new(%tmpopt);
143         $edit_fh->autoflush(1);
144         my $edit_fn = $edit_fh->filename;
145         my ($ibx, $smsg) = @{$to_edit->[0]};
146         my $old_raw = $ibx->msg_by_smsg($smsg);
147         PublicInbox::InboxWritable::cleanup($ibx);
148
149         my $tmp = $$old_raw;
150         if (!$opt->{raw}) {
151                 my $oid = $smsg->{blob};
152                 print $edit_fh "From mboxrd\@$oid Thu Jan  1 00:00:00 1970\n"
153                         or die "failed to write From_ line: $!";
154                 $tmp =~ s/^(>*From )/>$1/gm;
155         }
156         print $edit_fh $tmp or
157                 die "failed to write tempfile for editing: $!";
158
159         # run the editor, respecting spaces/quote
160 retry_edit:
161         if (system(qw(sh -c), $editor.' "$@"', $editor, $edit_fn)) {
162                 if (!(-t STDIN) && !$opt->{force}) {
163                         die "E: $editor failed: $?\n";
164                 }
165                 print STDERR "$editor failed, ";
166                 print STDERR "continuing as forced\n" if $opt->{force};
167                 while (!$opt->{force}) {
168                         print STDERR "(r)etry, (c)ontinue, (q)uit?\n";
169                         chomp(my $op = <STDIN> || '');
170                         $op = lc($op);
171                         goto retry_edit if $op eq 'r';
172                         if ($op eq 'q') {
173                                 # n.b. we'll lose the exit signal, here,
174                                 # oh well; "q" is user-specified anyways.
175                                 exit($? >> 8);
176                         }
177                         last if $op eq 'c'; # continuing
178                         print STDERR "\`$op' not recognized\n";
179                 }
180         }
181
182         # reread the edited file, not using $edit_fh since $EDITOR may
183         # rename/relink $edit_fn
184         open my $new_fh, '<', $edit_fn or
185                 die "can't read edited file ($edit_fn): $!\n";
186         defined(my $new_raw = do { local $/; <$new_fh> }) or die
187                 "read $edit_fn: $!\n";
188
189         if (!$opt->{raw}) {
190                 # get rid of the From we added
191                 $new_raw =~ s/\A[\r\n]*From [^\r\n]*\r?\n//s;
192
193                 # check if user forgot to purge (in mutt) after editing
194                 if ($new_raw =~ /^From /sm) {
195                         if (-t STDIN) {
196                                 print STDERR <<'';
197 Extra "From " lines detected in new mbox.
198 Did you forget to purge the original message from the mbox after editing?
199
200                                 while (1) {
201                                         print STDERR <<"";
202 (y)es to re-edit, (n)o to continue
203
204                                         chomp(my $op = <STDIN> || '');
205                                         $op = lc($op);
206                                         goto retry_edit if $op eq 'y';
207                                         last if $op eq 'n'; # continuing
208                                         print STDERR "\`$op' not recognized\n";
209                                 }
210                         } else { # non-interactive path
211                                 # unlikely to happen, as extra From lines are
212                                 # only a common mistake (for me) with
213                                 # interactive use
214                                 warn <<"";
215 W: possible message boundary splitting error
216
217                         }
218                 }
219                 # unescape what we escaped:
220                 $new_raw =~ s/^>(>*From )/$1/gm;
221         }
222
223         my $new_mime = PublicInbox::Eml->new(\$new_raw);
224         my $old_mime = PublicInbox::Eml->new($old_raw);
225
226         # make sure we don't compare unwanted headers, since mutt adds
227         # Content-Length, Status, and Lines headers:
228         PublicInbox::Import::drop_unwanted_headers($new_mime);
229         PublicInbox::Import::drop_unwanted_headers($old_mime);
230
231         # allow changing Received: and maybe other headers which can
232         # contain sensitive info.
233         my $nhdr = $new_mime->header_obj->as_string;
234         my $ohdr = $old_mime->header_obj->as_string;
235         if (($nhdr eq $ohdr) &&
236             (content_hash($new_mime) eq content_hash($old_mime))) {
237                 warn "No change detected to:\n", show_cmd($ibx, $smsg);
238
239                 next unless $opt->{verbose};
240                 # should we consider this machine-parseable?
241                 PublicInbox::AdminEdit::show_rewrites(\*STDOUT, $ibx, []);
242                 next;
243         }
244
245         foreach my $tuple (@$to_edit) {
246                 $ibx = PublicInbox::InboxWritable->new($tuple->[0]);
247                 $smsg = $tuple->[1];
248                 my $im = $ibx->importer(0);
249                 my $commits = $im->replace($old_mime, $new_mime);
250                 $im->done;
251                 unless ($commits) {
252                         warn "Failed to replace:\n", show_cmd($ibx, $smsg);
253                         next;
254                 }
255                 next unless $opt->{verbose};
256                 # should we consider this machine-parseable?
257                 PublicInbox::AdminEdit::show_rewrites(\*STDOUT, $ibx, $commits);
258         }
259 }