-# Copyright (C) 2021 all contributors <meta@public-inbox.org>
+# Copyright (C) all contributors <meta@public-inbox.org>
# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
# for maintaining synchronization between lei/store <=> Maildir|MH|IMAP|JMAP
use strict;
use v5.10.1;
use parent qw(PublicInbox::Lock);
-use DBI;
+use DBI qw(:sql_types); # SQL_BLOB
use PublicInbox::ContentHash qw(git_sha);
use Carp ();
sub dbh_new {
- my ($self, $rw) = @_;
+ my ($self) = @_;
my $f = $self->{filename};
- my $creat = $rw && !-s $f;
+ my $creat = !-s $f;
if ($creat) {
- require PublicInbox::Spawn;
+ require PublicInbox::Syscall;
open my $fh, '+>>', $f or Carp::croak "open($f): $!";
- PublicInbox::Spawn::nodatacow_fd(fileno($fh));
+ PublicInbox::Syscall::nodatacow_fh($fh);
}
my $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
AutoCommit => 1,
RaiseError => 1,
PrintError => 0,
- ReadOnly => !$rw,
sqlite_use_immediate_transaction => 1,
});
# no sqlite_unicode, here, all strings are binary
- create_tables($self, $dbh) if $rw;
+ create_tables($self, $dbh);
$dbh->do('PRAGMA journal_mode = WAL') if $creat;
$dbh->do('PRAGMA case_sensitive_like = ON');
$dbh;
}, $cls;
}
-sub lms_write_prepare { ($_[0]->{dbh} //= dbh_new($_[0], 1)); $_[0] }
+sub lms_write_prepare { ($_[0]->{dbh} //= dbh_new($_[0])); $_[0] }
sub lms_pause {
my ($self) = @_;
$self->{fmap} = {};
- delete $self->{dbh};
+ my $dbh = delete $self->{dbh};
+ eval { $dbh->do('PRAGMA optimize') } if $dbh;
}
sub create_tables {
oidbin VARBINARY NOT NULL,
fid INTEGER NOT NULL, /* folder ID */
uid INTEGER NOT NULL, /* NNTP article number, IMAP UID, MH number */
+ /* not UNIQUE(fid, uid), since we may have broken servers */
UNIQUE (oidbin, fid, uid)
)
oidbin VARBINARY NOT NULL,
fid INTEGER NOT NULL, /* folder ID */
name VARBINARY NOT NULL, /* Maildir basename, JMAP blobId */
+ /* not UNIQUE(fid, name), since we may have broken software */
UNIQUE (oidbin, fid, name)
)
}
+# used to fixup pre-1.7.0 folders
+sub update_fid ($$$) {
+ my ($dbh, $fid, $loc) = @_;
+ my $sth = $dbh->prepare(<<'');
+UPDATE folders SET loc = ? WHERE fid = ?
+
+ $sth->bind_param(1, $loc, SQL_BLOB);
+ $sth->bind_param(2, $fid);
+ $sth->execute;
+}
+
+sub get_fid ($$$) {
+ my ($sth, $folder, $dbh) = @_;
+ $sth->bind_param(1, $folder, SQL_BLOB);
+ $sth->execute;
+ my ($fid) = $sth->fetchrow_array;
+ if (defined $fid) { # for downgrade+upgrade (1.8 -> 1.7 -> 1.8)
+ my $del = $dbh->prepare_cached(<<'');
+DELETE FROM folders WHERE loc = ? AND fid != ?
+
+ $del->execute($folder, $fid);
+ } else {
+ $sth->bind_param(1, $folder, SQL_VARCHAR);
+ $sth->execute; # fixup old stuff
+ ($fid) = $sth->fetchrow_array;
+ update_fid($dbh, $fid, $folder) if defined($fid);
+ }
+ $fid;
+}
+
sub fid_for {
- my ($self, $folder, $rw) = @_;
- my $dbh = $self->{dbh} //= dbh_new($self, $rw);
- my $sel = 'SELECT fid FROM folders WHERE loc = ? LIMIT 1';
- my ($fid) = $dbh->selectrow_array($sel, undef, $folder);
- return $fid if defined $fid;
+ my ($self, $folder, $creat) = @_;
+ my $dbh = $self->{dbh} //= dbh_new($self);
+ my $sth = $dbh->prepare_cached(<<'', undef, 1);
+SELECT fid FROM folders WHERE loc = ? LIMIT 1
+
+ my $fid = get_fid($sth, $folder, $dbh);
+ return $fid if defined($fid);
# caller had trailing slash (LeiToMail)
if ($folder =~ s!\A((?:maildir|mh):.*?)/+\z!$1!i) {
- ($fid) = $dbh->selectrow_array($sel, undef, $folder);
+ $fid = get_fid($sth, $folder, $dbh);
if (defined $fid) {
- $dbh->do(<<EOM, undef, $folder, $fid) if $rw;
-UPDATE folders SET loc = ? WHERE fid = ?
-EOM
+ update_fid($dbh, $fid, $folder);
return $fid;
}
# sometimes we stored trailing slash..
} elsif ($folder =~ m!\A(?:maildir|mh):!i) {
- ($fid) = $dbh->selectrow_array($sel, undef, "$folder/");
+ $fid = get_fid($sth, $folder, $dbh);
if (defined $fid) {
- $dbh->do(<<EOM, undef, $folder, $fid) if $rw;
-UPDATE folders SET loc = ? WHERE fid = ?
-EOM
+ update_fid($dbh, $fid, $folder);
return $fid;
}
- } elsif ($rw && $folder =~ m!\Aimaps?://!i) {
+ } elsif ($creat && $folder =~ m!\Aimaps?://!i) {
require PublicInbox::URIimap;
- PublicInbox::URIimap->new($folder)->uidvalidity //
+ my $uri = PublicInbox::URIimap->new($folder);
+ $uri->uidvalidity //
Carp::croak("BUG: $folder has no UIDVALIDITY");
+ defined($uri->uid) and Carp::confess("BUG: $folder has UID");
}
- return unless $rw;
+ return unless $creat;
($fid) = $dbh->selectrow_array('SELECT MAX(fid) FROM folders');
$fid += 1;
$dbh->do('DELETE FROM blob2name WHERE fid = ?', undef, $fid);
$dbh->do('DELETE FROM blob2num WHERE fid = ?', undef, $fid);
- my $sth = $dbh->prepare('INSERT INTO folders (fid, loc) VALUES (?, ?)');
- $sth->execute($fid, $folder);
+ $sth = $dbh->prepare('INSERT INTO folders (fid, loc) VALUES (?, ?)');
+ $sth->bind_param(1, $fid);
+ $sth->bind_param(2, $folder, SQL_BLOB);
+ $sth->execute;
$fid;
}
my ($self, $oidbin, $folder, $id) = @_;
my $lk = $self->lock_for_scope;
my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
- my $sth;
+ my $dbh = $self->{dbh};
+ my ($sth, @param3, $del_old);
if (ref($id)) { # scalar name
- $id = $$id;
- $sth = $self->{dbh}->prepare_cached(<<'');
+ @param3 = ($$id, SQL_BLOB);
+ $sth = $dbh->prepare_cached(<<'');
INSERT OR IGNORE INTO blob2name (oidbin, fid, name) VALUES (?, ?, ?)
+ $del_old = $dbh->prepare_cached(<<'');
+DELETE FROM blob2name WHERE oidbin = ? AND fid = ? AND name = ?
+
} else { # numeric ID (IMAP UID, MH number)
- $sth = $self->{dbh}->prepare_cached(<<'');
+ @param3 = ($id);
+ $sth = $dbh->prepare_cached(<<'');
INSERT OR IGNORE INTO blob2num (oidbin, fid, uid) VALUES (?, ?, ?)
+ $del_old = $dbh->prepare_cached(<<'');
+DELETE FROM blob2num WHERE oidbin = ? AND fid = ? AND uid = ?
+
}
- $sth->execute($oidbin, $fid, $id);
+ $sth->bind_param(1, $oidbin, SQL_BLOB);
+ $sth->bind_param(2, $fid);
+ $sth->bind_param(3, @param3);
+ my $ret = $sth->execute;
+ $del_old->execute($oidbin, $fid, $param3[0]);
+ $ret;
}
sub clear_src {
my ($self, $folder, $id) = @_;
my $lk = $self->lock_for_scope;
my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
- my $sth;
+ my ($sth, @param3);
if (ref($id)) { # scalar name
- $id = $$id;
+ @param3 = ($$id, SQL_BLOB);
$sth = $self->{dbh}->prepare_cached(<<'');
DELETE FROM blob2name WHERE fid = ? AND name = ?
} else {
+ @param3 = ($id);
$sth = $self->{dbh}->prepare_cached(<<'');
DELETE FROM blob2num WHERE fid = ? AND uid = ?
}
- $sth->execute($fid, $id);
+ $sth->bind_param(1, $fid);
+ $sth->bind_param(2, @param3);
+ my $ret = $sth->execute;
+
+ # older versions may not have used SQL_BLOB:
+ if (defined($ret) && $ret == 0 && scalar(@param3) == 2) {
+ $sth->bind_param(1, $fid);
+ $sth->bind_param(2, $param3[0]);
+ $ret = $sth->execute;
+ }
+ $ret;
}
# Maildir-only
my ($self, $folder, $oidbin, $id, $newbn) = @_;
my $lk = $self->lock_for_scope;
my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
+ $self->{dbh}->begin_work;
my $sth = $self->{dbh}->prepare_cached(<<'');
UPDATE blob2name SET name = ? WHERE fid = ? AND oidbin = ? AND name = ?
- my $nr = $sth->execute($newbn, $fid, $oidbin, $$id);
- if ($nr == 0) { # may race with a clear_src, ensure new value exists
+ # eval since unique constraint may fail due to race
+ $sth->bind_param(1, $newbn, SQL_BLOB);
+ $sth->bind_param(2, $fid);
+ $sth->bind_param(3, $oidbin, SQL_BLOB);
+ $sth->bind_param(4, $$id, SQL_BLOB);
+ my $nr = eval { $sth->execute };
+ if (!defined($nr) || $nr == 0) { # $nr may be `0E0'
+ # delete from old, pre-SQL_BLOB rows:
+ my $del_old = $self->{dbh}->prepare_cached(<<'');
+DELETE FROM blob2name WHERE fid = ? AND oidbin = ? AND name = ?
+
+ $del_old->execute($fid, $oidbin, $$id); # missing-OK
+ $del_old->execute($fid, $oidbin, $newbn); # ditto
+
+ # may race with a clear_src, ensure new value exists
$sth = $self->{dbh}->prepare_cached(<<'');
INSERT OR IGNORE INTO blob2name (oidbin, fid, name) VALUES (?, ?, ?)
- $sth->execute($oidbin, $fid, $newbn);
+ $sth->bind_param(1, $oidbin, SQL_BLOB);
+ $sth->bind_param(2, $fid);
+ $sth->bind_param(3, $newbn, SQL_BLOB);
+ $sth->execute;
}
+ $self->{dbh}->commit;
}
# read-only, iterates every oidbin + UID or name for a given folder
sub each_src {
my ($self, $folder, $cb, @args) = @_;
my $dbh = $self->{dbh} //= dbh_new($self);
- my $fid;
+ my ($fid, @rng);
+ my $and_ge_le = '';
if (ref($folder) eq 'HASH') {
$fid = $folder->{fid} // die "BUG: no `fid'";
+ @rng = grep(defined, @$folder{qw(min max)});
+ $and_ge_le = 'AND uid >= ? AND uid <= ?' if @rng;
} else {
$fid = $self->{fmap}->{$folder} //=
fid_for($self, $folder) // return;
# minimize implicit txn time to avoid blocking writers by
# batching SELECTs. This looks wonky but is necessary since
# $cb-> may access the DB on its own.
- my $ary = $dbh->selectall_arrayref(<<'', undef, $fid);
-SELECT _rowid_,oidbin,uid FROM blob2num WHERE fid = ?
+ my $ary = $dbh->selectall_arrayref(<<"", undef, $fid, @rng);
+SELECT _rowid_,oidbin,uid FROM blob2num WHERE fid = ? $and_ge_le
ORDER BY _rowid_ ASC LIMIT 1000
my $min = @$ary ? $ary->[-1]->[0] : undef;
while (defined $min) {
for my $row (@$ary) { $cb->($row->[1], $row->[2], @args) }
- $ary = $dbh->selectall_arrayref(<<'', undef, $fid, $min);
-SELECT _rowid_,oidbin,uid FROM blob2num WHERE fid = ? AND _rowid_ > ?
+ $ary = $dbh->selectall_arrayref(<<"", undef, $fid, @rng, $min);
+SELECT _rowid_,oidbin,uid FROM blob2num
+WHERE fid = ? $and_ge_le AND _rowid_ > ?
ORDER BY _rowid_ ASC LIMIT 1000
$min = @$ary ? $ary->[-1]->[0] : undef;
# returns a { location => [ list-of-ids-or-names ] } mapping
sub locations_for {
my ($self, $oidbin) = @_;
- my ($fid, $sth, $id, %fid2id);
+ my ($fid, $sth, $id, %fid2id, %seen);
my $dbh = $self->{dbh} //= dbh_new($self);
$sth = $dbh->prepare('SELECT fid,uid FROM blob2num WHERE oidbin = ?');
- $sth->execute($oidbin);
+ $sth->bind_param(1, $oidbin, SQL_BLOB);
+ $sth->execute;
+ while (my ($fid, $uid) = $sth->fetchrow_array) {
+ push @{$fid2id{$fid}}, $uid;
+ $seen{"$uid.$fid"} = 1;
+ }
+
+ # deal with 1.7.0 DBs :<
+ $sth->bind_param(1, $oidbin, SQL_VARCHAR);
+ $sth->execute;
while (my ($fid, $uid) = $sth->fetchrow_array) {
+ next if $seen{"$uid.$fid"};
push @{$fid2id{$fid}}, $uid;
}
+
+ %seen = ();
$sth = $dbh->prepare('SELECT fid,name FROM blob2name WHERE oidbin = ?');
- $sth->execute($oidbin);
+ $sth->bind_param(1, $oidbin, SQL_BLOB);
+ $sth->execute;
+ while (my ($fid, $name) = $sth->fetchrow_array) {
+ push @{$fid2id{$fid}}, $name;
+ $seen{"$fid.$name"} = 1;
+ }
+
+ # deal with 1.7.0 DBs :<
+ $sth->bind_param(1, $oidbin, SQL_VARCHAR);
+ $sth->execute;
while (my ($fid, $name) = $sth->fetchrow_array) {
+ next if $seen{"$fid.$name"};
push @{$fid2id{$fid}}, $name;
}
+
$sth = $dbh->prepare('SELECT loc FROM folders WHERE fid = ? LIMIT 1');
my $ret = {};
while (my ($fid, $ids) = each %fid2id) {
# returns a list of folders used for completion
sub folders {
- my ($self, $pfx) = @_;
- my $dbh = $self->{dbh} //= dbh_new($self);
+ my ($self, @pfx) = @_;
my $sql = 'SELECT loc FROM folders';
- my @pfx;
- if (defined $pfx) {
- $sql .= ' WHERE loc LIKE ? ESCAPE ?';
- @pfx = ($pfx, '\\');
- $pfx[0] =~ s/([%_\\])/\\$1/g; # glob chars
- $pfx[0] .= '%';
+ my $re;
+ if (defined($pfx[0])) {
+ $sql .= ' WHERE loc REGEXP ?'; # DBD::SQLite uses perlre
+ $re = !!$pfx[1] ? '.*' : '';
+ $re .= quotemeta($pfx[0]);
+ $re .= '.*';
}
- map { $_->[0] } @{$dbh->selectall_arrayref($sql, undef, @pfx)};
+ my $sth = ($self->{dbh} //= dbh_new($self))->prepare($sql);
+ $sth->bind_param(1, $re) if defined($re);
+ $sth->execute;
+ map { $_->[0] } @{$sth->fetchall_arrayref};
}
sub local_blob {
LEFT JOIN folders f ON b.fid = f.fid
WHERE b.oidbin = ?
- $b2n->execute(pack('H*', $oidhex));
+ $b2n->bind_param(1, pack('H*', $oidhex), SQL_BLOB);
+ $b2n->execute;
while (my ($d, $n) = $b2n->fetchrow_array) {
substr($d, 0, length('maildir:')) = '';
# n.b. both mbsync and offlineimap use ":2," as a suffix
next unless -s $fh;
local $/;
my $raw = <$fh>;
- if ($vrfy && git_sha(1, \$raw)->hexdigest ne $oidhex) {
- warn "$f changed $oidhex\n";
- next;
+ if ($vrfy) {
+ my $got = git_sha(1, \$raw)->hexdigest;
+ if ($got ne $oidhex) {
+ warn "$f changed $oidhex => $got\n";
+ next;
+ }
}
return \$raw;
}
my ($self, $lei, $folders) = @_;
my @all = $self->folders;
my %all = map { $_ => 1 } @all;
- my ($err, @no);
+ my @no;
for (@$folders) {
next if $all{$_}; # ok
if (m!\A(maildir|mh):(.+)!i) {
my $res = match_imap_url($self, $orig, \@all);
if (ref $res) {
$_ = $$res;
- push(@{$err->{qerr}}, <<EOM);
+ $lei->qerr(<<EOM);
# using `$res' instead of `$orig'
EOM
} else {
- $lei->err($res) if defined $res;
+ warn($res, "\n") if defined $res;
push @no, $orig;
}
} elsif (m!\A(?:nntps?|s?news)://!i) {
my $res = match_nntp_url($self, $orig, \@all);
if (ref $res) {
$_ = $$res;
- push(@{$err->{qerr}}, <<EOM);
+ $lei->qerr(<<EOM);
# using `$res' instead of `$orig'
EOM
} else {
- $lei->err($res) if defined $res;
+ warn($res, "\n") if defined $res;
push @no, $orig;
}
} else {
}
if (@no) {
my $no = join("\n\t", @no);
- $err->{fail} = <<EOF;
+ die <<EOF;
No sync information for: $no
Run `lei ls-mail-sync' to display valid choices
EOF
}
- $err;
}
sub forget_folders {
}
}
-sub imap_oidbin ($$$) {
- my ($self, $url, $uid) = @_; # $url MUST have UIDVALIDITY
- my $fid = $self->{fmap}->{$url} //= fid_for($self, $url) // return;
+sub num_oidbin ($$$) {
+ my ($self, $url, $uid) = @_; # $url MUST have UIDVALIDITY if IMAP
+ my $fid = $self->{fmap}->{$url} //= fid_for($self, $url) // return ();
my $sth = $self->{dbh}->prepare_cached(<<EOM, undef, 1);
-SELECT oidbin FROM blob2num WHERE fid = ? AND uid = ?
+SELECT oidbin FROM blob2num WHERE fid = ? AND uid = ? ORDER BY _rowid_
EOM
$sth->execute($fid, $uid);
- $sth->fetchrow_array;
+ my %uniq; # for public-inbox <= 1.7.0
+ grep { !$uniq{$_}++ } map { $_->[0] } @{$sth->fetchall_arrayref};
}
sub name_oidbin ($$$) {
my $sth = $self->{dbh}->prepare_cached(<<EOM, undef, 1);
SELECT oidbin FROM blob2name WHERE fid = ? AND name = ?
EOM
- $sth->execute($fid, $nm);
- $sth->fetchrow_array;
+ $sth->bind_param(1, $fid);
+ $sth->bind_param(2, $nm, SQL_BLOB);
+ $sth->execute;
+ my @bin = map { $_->[0] } @{$sth->fetchall_arrayref};
+ $sth->bind_param(1, $fid);
+ $sth->bind_param(2, $nm, SQL_VARCHAR);
+ $sth->execute;
+ my @old = map { $_->[0] } @{$sth->fetchall_arrayref};
+ my %uniq; # for public-inbox <= 1.7.0
+ grep { !$uniq{$_}++ } (@bin, @old);
}
-sub imap_oid {
+sub imap_oidhex {
my ($self, $lei, $uid_uri) = @_;
my $mailbox_uri = $uid_uri->clone;
$mailbox_uri->uid(undef);
my $folders = [ $$mailbox_uri ];
- if (my $err = $self->arg2folder($lei, $folders)) {
- if ($err->{fail}) {
- $lei->qerr("# no sync information for $mailbox_uri");
- return;
- }
- $lei->qerr(@{$err->{qerr}}) if $err->{qerr};
- }
- my $oidbin = imap_oidbin($self, $folders->[0], $uid_uri->uid);
- $oidbin ? unpack('H*', $oidbin) : undef;
+ eval { $self->arg2folder($lei, $folders) };
+ $lei->qerr("# no sync information for $mailbox_uri") if $@;
+ map { unpack('H*',$_) } num_oidbin($self, $folders->[0], $uid_uri->uid)
}
1;