mark: A photo of Mark kneeling on top of the Taal Volcano in the Philippines. It was a long hike. (Default)
Mark Smith ([staff profile] mark) wrote in [site community profile] changelog2009-07-25 04:48 am

[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 [staff profile] mark.

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?>
--------------------------------------------------------------------------------