--- /dev/null
+# Copyright (C) 2021 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+
+# front-end for the "lei export-kw" sub-command
+package PublicInbox::LeiExportKw;
+use strict;
+use v5.10.1;
+use parent qw(PublicInbox::IPC PublicInbox::LeiInput);
+use Errno qw(EEXIST ENOENT);
+
+sub export_kw_md { # LeiMailSync->each_src callback
+ my ($oidbin, $id, $self, $mdir) = @_;
+ my $oidhex = unpack('H*', $oidbin);
+ my $sto_kw = $self->{lse}->oid_keywords($oidhex) or return;
+ my $bn = $$id;
+ my ($md_kw, $unknown, @try);
+ if ($bn =~ s/:2,([a-zA-Z]*)\z//) {
+ ($md_kw, $unknown) = PublicInbox::MdirReader::flags2kw($1);
+ @try = qw(cur new);
+ } else {
+ $unknown = [];
+ @try = qw(new cur);
+ }
+ if ($self->{-merge_kw} && $md_kw) { # merging keywords is the default
+ @$sto_kw{keys %$md_kw} = values(%$md_kw);
+ }
+ $bn .= ':2,'.
+ PublicInbox::LeiToMail::kw2suffix([keys %$sto_kw], @$unknown);
+ my $dst = "$mdir/cur/$bn";
+ my @fail;
+ for my $d (@try) {
+ my $src = "$mdir/$d/$$id";
+ next if $src eq $dst;
+
+ # we use link(2) + unlink(2) since rename(2) may
+ # inadvertently clobber if the "uniquefilename" part wasn't
+ # actually unique.
+ if (link($src, $dst)) { # success
+ # unlink(2) may ENOENT from parallel invocation,
+ # ignore it, but not other serious errors
+ if (!unlink($src) and $! != ENOENT) {
+ $self->{lei}->child_error(1,
+ "E: unlink($src): $!");
+ }
+ $self->{lms}->mv_src("maildir:$mdir",
+ $oidbin, $id, $bn) or die;
+ return; # success anyways if link(2) worked
+ }
+ if ($! == ENOENT && !-e $src) { # some other process moved it
+ $self->{lms}->clear_src("maildir:$mdir", $id);
+ next;
+ }
+ push @fail, $src if $! != EEXIST;
+ }
+ return unless @fail;
+ # both tries failed
+ my $e = $!;
+ my $orig = '['.join('|', @fail).']';
+ $self->{lei}->child_error(1, "link($orig, $dst) ($oidhex): $e");
+}
+
+# overrides PublicInbox::LeiInput::input_path_url
+sub input_path_url {
+ my ($self, $input, @args) = @_;
+ my $lms = $self->{lms} //= $self->{lse}->lms;
+ $lms->lms_begin;
+ if ($input =~ s/\Amaildir://i) {
+ require PublicInbox::LeiToMail; # kw2suffix
+ $lms->each_src("maildir:$input", \&export_kw_md, $self, $input);
+ }
+ $lms->lms_commit;
+}
+
+sub lei_export_kw {
+ my ($lei, @folders) = @_;
+ my $sto = $lei->_lei_store or return $lei->fail(<<EOM);
+lei/store uninitialized, see lei-import(1)
+EOM
+ my $lse = $sto->search;
+ my $lms = $lse->lms or return $lei->fail(<<EOM);
+lei mail_sync uninitialized, see lei-import(1)
+EOM
+ my $opt = $lei->{opt};
+ my $all = $opt->{all};
+ my @all = $lms->folders;
+ if (defined $all) { # --all=<local|remote>
+ my %x = map { $_ => $_ } split(/,/, $all);
+ my @ok = grep(defined, delete(@x{qw(local remote), ''}));
+ my @no = keys %x;
+ if (@no) {
+ @no = (join(',', @no));
+ return $lei->fail(<<EOM);
+--all=@no not accepted (must be `local' and/or `remote')
+EOM
+ }
+ my (%seen, @inc);
+ for my $ok (@ok) {
+ if ($ok eq 'local') {
+ @inc = grep(!m!\A[a-z0-9\+]+://!i, @all);
+ } elsif ($ok eq 'remote') {
+ @inc = grep(m!\A[a-z0-9\+]+://!i, @all);
+ } elsif ($ok ne '') {
+ return $lei->fail("--all=$all not understood");
+ } else {
+ @inc = @all;
+ }
+ for (@inc) {
+ push(@folders, $_) unless $seen{$_}++;
+ }
+ }
+ return $lei->fail(<<EOM) if !@folders;
+no --mail-sync folders known to lei
+EOM
+ } else {
+ my %all = map { $_ => 1 } @all;
+ my @no;
+ for (@folders) {
+ next if $all{$_}; # ok
+ if (-d "$_/new" && -d "$_/cur") {
+ my $d = 'maildir:'.$lei->rel2abs($_);
+ push(@no, $_) unless $all{$d};
+ $_ = $d;
+ } else {
+ push @no, $_;
+ }
+ }
+ my $no = join("\n\t", @no);
+ return $lei->fail(<<EOF) if @no;
+No sync information for: $no
+Run `lei ls-mail-sync' to display valid choices
+EOF
+ }
+ my $self = bless { lse => $lse }, __PACKAGE__;
+ $lei->{opt}->{'mail-sync'} = 1; # for prepare_inputs
+ $self->prepare_inputs($lei, \@folders) or return;
+ my $j = $opt->{jobs} // scalar(@{$self->{inputs}}) || 1;
+ if (my @ro = grep(!/\A(?:maildir|imaps?):/, @folders)) {
+ return $lei->fail("cannot export to read-only folders: @ro");
+ }
+ if (my $net = $lei->{net}) {
+ require PublicInbox::NetWriter;
+ bless $net, 'PublicInbox::NetWriter';
+ }
+ undef $lms;
+ my $m = $opt->{mode} // 'merge';
+ if ($m eq 'merge') { # default
+ $self->{-merge_kw} = 1;
+ } elsif ($m eq 'set') {
+ } else {
+ return $lei->fail(<<EOM);
+--mode=$m not supported (`set' or `merge')
+EOM
+ }
+ my $ops = {};
+ $lei->{auth}->op_merge($ops, $self) if $lei->{auth};
+ $self->{-wq_nr_workers} = $j // 1; # locked
+ (my $op_c, $ops) = $lei->workers_start($self, $j, $ops);
+ $lei->{wq1} = $self;
+ $lei->{-err_type} = 'non-fatal';
+ net_merge_all_done($self) unless $lei->{auth};
+ $op_c->op_wait_event($ops); # calls net_merge_all_done if $lei->{auth}
+}
+
+sub _complete_export_kw {
+ my ($lei, @argv) = @_;
+ my $sto = $lei->_lei_store or return;
+ my $lms = $sto->search->lms or return;
+ my $match_cb = $lei->complete_url_prepare(\@argv);
+ map { $match_cb->($_) } $lms->folders;
+}
+
+no warnings 'once';
+
+*ipc_atfork_child = \&PublicInbox::LeiInput::input_only_atfork_child;
+*net_merge_all_done = \&PublicInbox::LeiInput::input_only_net_merge_all_done;
+
+# the following works even when LeiAuth is lazy-loaded
+*net_merge_all = \&PublicInbox::LeiAuth::net_merge_all;
+
+1;
--- /dev/null
+#!perl -w
+# Copyright (C) 2021 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict; use v5.10.1; use PublicInbox::TestCommon;
+use File::Copy qw(cp);
+use File::Path qw(make_path);
+require_mods(qw(lei -imapd Mail::IMAPClient));
+my ($tmpdir, $for_destroy) = tmpdir;
+my ($ro_home, $cfg_path) = setup_public_inboxes;
+my $expect = eml_load('t/data/0001.patch');
+test_lei({ tmpdir => $tmpdir }, sub {
+ my $home = $ENV{HOME};
+ my $md = "$home/md";
+ make_path("$md/new", "$md/cur", "$md/tmp");
+ cp('t/data/0001.patch', "$md/new/y") or xbail "cp $md $!";
+ cp('t/data/message_embed.eml', "$md/cur/x:2,S") or xbail "cp $md $!";
+ lei_ok qw(index -q), $md;
+ lei_ok qw(tag t/data/0001.patch +kw:seen);
+ lei_ok qw(export-kw --all=local);
+ ok(!-e "$md/new/y", 'original gone');
+ is_deeply(eml_load("$md/cur/y:2,S"), $expect,
+ "`seen' kw exported");
+
+ lei_ok qw(tag t/data/0001.patch +kw:answered);
+ lei_ok qw(export-kw --all=local);
+ ok(!-e "$md/cur/y:2,S", 'seen-only file gone');
+ is_deeply(eml_load("$md/cur/y:2,RS"), $expect, "`R' added");
+
+ lei_ok qw(tag t/data/0001.patch -kw:answered -kw:seen);
+ lei_ok qw(export-kw --mode=set --all=local);
+ ok(!-e "$md/cur/y:2,RS", 'seen+answered file gone');
+ is_deeply(eml_load("$md/cur/y:2,"), $expect, 'no keywords left');
+});
+
+done_testing;