X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=lib%2FPublicInbox%2FSearch.pm;h=523003b3c269ae42e9ee210e91415b2846735daa;hb=1f1b1f0e22f7b4cbe5c8e843c1932222c0461393;hp=fb3e997518e3b790f5ee7d774282b81948342af7;hpb=8862c33ae93eea1af6246cd3c7a81e0a122186bf;p=public-inbox.git diff --git a/lib/PublicInbox/Search.pm b/lib/PublicInbox/Search.pm index fb3e9975..523003b3 100644 --- a/lib/PublicInbox/Search.pm +++ b/lib/PublicInbox/Search.pm @@ -1,20 +1,23 @@ -# Copyright (C) 2015-2020 all contributors +# Copyright (C) 2015-2021 all contributors # License: AGPL-3.0+ # based on notmuch, but with no concept of folders, files or flags # # Read-only search interface for use by the web and NNTP interfaces package PublicInbox::Search; use strict; +use v5.10.1; use parent qw(Exporter); -our @EXPORT_OK = qw(retry_reopen int_val); +our @EXPORT_OK = qw(retry_reopen int_val get_pct xap_terms); use List::Util qw(max); +use POSIX qw(strftime); +use Carp (); # values for searching, changing the numeric value breaks # compatibility with old indices (so don't change them it) use constant { - TS => 0, # Received: header in Unix time (IMAP INTERNALDATE) - YYYYMMDD => 1, # Date: header for searching in the WWW UI - DT => 2, # Date: YYYYMMDDHHMMSS + TS => 0, # Received: in Unix time (IMAP INTERNALDATE, JMAP receivedAt) + YYYYMMDD => 1, # redundant with DT below + DT => 2, # Date: YYYYMMDDHHMMSS (IMAP SENT*, JMAP sentAt) # added for public-inbox 1.6.0+ BYTES => 3, # IMAP RFC822.SIZE @@ -152,12 +155,9 @@ my %prob_prefix = ( our @HELP = ( 's:' => 'match within Subject e.g. s:"a quick brown fox"', 'd:' => < < 'match within message body, including text attachments', 'nq:' => 'match non-quoted text within message body', @@ -178,6 +178,9 @@ EOF 'dfpre:' => 'match pre-image git blob ID', 'dfpost:' => 'match post-image git blob ID', 'dfblob:' => 'match either pre or post-image git blob ID', + 'rt:' => <{shard})) { $self->{xpfx}; - } else { # v2 only: + } else { # v2 + extindex only: "$self->{xpfx}/$self->{shard}"; } } -sub xdb_sharded { +# returns all shards as separate Xapian::Database objects w/o combining +sub xdb_shards_flat ($) { my ($self) = @_; - opendir(my $dh, $self->{xpfx}) or return; # not initialized yet - - # We need numeric sorting so shard[0] is first for reading - # Xapian metadata, if needed - my $last = max(grep(/\A[0-9]+\z/, readdir($dh))) // return; + my $xpfx = $self->{xpfx}; my (@xdb, $slow_phrase); - for (0..$last) { - my $shard_dir = "$self->{xpfx}/$_"; - if (-d $shard_dir && -r _) { + load_xapian(); + $self->{qp_flags} //= $QP_FLAGS; + if ($xpfx =~ m!/xapian[0-9]+\z!) { + @xdb = ($X{Database}->new($xpfx)); + $self->{qp_flags} |= FLAG_PHRASE() if !-f "$xpfx/iamchert"; + } else { + opendir(my $dh, $xpfx) or return (); # not initialized yet + # We need numeric sorting so shard[0] is first for reading + # Xapian metadata, if needed + my $last = max(grep(/\A[0-9]+\z/, readdir($dh))) // return (); + for (0..$last) { + my $shard_dir = "$self->{xpfx}/$_"; push @xdb, $X{Database}->new($shard_dir); $slow_phrase ||= -f "$shard_dir/iamchert"; - } else { # gaps from missing epochs throw off mdocid() - warn "E: $shard_dir missing or unreadable\n"; - return; } + $self->{qp_flags} |= FLAG_PHRASE() if !$slow_phrase; } - $self->{qp_flags} |= FLAG_PHRASE() if !$slow_phrase; - $self->{nshard} = scalar(@xdb); - my $xdb = shift @xdb; - $xdb->add_database($_) for @xdb; - $xdb; -} - -sub _xdb { - my ($self) = @_; - my $dir = xdir($self, 1); - $self->{qp_flags} //= $QP_FLAGS; - if ($self->{ibx_ver} >= 2) { - xdb_sharded($self); - } else { - $self->{qp_flags} |= FLAG_PHRASE() if !-f "$dir/iamchert"; - $X{Database}->new($dir); - } + @xdb; } # v2 Xapian docids don't conflict, so they're identical to @@ -238,36 +229,28 @@ sub mdocid { sub mset_to_artnums { my ($self, $mset) = @_; - my $nshard = $self->{nshard} // 1; + my $nshard = $self->{nshard}; [ map { mdocid($nshard, $_) } $mset->items ]; } sub xdb ($) { my ($self) = @_; - $self->{xdb} //= do { - load_xapian(); - $self->_xdb; + $self->{xdb} // do { + my @xdb = $self->xdb_shards_flat or return; + $self->{nshard} = scalar(@xdb); + my $xdb = shift @xdb; + $xdb->add_database($_) for @xdb; + $self->{xdb} = $xdb; }; } -sub xpfx_init ($) { - my ($self) = @_; - if ($self->{ibx_ver} == 1) { - $self->{xpfx} .= '/public-inbox/xapian' . SCHEMA_VERSION; - } else { - $self->{xpfx} .= '/xap'.SCHEMA_VERSION; - } -} - sub new { my ($class, $ibx) = @_; ref $ibx or die "BUG: expected PublicInbox::Inbox object: $ibx"; - my $self = bless { - xpfx => $ibx->{inboxdir}, # for xpfx_init - altid => $ibx->{altid}, - ibx_ver => $ibx->version, - }, $class; - xpfx_init($self); + my $xap = $ibx->version > 1 ? 'xap' : 'public-inbox/xapian'; + my $xpfx = "$ibx->{inboxdir}/$xap".SCHEMA_VERSION; + my $self = bless { xpfx => $xpfx }, $class; + $self->{altid} = $ibx->{altid} if defined($ibx->{altid}); $self; } @@ -279,11 +262,135 @@ sub reopen { $self; # make chaining easier } +# Convert git "approxidate" ranges to something usable with our +# Xapian indices. At the moment, Xapian only offers a C++-only API +# and neither the SWIG nor XS bindings allow us to use custom code +# to parse dates (and libgit2 doesn't expose git__date_parse, either, +# so we're running git-rev-parse(1)). +# This replaces things we need to send to $git->date_parse with +# "\0".$strftime_format.['+'|$idx]."\0" placeholders +sub date_parse_prepare { + my ($to_parse, $pfx, $range) = @_; + # are we inside a parenthesized statement? + my $end = $range =~ s/([\)\s]*)\z// ? $1 : ''; + my @r = split(/\.\./, $range, 2); + + # expand "d:20101002" => "d:20101002..20101003" and like + # n.b. git doesn't do YYYYMMDD w/o '-', it needs YYYY-MM-DD + # We upgrade "d:" to "dt:" to iff using approxidate + if ($pfx eq 'd') { + my $fmt = "\0%Y%m%d"; + if (!defined($r[1])) { + if ($r[0] =~ /\A([0-9]{4})([0-9]{2})([0-9]{2})\z/) { + push @$to_parse, "$1-$2-$3"; + # we could've handled as-is, but we need + # to parse anyways for "d+" below + } else { + push @$to_parse, $r[0]; + if ($r[0] !~ /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/) { + $pfx = 'dt'; + $fmt = "\0%Y%m%d%H%M%S"; + } + } + $r[0] = "$fmt+$#$to_parse\0"; + $r[1] = "$fmt+\0"; + } else { + for my $x (@r) { + next if $x eq '' || $x =~ /\A[0-9]{8}\z/; + push @$to_parse, $x; + if ($x !~ /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/) { + $pfx = 'dt'; + } + $x = "$fmt$#$to_parse\0"; + } + if ($pfx eq 'dt') { + for (@r) { + s/\0%Y%m%d/\0%Y%m%d%H%M%S/; + s/\A([0-9]{8})\z/${1}000000/; + } + } + } + } elsif ($pfx eq 'dt') { + if (!defined($r[1])) { # git needs gaps and not /\d{14}/ + if ($r[0] =~ /\A([0-9]{4})([0-9]{2})([0-9]{2}) + ([0-9]{2})([0-9]{2})([0-9]{2})\z/x) { + push @$to_parse, "$1-$2-$3 $4:$5:$6"; + } else { + push @$to_parse, $r[0]; + } + $r[0] = "\0%Y%m%d%H%M%S$#$to_parse\0"; + $r[1] = "\0%Y%m%d%H%M%S+\0"; + } else { + for my $x (@r) { + next if $x eq '' || $x =~ /\A[0-9]{14}\z/; + push @$to_parse, $x; + $x = "\0%Y%m%d%H%M%S$#$to_parse\0"; + } + } + } else { # "rt", let git interpret "YYYY", deal with Y10K later :P + for my $x (@r) { + next if $x eq '' || $x =~ /\A[0-9]{5,}\z/; + push @$to_parse, $x; + $x = "\0%s$#$to_parse\0"; + } + $r[1] //= "\0%s+\0"; # add 1 day + } + "$pfx:".join('..', @r).$end; +} + +sub date_parse_finalize { + my ($git, $to_parse) = @_; + # git-rev-parse can handle any number of args up to system + # limits (around (4096*32) bytes on Linux). + my @r = $git->date_parse(@$to_parse); + # n.b. git respects TZ, times stored in SQLite/Xapian are always UTC, + # and gmtime doesn't seem to do the right thing when TZ!=UTC + my ($i, $t); + $_[2] =~ s/\0(%[%YmdHMSs]+)([0-9\+]+)\0/ + $t = $2 eq '+' ? ($r[$i]+86400) : $r[$i=$2+0]; + $1 eq '%s' ? $t : strftime($1, gmtime($t))/sge; +} + +# n.b. argv never has NUL, though we'll need to filter it out +# if this $argv isn't from a command execution +sub query_argv_to_string { + my (undef, $git, $argv) = @_; + my $to_parse; + my $tmp = join(' ', map {; + if (s!\b(d|rt|dt):(\S+)\z!date_parse_prepare( + $to_parse //= [], $1, $2)!sge) { + $_; + } elsif (/\s/) { + s/(.*?)\b(\w+:)// ? qq{$1$2"$_"} : qq{"$_"}; + } else { + $_ + } + } @$argv); + date_parse_finalize($git, $to_parse, $tmp) if $to_parse; + $tmp +} + +# this is for the WWW "q=" query parameter and "lei q --stdin" +# it can't do d:"5 days ago", but it will do d:5.days.ago +sub query_approxidate { + my (undef, $git) = @_; # $_[2] = $query_string (modified in-place) + my $DQ = qq<"\x{201c}\x{201d}>; # Xapian can use curly quotes + $_[2] =~ tr/\x00/ /; # Xapian doesn't do NUL, we use it as a placeholder + my ($terms, $phrase, $to_parse); + $_[2] =~ s{([^$DQ]*)([$DQ][^$DQ]*[$DQ])?}{ + ($terms, $phrase) = ($1, $2); + $terms =~ s!\b(d|rt|dt):(\S+)! + date_parse_prepare($to_parse //= [], $1, $2)!sge; + $terms.($phrase // ''); + }sge; + date_parse_finalize($git, $to_parse, $_[2]) if $to_parse; +} + # read-only sub mset { my ($self, $query_string, $opts) = @_; $opts ||= {}; - my $qp = $self->{qp} //= qparse_new($self); + my $qp = $self->{qp} //= $self->qparse_new; my $query = $qp->parse_query($query_string, $self->{qp_flags}); _do_enquire($self, $query, $opts); } @@ -292,27 +399,24 @@ sub retry_reopen { my ($self, $cb, @arg) = @_; for my $i (1..10) { if (wantarray) { - my @ret; - eval { @ret = $cb->($self, @arg) }; + my @ret = eval { $cb->($self, @arg) }; return @ret unless $@; } else { - my $ret; - eval { $ret = $cb->($self, @arg) }; + my $ret = eval { $cb->($self, @arg) }; return $ret unless $@; } # Exception: The revision being read has been discarded - # you should call Xapian::Database::reopen() if (ref($@) =~ /\bDatabaseModifiedError\b/) { - warn "reopen try #$i on $@\n"; reopen($self); } else { # let caller decide how to spew, because ExtMsg queries # get wonky and trigger: # "something terrible happened at .../Xapian/Enquire.pm" - die; + Carp::croak($@); } } - die "Too many Xapian database modifications in progress\n"; + Carp::croak("Too many Xapian database modifications in progress\n"); } sub _do_enquire { @@ -341,22 +445,21 @@ sub _enquire_once { # retry_reopen callback my $enquire = $X{Enquire}->new($xdb); $enquire->set_query($query); $opts ||= {}; - my $desc = !$opts->{asc}; my $rel = $opts->{relevance} // 0; - if ($rel == -1) { # ORDER BY docid/UID + if ($rel == -2) { # ORDER BY docid/UID (highest first) + $enquire->set_weighting_scheme($X{BoolWeight}->new); + $enquire->set_docid_order($ENQ_DESCENDING); + } elsif ($rel == -1) { # ORDER BY docid/UID (lowest first) $enquire->set_weighting_scheme($X{BoolWeight}->new); $enquire->set_docid_order($ENQ_ASCENDING); } elsif ($rel == 0) { - $enquire->set_sort_by_value_then_relevance(TS, $desc); - } elsif ($rel == -2) { - $enquire->set_weighting_scheme($X{BoolWeight}->new); - $enquire->set_docid_order($ENQ_DESCENDING); + $enquire->set_sort_by_value_then_relevance(TS, !$opts->{asc}); } else { # rel > 0 - $enquire->set_sort_by_relevance_then_value(TS, $desc); + $enquire->set_sort_by_relevance_then_value(TS, !$opts->{asc}); } # `mairix -t / --threads' or JMAP collapseThreads - if ($opts->{thread} && has_threadid($self)) { + if ($opts->{threads} && has_threadid($self)) { $enquire->set_collapse_key(THREADID); } $enquire->get_mset($opts->{offset} || 0, $opts->{limit} || 50); @@ -364,7 +467,7 @@ sub _enquire_once { # retry_reopen callback sub mset_to_smsg { my ($self, $ibx, $mset) = @_; - my $nshard = $self->{nshard} // 1; + my $nshard = $self->{nshard}; my $i = 0; my %order = map { mdocid($nshard, $_) => ++$i } $mset->items; my @msgs = sort { @@ -377,7 +480,7 @@ sub mset_to_smsg { sub stemmer { $X{Stem}->new($LANG) } # read-only -sub qparse_new ($) { +sub qparse_new { my ($self) = @_; my $xdb = xdb($self); @@ -395,8 +498,8 @@ sub qparse_new ($) { $cb->($qp, $NVRP->new(DT, 'dt:')); # for IMAP, undocumented for WWW and may be split off go away - $cb->($qp, $NVRP->new(BYTES, 'bytes:')); - $cb->($qp, $NVRP->new(TS, 'ts:')); + $cb->($qp, $NVRP->new(BYTES, 'z:')); + $cb->($qp, $NVRP->new(TS, 'rt:')); $cb->($qp, $NVRP->new(UID, 'uid:')); while (my ($name, $prefix) = each %bool_pfx_external) { @@ -430,7 +533,7 @@ EOF sub help { my ($self) = @_; - $self->{qp} //= qparse_new($self); # parse altids + $self->{qp} //= $self->qparse_new; # parse altids my @ret = @HELP; if (my $user_pfx = $self->{-user_pfx}) { push @ret, @$user_pfx; @@ -444,4 +547,34 @@ sub int_val ($$) { sortable_unserialise($val) + 0; # PV => IV conversion } +sub get_pct ($) { # mset item + # Capped at "99%" since "100%" takes an extra column in the + # thread skeleton view. says the value isn't + # very meaningful, anyways. + my $n = $_[0]->get_percent; + $n > 99 ? 99 : $n; +} + +sub xap_terms ($$;@) { + my ($pfx, $xdb_or_doc, @docid) = @_; # @docid may be empty () + my %ret; + my $end = $xdb_or_doc->termlist_end(@docid); + my $cur = $xdb_or_doc->termlist_begin(@docid); + for (; $cur != $end; $cur++) { + $cur->skip_to($pfx); + last if $cur == $end; + my $tn = $cur->get_termname; + $ret{substr($tn, length($pfx))} = undef if !index($tn, $pfx); + } + wantarray ? sort(keys(%ret)) : \%ret; +} + +# get combined docid from over.num: +# (not generic Xapian, only works with our sharding scheme) +sub num2docid ($$) { + my ($self, $num) = @_; + my $nshard = $self->{nshard}; + ($num - 1) * $nshard + $num % $nshard + 1; +} + 1;