[dw-free] This is the first pass at the search system for Dreamwidth. For more information on how t
[commit: http://hg.dwscoalition.org/dw-free/rev/4dedfba1fe16]
This is the first pass at the search system for Dreamwidth. For more
information on how to set this up, please see:
http://wiki.dwscoalition.org/notes/Setting_Up_Search
Patch by
mark.
Files modified:
This is the first pass at the search system for Dreamwidth. For more
information on how to set this up, please see:
http://wiki.dwscoalition.org/notes/Setting_Up_Search
Patch by
![[staff profile]](https://www.dreamwidth.org/img/silk/identity/user_staff.png)
Files modified:
- bin/schedule-copier-jobs
- bin/upgrading/en.dat
- bin/worker/sphinx-copier
- bin/worker/sphinx-search-gm
- cgi-bin/DW/Logic/ProfilePage.pm
- cgi-bin/DW/Logic/UserLinkBar.pm
- cgi-bin/DW/Pay.pm
- cgi-bin/LJ/Entry.pm
- cgi-bin/ljprotocol.pl
- htdocs/img/silk/profile/search.png
- htdocs/search.bml
-------------------------------------------------------------------------------- diff -r 6865fbf1b5da -r 4dedfba1fe16 bin/schedule-copier-jobs --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/schedule-copier-jobs Sat Jul 25 04:48:46 2009 +0000 @@ -0,0 +1,35 @@ +#!/usr/bin/perl +# +# bin/schedule-copier-jobs +# +# A simple job that schedules copier tasks for the sphinx copier. This should +# be run whenever you want to clean up the database of new/old data. Probably +# no more often than once every few weeks. +# +# Authors: +# Mark Smith <mark@dreamwidth.org> +# +# Copyright (c) 2009 by Dreamwidth Studios, LLC. +# +# This program is free software; you may redistribute it and/or modify it under +# the same terms as Perl itself. For a copy of the license, please reference +# 'perldoc perlartistic' or 'perldoc perlgpl'. +# + +use strict; +use lib "$ENV{LJHOME}/cgi-bin"; +require 'ljlib.pl'; + +die "Need to have Sphinx search enabled.\n" + unless @LJ::SPHINX_SEARCHD; + +my $dbr = LJ::get_db_reader() or die; +my $sth = $dbr->prepare( q{SELECT userid FROM user WHERE journaltype IN ('P','C')} ); +$sth->execute; + +my $sclient = LJ::theschwartz() or die; + +while ( my ( $userid ) = $sth->fetchrow_array ) { + warn "Scheduling $userid ...\n"; + $sclient->insert_jobs( TheSchwartz::Job->new_from_array( 'DW::Worker::Sphinx::Copier', { userid => $userid } ) ); +} diff -r 6865fbf1b5da -r 4dedfba1fe16 bin/upgrading/en.dat --- a/bin/upgrading/en.dat Sat Jul 25 04:43:19 2009 +0000 +++ b/bin/upgrading/en.dat Sat Jul 25 04:48:46 2009 +0000 @@ -3633,6 +3633,10 @@ userlinkbar.postentry=Post an Entry userlinkbar.postentry.title=Post to your journal +userlinkbar.search=Search for Entries + +userlinkbar.search.title=Search for entries in this account + userlinkbar.sendmessage=Send Message userlinkbar.sendmessage.title=Send a private message to this user diff -r 6865fbf1b5da -r 4dedfba1fe16 bin/worker/sphinx-copier --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/worker/sphinx-copier Sat Jul 25 04:48:46 2009 +0000 @@ -0,0 +1,155 @@ +#!/usr/bin/perl +# +# bin/worker/sphinx-copier +# +# Responsible for ensuring a user is up to date when they make a new post or +# edit an existing one. +# +# Authors: +# Mark Smith <mark@dreamwidth.org> +# +# Copyright (c) 2009 by Dreamwidth Studios, LLC. +# +# This program is free software; you may redistribute it and/or modify it under +# the same terms as Perl itself. For a copy of the license, please reference +# 'perldoc perlartistic' or 'perldoc perlgpl'. +# + +use strict; +use lib "$ENV{LJHOME}/cgi-bin"; +use LJ::Worker::TheSchwartz; + +schwartz_decl( 'DW::Worker::Sphinx::Copier' ); +schwartz_work(); + +# ============================================================================ +package DW::Worker::Sphinx::Copier; +use base 'TheSchwartz::Worker'; +use Encode; +use DW::Pay; +use LJ::DBUtil; + +sub work { + my ( $class, $job ) = @_; + my $a = $job->arg; + + my $u = LJ::load_userid( $a->{userid} ) + or die "Invalid userid: $a->{userid}.\n"; + my $dbto = LJ::get_dbh( 'sphinx_search' ) + or die "Unable to connect to Sphinx search database.\n"; + my $p_revtime = LJ::get_prop( log => 'revtime' ) + or die "No logprop revtime?\n"; + + # annotate copier is going + warn "[$$] Sphinx copier started for " . $u->user . "(" . $u->id . ").\n"; + + # we have to use utf8 when we write to the db + $dbto->do( 'SET NAMES \'utf8\'' ); + + # if this is a free user, then it's easy + my $ps = DW::Pay::get_paid_status( $u ); + unless ( $ps && ( $ps->{permanent} || $ps->{expiresin} > 0 ) ) { + warn "[$$] Removing " . $u->user . "(" . $u->id . ") from the index [free user]...\n"; + $dbto->do( 'DELETE FROM posts_raw WHERE journal_id = ?', undef, $u->id ); + die $dbto->errstr if $dbto->err; + return $job->completed; + } + + # they're a paid user of some sort, so get the posts we know about + my $sphinx_times = $dbto->selectall_hashref( + 'SELECT jitemid, revtime FROM posts_raw WHERE journal_id = ?', + 'jitemid', undef, $u->id + ); + die $dbto->errstr if $dbto->err; + + # now get the times for all of their posts from the database (inactive if possible) + my $dbfrom; + if ( $LJ::CLUSTER_PAIR_ACTIVE{$u->clusterid} ) { + $dbfrom = LJ::DBUtil->get_inactive_db( $u->clusterid ); + } + $dbfrom ||= LJ::get_cluster_reader( $u->clusterid ); + die "No dbfrom available.\n" + unless $dbfrom; + + # okay, we're actually getting the times now + my $db_times = $dbfrom->selectall_hashref( + qq{SELECT l.jitemid, UNIX_TIMESTAMP(l.logtime) AS 'createtime', lp.value AS 'edittime' + FROM log2 l LEFT JOIN logprop2 lp ON (l.journalid = lp.journalid AND l.jitemid = lp.jitemid AND lp.propid = ?) + WHERE l.journalid = ?}, + 'jitemid', undef, $p_revtime->{id}, $u->id + ); + die $dbfrom->errstr if $dbfrom->err; + + # three doors: ignore, copy, delete + my ( @copy_jitemids, @delete_jitemids ); + + # now iterate through and find entirely NEW and EDITED posts + foreach my $jitemid ( keys %$db_times ) { + + # we have to decide which time to use. we want the edit time if one is available, + # but we'll use the createtime if not + $db_times->{$jitemid} = $db_times->{$jitemid}->{edittime} || $db_times->{$jitemid}->{createtime}; + $sphinx_times->{$jitemid} = $sphinx_times->{$jitemid}->{revtime}; + + next if exists $sphinx_times->{$jitemid} && + $sphinx_times->{$jitemid} == $db_times->{$jitemid}; + + push @copy_jitemids, $jitemid; + } + + # now find deleted posts + foreach my $jitemid ( keys %$sphinx_times ) { + next if exists $db_times->{$jitemid}; + + warn "[$$] Deleting post #$jitemid.\n"; + push @delete_jitemids, $jitemid; + } + + # deletes are easy... + if ( @delete_jitemids ) { + my $ct = $dbto->do( 'DELETE FROM posts_raw WHERE journal_id = ? AND jitemid IN (' . + join( ',', @delete_jitemids ) . ')', undef, $u->id ) + 0; + die $dbto->errstr if $dbto->err; + + warn "[$$] Actually deleted $ct posts.\n"; + } + + # now to copy entries. this is not done enmasse since the major case will be after a user + # already has most of their posts copied and they are just updating one or two. + foreach my $jitemid ( @copy_jitemids ) { + my $row = $dbfrom->selectrow_hashref( + qq{SELECT l.journalid, l.jitemid, l.posterid, l.security, l.eventtime, lt.subject, lt.event + FROM log2 l INNER JOIN logtext2 lt ON (l.journalid = lt.journalid AND l.jitemid = lt.jitemid) + WHERE l.journalid = ? AND l.jitemid = ?}, + undef, $u->id, $jitemid + ); + die $dbfrom->errstr if $dbfrom->err; + + # have to do some more munging + $row->{is_public} = $row->{security} eq 'public' ? 1 : 0; + $row->{edittime} = $db_times->{$jitemid}; + + # very important, the search engine can't index compressed crap... + foreach ( qw/ subject event / ) { + LJ::text_uncompress( \$row->{$_} ); + $row->{$_} = Encode::decode( 'utf8', $row->{$_} ); + } + + # insert + $dbto->do( 'REPLACE INTO posts_raw (id, journal_id, jitemid, poster_id, is_public, date_posted, title, data, revtime) ' . + 'VALUES (NULL, ?, ?, ?, ?, UNIX_TIMESTAMP(?), ?, ?, ?)', + undef, map { $row->{$_} } qw/ journalid jitemid posterid is_public eventtime subject event edittime /, ); + die $dbto->errstr if $dbto->err; + + # let the viewer know what they missed + warn "[$$] Inserted post #$jitemid for " . $u->user . "(" . $u->id . ").\n"; + } + + # all good + return $job->completed; +} + +sub keep_exit_status_for { 0 } +sub grab_for { 1800 } +sub max_retries { 3 } +sub retry_delay { 1800 } diff -r 6865fbf1b5da -r 4dedfba1fe16 bin/worker/sphinx-search-gm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/worker/sphinx-search-gm Sat Jul 25 04:48:46 2009 +0000 @@ -0,0 +1,112 @@ +#!/usr/bin/perl +# +# sphinx-search-gm +# +# This Gearman worker is responsible for taking a search and issuing it to the +# Sphinx searchd. +# +# Authors: +# Mark Smith <mark@dreamwidth.org> +# +# Copyright (c) 2009 by Dreamwidth Studios, LLC. +# +# This program is free software; you may redistribute it and/or modify it under +# the same terms as Perl itself. For a copy of the license, please reference +# 'perldoc perlartistic' or 'perldoc perlgpl'. +# + +use strict; +use lib "$ENV{LJHOME}/cgi-bin"; +require "ljlib.pl"; + +use Encode; +use Gearman::Worker; +use LJ::Worker::Gearman; +use Sphinx::Search; +use Storable; + +gearman_decl( 'sphinx_search' => \&sphinx_search ); +gearman_work(); + +sub sphinx_search { + my $job = $_[0]; + + my $args = Storable::thaw( $job->arg ) || {}; + return undef + unless $args->{userid} && $args->{query}; + + my $sx = Sphinx::Search->new(); + $sx->SetServer( @LJ::SPHINX_SEARCHD ); + + $sx->SetMatchMode( SPH_MATCH_ALL ) + ->SetSortMode( SPH_SORT_RELEVANCE ) + ->SetMaxQueryTime( 15_000 ) + ->SetFilter( 'journal_id', [ $args->{userid} ] ) + ->SetLimits( $args->{offset} || 0, 20 ); + + $sx->SetFilter( 'is_public', [ 1 ] ) + if $args->{public}; + + my $res = $sx->Query( $args->{query} ); + return undef unless $res; + + # try to build some excerpts of these searches, which involves us loading + # up the exact entry contents... + if ( $res->{total} > 0 ) { + + # this is weird, I push the hashrefs onto @out from $res->{matches} for + # convenience only... they're the same hashrefs you know and love + my @out; + + foreach my $match ( @{ $res->{matches} } ) { + my $entry = LJ::Entry->new( $match->{journal_id}, jitemid => $match->{jitemid} ); + if ( $entry && $entry->valid ) { + # use text only version of event for excerpt purposes. best effort. + $match->{entry} = $entry->event_text; + $match->{entry} =~ s#<(?:br|p)\s*/?># #gi; + $match->{entry} = LJ::strip_html( $match->{entry} ); + + # we don't munge the subject... just clean it + $match->{subject} = $entry->subject_text || '(no subject)'; + + # also useful information that we want for later + $match->{url} = $entry->url; + $match->{tags} = $entry->tag_map; + $match->{security} = $entry->security; + $match->{security} = 'access' + if $match->{security} eq 'usemask' && + $entry->allowmask == 1; + $match->{eventtime} = $entry->eventtime_mysql; + + } else { + # something happened, couldn't get the entry + $match->{entry} = '(sorry, this entry has been deleted or is otherwise unavailable)'; + $match->{subject} = 'Entry deleted or unavailable.'; + } + push @out, $match; + } + + # FIXME: this should use some other index name than 'test1' heh, and we should probably + # try to figure out the language of the journal being searched (or the searcher?) and use + # an appropriate stemming library? (future expansion opportunity) + my $exc = $sx->BuildExcerpts( [ map { $_->{entry} } @out ], 'test1stemmed', $args->{query}, {} ); + + # if we have a matching number of excerpts to events, then we can determine + # which one goes with which post. + if ( scalar( @out ) == scalar( @$exc ) ) { + foreach my $m ( @out ) { + delete $m->{entry}; + $m->{excerpt} = shift @$exc; + } + + } else { + # something terrible has happened..., user gets no excerpts :( + foreach my $m ( @out ) { + delete $m->{entry}; + $m->{excerpt} = '(something terrible happened to the excerpts)'; + } + } + } + + return Storable::nfreeze( $res ); +} diff -r 6865fbf1b5da -r 4dedfba1fe16 cgi-bin/DW/Logic/ProfilePage.pm --- a/cgi-bin/DW/Logic/ProfilePage.pm Sat Jul 25 04:43:19 2009 +0000 +++ b/cgi-bin/DW/Logic/ProfilePage.pm Sat Jul 25 04:48:46 2009 +0000 @@ -58,7 +58,7 @@ sub action_links { my $remote = $self->{remote}; my $user_link_bar = $u->user_link_bar( $remote, class_prefix => "profile" ); - my @ret = $user_link_bar->get_links( "manage_membership", "trust", "watch", "post", "track", "message", "buyaccount" ); + my @ret = $user_link_bar->get_links( "manage_membership", "trust", "watch", "post", "track", "message", "search", "buyaccount" ); } diff -r 6865fbf1b5da -r 4dedfba1fe16 cgi-bin/DW/Logic/UserLinkBar.pm --- a/cgi-bin/DW/Logic/UserLinkBar.pm Sat Jul 25 04:43:19 2009 +0000 +++ b/cgi-bin/DW/Logic/UserLinkBar.pm Sat Jul 25 04:48:46 2009 +0000 @@ -38,6 +38,7 @@ DW::Logic::UserLinkBar - This module pro $link = $user_link_bar->message; $link = $user_link_bar->tellafriend; $link = $user_link_bar->memories; + $link = $user_link_bar->search; =cut @@ -453,6 +454,36 @@ sub memories { return $self->fix_link( $link ); } +=head2 C<< $obj->search >> + +Returns a hashref with the appropriate icon/link/text for searching this journal. + +=cut + +sub search { + my $self = $_[0]; + + my $u = $self->{u}; + my $remote = $self->{remote}; + my $user = $u->user; + + # don't show if search is disabled + return undef unless + @LJ::SPHINX_SEARCHD && + ( $u->is_community || ( $u->equals( $remote ) ) ) && + $u->is_paid; + + my $link = { + url => 'search' . ( $u->is_community ? "?search_user=" . $u->user : '' ), + image => 'search.png', + text_ml => "userlinkbar.search", + title_ml => "userlinkbar.search.title", + class => 'search', + }; + + return $self->fix_link( $link ); +} + =head2 C<< $obj->buyaccount >> Returns a hashref with the appropriate icon/link/text for buying this user a paid account. diff -r 6865fbf1b5da -r 4dedfba1fe16 cgi-bin/DW/Pay.pm --- a/cgi-bin/DW/Pay.pm Sat Jul 25 04:43:19 2009 +0000 +++ b/cgi-bin/DW/Pay.pm Sat Jul 25 04:48:46 2009 +0000 @@ -490,6 +490,12 @@ sub update_paid_status { if $dbh->err; } + # and now, at this last step, we kick off a job to check if this user + # needs to have their search index setup/messed with. + if ( @LJ::SPHINX_SEARCHD && ( my $sclient = LJ::theschwartz() ) ) { + $sclient->insert_jobs( TheSchwartz::Job->new_from_array( 'DW::Worker::Sphinx::Copier', { userid => $u->id } ) ); + } + return 1; } diff -r 6865fbf1b5da -r 4dedfba1fe16 cgi-bin/LJ/Entry.pm --- a/cgi-bin/LJ/Entry.pm Sat Jul 25 04:43:19 2009 +0000 +++ b/cgi-bin/LJ/Entry.pm Sat Jul 25 04:48:46 2009 +0000 @@ -1982,6 +1982,11 @@ sub delete_entry # delete all comments LJ::delete_all_comments($u, 'L', $jitemid); + # fired to delete the post from the Sphinx search database + if ( @LJ::SPHINX_SEARCHD && ( my $sclient = LJ::theschwartz() ) ) { + $sclient->insert_jobs( TheSchwartz::Job->new_from_array( 'DW::Worker::Sphinx::Copier', { userid => $u->id } ) ); + } + return 1; } diff -r 6865fbf1b5da -r 4dedfba1fe16 cgi-bin/ljprotocol.pl --- a/cgi-bin/ljprotocol.pl Sat Jul 25 04:43:19 2009 +0000 +++ b/cgi-bin/ljprotocol.pl Sat Jul 25 04:48:46 2009 +0000 @@ -1540,6 +1540,11 @@ sub postevent } push @jobs, LJ::EventLogRecord::NewEntry->new($entry)->fire_job; + # update the sphinx search engine + if ( @LJ::SPHINX_SEARCHD ) { + push @jobs, TheSchwartz::Job->new_from_array( 'DW::Worker::Sphinx::Copier', { userid => $uowner->id } ); + } + my $sclient = LJ::theschwartz(); if ($sclient && @jobs) { my @handles = $sclient->insert_jobs(@jobs); @@ -1887,6 +1892,12 @@ sub editevent my $entry = LJ::Entry->new($ownerid, jitemid => $itemid); LJ::EventLogRecord::EditEntry->new($entry)->fire; + + # fired to copy the post over to the Sphinx search database + if ( @LJ::SPHINX_SEARCHD && ( my $sclient = LJ::theschwartz() ) ) { + $sclient->insert_jobs( TheSchwartz::Job->new_from_array( 'DW::Worker::Sphinx::Copier', { userid => $ownerid } ) ); + } + LJ::run_hooks("editpost", $entry); return $res; diff -r 6865fbf1b5da -r 4dedfba1fe16 htdocs/img/silk/profile/search.png Binary file htdocs/img/silk/profile/search.png has changed diff -r 6865fbf1b5da -r 4dedfba1fe16 htdocs/search.bml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/htdocs/search.bml Sat Jul 25 04:48:46 2009 +0000 @@ -0,0 +1,167 @@ +<?_c +# +# search.bml +# +# A very basic search function that allows you to search a given journal in +# a few particular cases. +# +# Authors: +# Mark Smith <mark@dreamwidth.org> +# +# Copyright (c) 2009 by Dreamwidth Studios, LLC. +# +# This program is free software; you may redistribute it and/or modify it under +# the same terms as Perl itself. For a copy of the license, please reference +# 'perldoc perlartistic' or 'perldoc perlgpl'. +# +_c?><?page +body<= +<?_code +{ + use strict; + use vars qw/ %POST %GET /; + use Storable; + + # FIXME: english strip and make the UI a lot better :) + + # if no gearman, bail + my $gc = LJ::gearman_client(); + return "Sorry, content searching is not configured on this server.\n" + unless $gc && @LJ::SPHINX_SEARCHD; + + # for pages that require authentication + my $remote = LJ::get_remote(); + return "<?needlogin?>" unless $remote; + + # okay, now let's make sure the data is valid + my $su = LJ::load_user( $GET{search_user} || $remote->user ); + return "Invalid search user.\n" unless $su; + return "You can't search that journal!\n" + unless $remote->equals( $su ) || + $su->is_community; + + # and make sure we're searching a paid account + return "Sorry, searches are only available for paid accounts.\n" + unless $su->is_paid; + + # for later + my $sulj = $su->ljuser_display; + my $suu = $su->user; + + # if they posted we might have to do something + if ( LJ::did_post() ) { + return "Failauth.\n" unless LJ::check_form_auth(); + # and make sure we got a query + my $q = LJ::strip_html( LJ::trim( $POST{query} ) ); + return "Query must be shorter than 255 characters, sorry!\n" + if length( $q ) > 255; + return "Please enter a search query.\n" + unless $q; + + # if an offset, less than 1000 please + my $offset = $GET{offset} + 0; + return "Hey, that offset is nonsensical... :(\n" + if $offset < 0 || $offset > 1000; + + # set a public only flag if this is not your journal + my $public = ( $remote->equals( $su ) || ( $su->is_community && $remote->member_of( $su ) ) ) ? 0 : 1; + + # the arguments to the search + my $args = { userid => $su->id, query => $q, offset => $offset, public => $public }; + my $arg = Storable::nfreeze( $args ); + + # so we know that they're searching something valid, send to gearman + my $result; + my $task = Gearman::Task->new( + 'sphinx_search', \$arg, + { + uniq => '-', + on_complete => sub { + my $res = $_[0] or return undef; + $result = Storable::thaw( $$res ); + }, + } + ); + + # setup the task set for gearman... really, isn't there a way to make this + # simpler? oh well + my $ts = $gc->new_task_set(); + $ts->add_task( $task ); + $ts->wait( timeout => 20 ); + + # if we didn't get a result... + return "Sorry, we were unable to find a result in the time allotted. This may mean that ". + "the server is busy or down. Please try your query again later.\n" + unless $result; + + # if we didn't get any matches... + return "Sorry, we didn't find any matches for the search <strong>$q</strong>. We looked for $result->{time} seconds, too!\n" + if $result->{total} <= 0; + + # now we can process the results and do something fascinating! + my $matches = ''; + foreach my $match ( @{ $result->{matches} } ) { + my $icon = { + public => '', + private => "<img src='$LJ::IMGPREFIX/silk/entry/private.png'>", + usemask => "<img src='$LJ::IMGPREFIX/silk/entry/filtered.png'>", + access => "<img src='$LJ::IMGPREFIX/silk/entry/locked.png'>", + }->{$match->{security}}; + + my $tags = join( ', ', map { "<strong>" . $match->{tags}->{$_} . "</strong>" } keys %{ $match->{tags} } ); + $tags = "<br />Tags: $tags" + if $tags; + + my $html = qq(<div class='searchres'>$icon <a href="$match->{url}">$match->{subject}</a><br /> + <span class='exc'>$match->{excerpt}</span>$tags<br />Posted: <strong>$match->{eventtime}</strong><br /><br /> + </div>); + $matches .= $html; + } + + # build the rest of the search page + my $ret = "<?p You have searched $sulj... p?>" . $matches; + + # put some stats on the output + my $matchct = scalar( @{ $result->{matches} } ); + my $skip = $offset > 0 ? " (skipped $offset)" : ""; + $ret .= qq(<span class="stats">$matchct results displayed out of $result->{total} hits total$skip for <strong>$q</strong>. + $result->{time} seconds.</span>); + + if ( $result->{total} > ( $offset + $matchct ) ) { + my $offsetm = $offset + $matchct; + my $fa = LJ::form_auth(); + $ret .= qq(<form method="post" action="$LJ::SITEROOT/search?search_user=$suu&offset=$offsetm">$fa + <input type="hidden" name="query" value="$q"> <input type="submit" value="More Results..." /> + </form>); + } + + return $ret; + } + + # give them a form to search... + my $fa = LJ::form_auth(); + return <<EOF; + +<?p Searching $sulj ... p?> + +<form method="post" action="$LJ::SITEROOT/search?search_user=$suu"> +$fa +<input type="text" name="query" maxlength="255" size="60"> <input type="submit" value="Search!" /> +</form> + +<?p This is a beta search. Things may be slow or broken while we iron out the kinks +in the system. If you run into trouble, let us know! p?> + +EOF +} +_code?> +<=body +title=>Content Search +head<= +<style type="text/css"> +.exc { padding-left: 1em; font-style: italic; font-size: smaller; } +.stats { font-style: italic; } +.searchres { margin: 0.2em 0em 0.2em 2em; } +</style> +<=head +page?> --------------------------------------------------------------------------------