<?xml version="1.0" encoding="windows-1252"?>
<node id="187283" title="(tye)Re: Newest Super Search" created="2002-08-02 22:58:21" updated="2005-07-28 10:43:28">
<type id="11">
note</type>
<author id="22609">
tye</author>
<data>
<field name="doctext">
&lt;p&gt;
The only modules are DBI, CGI, and Everything.  Most of the actual code
is included below.
&lt;/p&gt;&lt;p&gt;
This is a pretty specialized situation.  For example, we can't allow any
single query to run very long since MySQL was designed assuming some
aspects of the threading model (light-weight processes) that aren't
present on FreeBSD.  So a single long-running query can nearly lock up
access to the database for everyone.
&lt;/p&gt;&lt;readmore&gt;&lt;p&gt;
Another MySQL quirk is that even when you use DBI, once you submit a query
MySQL does the whole dang thing before it lets you get the first matching
record back.  If it weren't for this quirk, I could work around the
previous problem (in many cases) by terminating the long-running query
after I'd fetched enough records (or taken too much time).  But I can't,
so I have to ensure that each query will finish fairly quickly in all
cases.
&lt;/p&gt;&lt;p&gt;
And we tried using MySQL's "full-text search" features.  I pretty much
hated the results (can't search for 3-letter words, for example).  But
worse, it still had "worst case" situations where a single search could
end up locking up the whole site.
&lt;/p&gt;&lt;p&gt;
I remember my first run-ins with full-text search (in the '80s).  It
sounds so nice but in practice I find that it rarely works very well.  It
usually takes lots and lots of practice to learn how to do a decent
search that gives you back something you are looking for but not buried
amid 4000 things you don't want.
&lt;/p&gt;&lt;p&gt;
Well, I haven't noticed it get much better.  Sure, there are some "words"
that are unique enough that searching on them works great.  But way too
much of the time you don't get that lucky.  I find that I almost always
prefer searching an index than trying my luck at "full-text search".
&lt;/p&gt;&lt;p&gt;
Except, of course, for [google://Google]!  The miracle of Google is how
they sort the results.  Trying to imitate Google's ingenious sorting
technique (what we know of it) seemed like too much work for me to pull
off in my spare time.
&lt;/p&gt;&lt;p&gt;
Now, we can use Google.  But I've tried using Google against PerlMonks
(directly and through "thepen") and it just doesn't work that well.
&lt;/p&gt;&lt;p&gt;
And lots of times I don't want to search for "words".  Even Google doesn't
do a very good job if you want to search for parts of "words".  It does
pretty good at search for "phrases".  But throw lots of punctuation in and
it and I often don't agree on what a "word" is.  And Perl code certain
often has lots of punctuation in it.
&lt;/p&gt;&lt;p&gt;
So I realy liked the original PerlMonks simple and Super searches.  They
were pretty much just substring searches that required "and" (match
&lt;i&gt;all&lt;/i&gt; of the criteria listed).  I found that I was successful with
them much more frequently than I am with full-text searches.  But, they
are resource intensive.
&lt;/p&gt;&lt;p&gt;
So I've heard quite a few people's suggestions on ways to build a word
lists and lists of "stop" words that you aren't allowed to search for,
etc. to roll our own full-text search.  Or some canned full-text search
to use.  Well, I encourage anyone who wants to to pursue such.  We've
even added XML tickers so external search engines can be built more
easily.  But I doubt &lt;i&gt;I&lt;/i&gt; will find them very useful.  (BTW, my
quick glance at DBIx::FullTextSearch makes me think it wants to create
its own tables so you might want to try making an external search out
of it -- if it turns out nice, we can probably find a place to host it.)
&lt;/p&gt;&lt;p&gt;
Anyway, in trying to find out why the site was locking up, I made changes
to "mytop" (like "top" but for MySQL queries -- it shows the current queries
against database and how long they've been running) and did lots of watching
and fixing.  And I've come to understand some of what makes a query slow
in MySQL.
&lt;/p&gt;&lt;p&gt;
I kind of like MySQL's query optimizer.  It is quite simple (compared to
some of the ones I deal with) and so it is much easier to predict what it
is going to do.  I spent years writing database code where we didn't use
SQL, we would seek to a point in an index and read forward or backward
from there.  So I think of ways to get results efficiently at a &lt;b&gt;low&lt;/b&gt;
level.  But sometimes it is dang hard to write &lt;i&gt;SQL&lt;/i&gt; that convinces
the optimizer to do what I thought up (so, somewhat paradoxically, a query
that I could perform very efficiently will be executed very inefficiently
by the SQL server).
&lt;/p&gt;&lt;p&gt;
So, the basic lesson in this case is that you want to make a query that
can find all of the records that it needs by searching a fairly small
range of one index and then set a reasonable LIMIT.  Then you have to
tell SQL to order the records based on that index (that makes it much more
likely that the optimizer will actually choose to use the index that you
gave to it on a silver platter).
&lt;/p&gt;&lt;p&gt;
Of course, many queries can't be fullfilled that way so I end up findng
the matches by doing a sequence of such queries.
&lt;/p&gt;&lt;p&gt;
If you don't specify a small range, then you could spend too much time
searching a large number of records (and the optimizer also "understands"
this and so becomes more likely to ignore your preferred index) which
would lock up [MySQL on FreeBSD] (a joke user someone made for me when I
started blaming all site problems on this combination).  And you have
to set a reasonable LIMIT or else you could spend too much time sending
the matching information across (with the same result).  And since you
specified the sort order, you can also efficiently continue where you
left off (without having to use something like MySQL's "LIMIT 50,150" which
probably requires the first part of the search basically be repeated).
&lt;/p&gt;&lt;p&gt;
So that is what the newest [Super Search] does.  And, just in case I don't
know the MySQL optimizer as well as I think I do, it asks the optimizer
to explain its plan before letting MySQL try to perform the query.  If it
doesn't decide to use an index that requires only a fairly small number
of records to be read, then I won't run the query.
&lt;/p&gt;&lt;p&gt;
I've made some fairly minor changes to the code and removed some code
that is just for future features in hopes of it being slightly easier
to understand.  Some changes are things I planned to do to clean up
the code but I just did them quickly here and haven't tested.  So if
you see a syntax error, it is probably just a typo. /:
&lt;/p&gt;&lt;p&gt;
Also, writing code for Everything makes it hard to write utility
subroutines so this isn't really "factored" like I would normally
write code.  Okay, enough excuses. ;)
&lt;/p&gt;&lt;p&gt;
&lt;b&gt;Update&lt;/b&gt;: I forgot one excuse. (:  The CGI parameters names were chosen to be very short (but still somewhat mnemonic) because I plan to add a feature where you can cut'n'paste a URL that performs the search that you have crafted for repeating it later or referring to it in a node, etc.
&lt;/p&gt;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- [tye] (but my friends call me "Tye")
&lt;code&gt;[%
    my $html= '';
    my @errors;

    my @types= q(
        Wi  perlquestion            SoPW        Seekers of Perl Wisdom
        D   monkdiscuss             PMD         PM Discussions
        Ob  obfuscated              Obfu        Obfuscation
        CU  CUFP                    CUFP        Cool Uses For Perl
        CC  sourcecode              Code        Code Catacombs
        CQ  categorized_question    CatQ        Categorized Questions
        CA  categorized_answer      CatA        Categorized Answers
        Hlp sitefaqlet              Help        Monk Help

        Tu  perltutorial            Tut         Tutorial
        U   user                    User
        Po  poem                    Poem
        Cr  perlcraft               Craft
        Sn  snippet                 Snippet
        N   perlnews                News
        Q   quest                   Quest
        Pol poll                    Poll

        M   perlmeditation          Med         Meditations
        SP  scratchpad              SPad        Scratch Pad
        MR  modulereview            ModRev      Module Review
        BR  bookreview              BkRev       Book Review
        pPd perlman                 perlman     Perl Manpage
        pFn perlfunc                perlfunc    Perl Function
        pFq perlfaq_nodetype        perlfaq     Perl FAQ

    ) =~ /(\S.*\S)/g;

    my( %abbr, %desc, %typeId, %link );
    for(  @types  ) {
        my( $abbr, $type, $link, $desc )= split " ", $_, 4;
        $type =~ tr/_/ /;
        my $id= getId( getType($type) );
        $typeId{$type}= $id;
        $abbr{$type}= $abbr;
        $desc{$type}= $desc || $link;
        $link{ $id }= $link;
        $_= $type;
    }

    my %typeTable= qw(
        snippet         snippet
        bookreview      review
        modulereview    review
        sourcecode      sourcecode
        poll            polls
    );
    my %fieldOfTable= (
        snippet =&gt; [qw( snippetdesc snippetcode )],
        review =&gt; [qw( itemdescription usercomment doctext )],
        sourcecode =&gt; [qw( codedescription doctext )],
        polls =&gt; ['choices'],
        #user =&gt; [qw( scratchpad )],    # Needs to change
    );

    my @sects;
    my $sects= do {
        my $negSects= ( $q-&gt;param("xs") )[-1] ? 1 : 0;
        my %checked;
        @checked{keys %abbr}= map {
            ( ()= $q-&gt;param($abbr{$_}) ) ? 1 : 0;
        } keys %abbr;
        @sects= grep $negSects != $checked{$_}, keys %abbr;
        @sects= @types   if  ! @sects;
        join ", ", map $typeId{$_}, @sects;
    };

    my @criteria;

    my @users= grep length, $q-&gt;param("a");
    if(  1 == @users  &amp;&amp;  $users[0] =~ m#^(\s*\[[^\]]+\])+\s*$#g  ) {
        @users= $users[0] =~ m#\[([^\]]+)\]#g;
    }
    for my $user (  @users  ) {
        my $type = "user";
        my $reason = "does not exist";
        my $U;
        if(  $user !~ m#^id://(\d+)$#  ) {
            $U = getNode( $user, "user" );
        } else {
            ( $type, $user ) = ( "node ID", $1 );
            $U = getNodeById( $user );
            if(  $U  &amp;&amp;  "user" ne $U-&gt;{type}{title}  ) {
               undef $U;
               $reason = "is not a user";
            }
        }
        if(  $U  ) {
            $user= getId($U);
        } else {
            $user= 0;
            push @errors, qq[\u$type "] . $query-&gt;escapeHTML($user)
                . qq[" $reason.&lt;br /&gt;];
        }
    }
    @users= grep $_, @users;
    my $negAuthor= ( $q-&gt;param("xa") )[-1] ? 1 : 0;
    $negAuthor= $negAuthor ? " NOT" : "";
    if(  @users  ) {
        push @criteria, "n.author_user$negAuthor IN ( "
            . join( ", ", @users ) . " )";
    }

    my $replies= ( $q-&gt;param("re") )[-1];
    $q-&gt;param( "re", $replies );
    my $xRoots= ()= $q-&gt;param("xr");
    my $note= getId( getType("note") );

    push @criteria, do {
        if(  "N" eq $replies  ) {           # No replies:
            push @errors,
                "No root nodes and no replies means no search.&lt;br /&gt;"
              if  $xRoots;
            $xRoots
              ? "n.node_id = 0"                  # Find nothing!
              : "n.type_nodetype IN ( $sects )"; # Just sel. roots
        } elsif(  "A" eq $replies           # All replies (same as
              ||  @sects == @types  ) {     #  re.s from all sect.s):
            $xRoots
              ? "n.type_nodetype = $note"              #Just all re.s
              : "n.type_nodetype IN ( $note, $sects )";#^ + sel. roots
        } else {                            # Replies from sel. sects:
            $q-&gt;param( "re", undef );
            my $c= "( n.type_nodetype = $note"
                . " AND root.type_nodetype IN ( $sects ) )";
            $xRoots
              ? $c                                         # Sel. re.s
              : "( n.type_nodetype IN ( $sects ) OR $c )"; # ^ + roots
        }
    };

    # ( Head Body ) + ( Includes Excludes ) + ( Terms Seperator )

    my $getTerms= sub {
        my( $textParam, $sepParam )= @_;
        my $str= $q-&gt;param( $textParam );
        my $sep= $q-&gt;param( $sepParam );
        $sep =~ s/^\s*//;
        $sep =~ s/\s*$//;
        $sep= " "   if  ! length $sep;
        $q-&gt;param( $sepParam, $sep );
        my @terms= grep length, split /\Q$sep/, $str;
        $q-&gt;param( $textParam, join $sep, @terms );
        return @terms;
    };
    my @headHas= $getTerms-&gt;( "HIT", "HIS" );
    my @headLacks= $getTerms-&gt;( "HET", "HES" );
    my @bodyHas= $getTerms-&gt;( "BIT", "BIS" );
    my @bodyLacks= $getTerms-&gt;( "BET", "BES" );

    my( @tables, @fields );
    push @tables, 'note',
        "left join node as root on root.node_id=root_node";

    if(  @bodyHas  ||  @bodyLacks  ) {
        my( %tables, %fields );
        push @sects, 'note'
            unless  'N' eq $replies;
        for my $type (  @sects  ) {
            if(  $typeTable{$type}  ) {
                ++$tables{ $typeTable{$type} };
                ++$fields{$_}
                  for  @{  $fieldOfTable{ $typeTable{$type} }  };
            } else {
                ++$tables{document};
                ++$fields{doctext};
            }
        }
        push @tables, keys %tables;
        push @fields, keys %fields;
    }

    my $tables= "node as n";
    for my $table (  @tables  ) {
        if(  $table =~ / /  ) {
            $tables .= "\n$table";
        } else {
            $tables .= "\nleft join $table on ${table}_id=n.node_id";
        }
    }

    if(  @headHas  ) {
        push @criteria, map {
            my $quoted= $_;
            $quoted =~ s#\\#\\\\#g;     # MySQL bug
            $quoted =~ s#(['%_\\\[\]])#\\$1#g;
            "n.title LIKE '%$quoted%'";
        } @headHas;
    }

    if(  @headLacks  ) {
        push @criteria, map {
            my $quoted= $_;
            $quoted =~ s#\\#\\\\#g;     # MySQL bug
            $quoted =~ s#(['%_\\\[\]])#\\$1#g;
            "n.title NOT LIKE '%$quoted%'";
        } @headLacks;
    }

    if(  @bodyHas  ) {
        push @criteria, map {
            my $quoted= $_;
            $quoted =~ s#\\#\\\\#g;     # MySQL bug
            $quoted =~ s#(['%_\\\[\]])#\\$1#g;
            "( " . join( " OR ", map {
                "$_ LIKE '%$quoted%'";
            } @fields ) . " )";
        } @bodyHas;
    }

    if(  @bodyLacks  ) {
        push @criteria, map {
            my $quoted= $_;
            $quoted =~ s#\\#\\\\#g;     # MySQL bug
            $quoted =~ s#(['%_\\\[\]])#\\$1#g;
            map {
                "$_ NOT LIKE '%$quoted%'";
            } @fields;
        } @bodyLacks;
    }

    my $oldFirst= ! ( $q-&gt;param("nf") )[-1];
    my $n0= $q-&gt;param("n0");
    my $doSearch=  $n0  &amp;&amp;  ! @errors;
    my $lastNode= $DB-&gt;sqlSelect( "max(node_id)", "node" );
    $n0 ||= $oldFirst ? 1 : $DB-&gt;sqlSelect( "max(node_id)", "node" );

    push @criteria, "n.node_id BETWEEN !TBD!";

    my $limit= 50;
    if(  $doSearch  ) {
        require Time::HiRes;
        my @matches;
        my $start= $n0;
        my $startTime= Time::HiRes::time();
        while(  1  ) {

            my( $min, $max );
            if(  $oldFirst  ) {
                ( $min, $max )= ( $n0, $n0+10000 );
                $max= 1000 * int( $max/1000 + 0.5 );
                $max= $lastNode   if  $lastNode &lt; $max;
            } else {
                ( $min, $max )= ( $n0-10000, $n0 );
                $min= 1000 * int( $min/1000 + 0.5 );
                $min= 1   if  $min &lt; 1;
            }
            $criteria[-1]= "n.node_id BETWEEN $min AND $max";

            my $explainTime= Time::HiRes::time();
            my $query= qq[
                SELECT n.node_id, n.title, n.type_nodetype,
                       n.author_user, n.createtime, root.type_nodetype
                FROM   $tables
                WHERE  ] . join( " AND ", @criteria ) . qq[
                ORDER BY n.node_id
                LIMIT ] . ( $limit - @matches );
            my $explain= $DB-&gt;getDatabaseHandle()-&gt;prepare(
                "EXPLAIN $query" );
            $explain-&gt;execute();
            my $rec= $explain-&gt;fetchrow_hashref();
            $explain-&gt;finish();
            my $key_used= $rec-&gt;{key};
            my $key_rows= $rec-&gt;{rows};
            my $comment= $rec-&gt;{Comment};

            $explainTime= Time::HiRes::time() - $explainTime;
            if(  3 &lt; $explainTime  ) {
                push @errors, ( $start==$n0 ? "Q" : "Remainder of q" )
                    . qq[uery was not run;  Server is too busy ]
                    . sprintf(
                        qq[("explain" took %.2f seconds)&lt;br /&gt;],
                        $explainTime
                      );
                last;
            }

            unless(  "PRIMARY" eq $key_used
                 or  "" ne $key_used  &amp;&amp;  $key_rows &lt; 10000
            ) {
                push @errors, ( $start==$n0 ? "Q" : "Remainder of q" )
                    . "uery would not run quickly"
                    . ( $comment ? " ($comment)" : "" )
                    . ".&lt;br /&gt;\n";
                last;
            }

            my $cursor= $DB-&gt;sqlSelectMany(
                "n.node_id as node_id, n.title as title,
                 n.type_nodetype as type_nodetype,
                 n.author_user as author_user, n.createtime as createtime,
                 root.type_nodetype as root_nodetype",
                $tables,
                join( " AND ", @criteria ),
                "ORDER BY n.node_id LIMIT " . ( $limit - @matches ),
            );
            my $rec;
            while(  $rec= $cursor-&gt;fetchrow_hashref()  ) {
                push @matches, $rec;
            }
            $cursor-&gt;finish();

            if(  @matches &lt; $limit  ) {
                $n0= 1 + $max;
            } else {
                $n0= 1 + $matches[-1]{node_id};
                last;
            }
            last   if  $lastNode &lt; $n0;
            my $runTime= Time::HiRes::time() - $startTime;
            if(  10 &lt; $runTime  ) {
                push @errors, ( $start==$n0 ? "Q" : "Remainder of q" )
                    . qq[uery was not run ]
                    . sprintf(
                        qq[(used %.2f seconds so far)&lt;br /&gt;],
                        $runTime
                      );
                last;
            }
        }

        my $startDate= ( split " ", $DB-&gt;sqlSelect(
            "createtime","node","node_id=$start") )[0];
        my $endDate= ( split " ", $DB-&gt;sqlSelect(
            "createtime","node","node_id=".($n0-1)) )[0];
        my $matches= @matches;

        $html .= qq[&lt;p&gt;&lt;hr /&gt;
            &lt;b&gt;Found $matches node] . ( 1==$matches ? "" : "s" )
            . qq[&lt;/b&gt;between IDs $start ($startDate) and ] . ($n0-1)
            . qq[($endDate)];

        if( @bodyHas || @bodyLacks || @headHas || @headLacks ) {
            $html .= qq[&lt;br /&gt;where ] . join qq[&lt;br /&gt;and ],
                map {
                    my( $desc, @terms )= @$_;
                    if(  ! @terms  ) {
                        ();
                    } else {
                        $desc . join( ", ", map {
                            '"&lt;tt&gt;' . $q-&gt;escapeHTML($_) . '&lt;/tt&gt;"'
                        } @terms )
                    }
                } ["any text contains all of ",@bodyHas],
                  ["no text contains any of ",@bodyLacks],
                  ["title contains all of ",@headHas],
                  ["title doesn't contain any of ",@headLacks],
        }

        if(  @users  ) {
            $html .= qq[&lt;br /&gt;written by ]
                . ( $negAuthor ? "anyone but " : "any of " )
                . join ", ", map linkNode($_), @users;
        }

        $html .= qq[&lt;/p&gt;\n];

        my $linkType= sub {
            my( $typeId )= @_;
            return  linkNode( $typeId, $link{$typeId} );
        };

        $html .= qq[&lt;p&gt;&lt;table width="100%"&gt;];
        for my $rec (  @matches  ) {
            $html .= $q-&gt;Tr(
                $q-&gt;td( ( split " ", $rec-&gt;{createtime} )[0] ),
                $q-&gt;td( linkNode($rec-&gt;{author_user}) ),
                $q-&gt;td( linkNode($rec-&gt;{node_id},$rec-&gt;{title}) ),
                $q-&gt;td(
                    $note == $rec-&gt;{type_nodetype}
                      ? "Re:" . $linkType-&gt;( $rec-&gt;{root_nodetype} )
                      : $linkType-&gt;( $rec-&gt;{type_nodetype} )
                ),
            );
        }
        $html .= qq[&lt;/table&gt;&lt;/p&gt;\n];
    }

    $q-&gt;param( "n0", $n0 );

    if(  $doSearch  ) {
        if(  $oldFirst  &amp;&amp;  $n0 &lt; $lastNode
         ||  ! $oldFirst  &amp;&amp;  1 &lt; $n0  ) {
            my( $min, $max )=
                $oldFirst ? ( $n0, $lastNode ) : ( 1, $n0 );
            $html .= qq[&lt;p&gt;
                Press a "Search" button (below) &lt;b&gt;to continue&lt;/b&gt;
                (IDs $min thru $max).
                &lt;/p&gt;\n];
        }
        $html .= "&lt;hr /&gt;";
    }


    $html .= '&lt;p&gt;' . linkNode( $NODE, "Reset search form" ) . "&lt;/p&gt;\n";

    $html .= $/ . htmlcode('openform') . $/;

    $html .= qq[&lt;p&gt;
        Match &lt;b&gt;text&lt;/b&gt; containing ] . $q-&gt;textfield( "BIT", "", 60 )
        . qq[&lt;br /&gt;(seperate strings with ]
        . $q-&gt;textfield( "BIS", " ", 2 ) . qq[ -- default is spaces)
        &lt;br /&gt;] . $q-&gt;radio_group( "BH", [ "0", "1" ], "1", 0,
            { 0=&gt;"Don't match -or-", 1=&gt;"Also match" },
        ) . qq[ &lt;b&gt;titles&lt;/b&gt; against above.&lt;/p&gt;];

    $html .= $/ . $q-&gt;submit("","Search") . qq[
        Please be patient after submitting your search.\n];

    $html .= qq[&lt;p&gt;
        Match &lt;b&gt;titles&lt;/b&gt; containing ]
        . $q-&gt;textfield( "HIT", "", 60 )
        . qq[&lt;br /&gt;(separate strings with ]
        . $q-&gt;textfield( "HIS", " ", 2 )
        . qq[ -- default is spaces)&lt;/p&gt;];

    $html .= qq[&lt;p&gt;
        ] . $q-&gt;radio_group(
            "xa", [ "0", "1" ], "0", 0, { 0=&gt;"Match -or-", 1=&gt;"Exclude" },
        ) . qq[ &lt;b&gt;authors&lt;/b&gt; ] . $q-&gt;textfield( "a", "", 20 )
        . qq[&lt;br /&gt;
        (use "&amp;#91;one&amp;#93; &amp;#91;two&amp;#93;" to list multiple authors)
        &lt;br /&gt;(Searching by author doesn't work for Categorized
        Questions and Answers yet.)&lt;/p&gt;];

    $html .= qq[&lt;p&gt;
        Search ] . $q-&gt;radio_group(
            -name=&gt;"nf", -values=&gt;[ "1", "0" ], -default=&gt;"0",
            -labels=&gt;{ 1=&gt;"Newest first -or-", 0=&gt;"Oldest first" },
            -disabled=&gt;"disabled",
        ) . qq[,&lt;br /&gt;starting at node
        ] . $q-&gt;textfield( "n0", "0", 12 ) . qq[ (]
        . ( split " ", $DB-&gt;sqlSelect(
                "createtime","node","node_id=$n0") )[0]
        . qq[).&lt;/p&gt;];

    $html .= qq[&lt;!-- &lt;p&gt; Show {10|20|50} matches per page.&lt;/p&gt; --&gt;];

    $html .= qq[&lt;p&gt;
        Search ] . $q-&gt;radio_group(
            "xs", [0,1], 0, 0, {0=&gt;"only -or-",1=&gt;"all but"},
        ) . qq[&lt;br /&gt;the following &lt;b&gt;sections&lt;/b&gt;:];
    $html .= qq[&lt;ul&gt;] . $q-&gt;table(
        map(
            "\n    "
            . $q-&gt;Tr(
                map "\n        " . $q-&gt;td(
                    $q-&gt;checkbox(
                        -name =&gt; $abbr{$types[$_]},
                        -value =&gt; "",
                        -label =&gt; $desc{$types[$_]},
                        "scratchpad" eq $types[$_]
                            ? ( -disabled =&gt; "disabled" )
                            : (),
                        )
                ), @$_
            ), map( [ $_, $_+8, $_+16 ], 0..6 ), [7,15]
        ), $/
    ) . qq[&lt;/ul&gt;\n];

    $html .= qq[&lt;p&gt;
        &lt;i&gt;Skip&lt;/i&gt; &lt;b&gt;text&lt;/b&gt; containing ]
        . $q-&gt;textfield( "BET", "", 60 )
        . qq[&lt;br /&gt;(seperate strings with ]
        . $q-&gt;textfield( "BES", " ", 2 )
        . qq[ -- default is spaces)&lt;br /&gt;
        (Does not exclude based on titles)&lt;/p&gt;];

    $html .= qq[&lt;p&gt;
        &lt;i&gt;Skip&lt;/i&gt; &lt;b&gt;titles&lt;/b&gt; containing ]
        . $q-&gt;textfield( "HET", "", 60 )
        . qq[&lt;br /&gt;(seperate strings with ]
        . $q-&gt;textfield( "HES", " ", 2 )
        . qq[ -- default is spaces)&lt;/p&gt;];

    $html .= qq[&lt;/p&gt;&lt;p&gt;\n] . $q-&gt;radio_group(
        "xr", ["0","1"], "0", 1, {
            0 =&gt; "Include &lt;b&gt;root&lt;/b&gt; nodes from selected sections",
            1 =&gt; "Don't include &lt;b&gt;root&lt;/b&gt; nodes",
        },
    );

    $html .= qq[&lt;/p&gt;&lt;p&gt;\n] . $q-&gt;radio_group(
        "re", [qw( A S N )], "S", 1, {
            A =&gt; "Include &lt;b&gt;replies&lt;/b&gt; from &lt;i&gt;any&lt;/i&gt; section",
            S =&gt; "Include &lt;b&gt;replies&lt;/b&gt; from &lt;i&gt;selected&lt;/i&gt; sections",
            N =&gt; "&lt;i&gt;Don't&lt;/i&gt; include &lt;b&gt;replies&lt;/b&gt;",
        },
    );

    $html .= qq[\n&lt;p&gt;] . $q-&gt;submit("","Search") . qq[
        Please be patient after submitting your search.&lt;/p&gt;\n];

    $html .= qq[&lt;/form&gt;\n];

    $html .= qq[\n&lt;!-- CGI::VERSION=$CGI::VERSION --&gt;\n];

    return "&lt;b&gt;@errors&lt;/b&gt;$html";
%]
&lt;/code&gt;
</field>
<field name="root_node">
187222</field>
<field name="parent_node">
187255</field>
</data>
</node>
