#!/usr/bin/perl -w
#
# Query a z39.50 server with zoomsh.
# 5dec1999/kjl
#
# Usage:
#   for production:
#     put it into your /cgi-bin/ and use it via your browser as
#	http://localhost/cgi-bin/zquery.pl
#   for limited debugging:
#     zquery.pl [querystring]
#
# Installation:
# Put this script to your cgi-bin directory of the http daemon.
# Edit $::HTML_BASE to point to the directory where the supplemental html
# pages are.
# Additionally, you can modify print_html_* () to customize the appearance
# of the html pages.
#

use CGI 'param';
use CGI::Carp;
use CGI::Carp qw(fatalsToBrowser);
use URI::Escape;
use integer;
use strict;

$::DEBUG = param ('DEBUG');
$::DEBUG = 0 unless ($::DEBUG);

#############################
# configuration starts here #
#############################
# where am I installed?
# this script will find out its location when started as cgi script.
# If you want to use it under mod_perl, move this script to your perl
# modules directory and change the path.
##########################################
$::ME = $ENV { 'SCRIPT_NAME' };
$::ME = "/Harvest/cgi-bin/zquery.pl" unless $::ME;

# prefix to supplemental html pages
$::HTML_BASE = "/Harvest/brokers/zquery";

# base URL to XML objects
$::DATA_URL = "/Harvest/brokers/tengu.local/objects-xml" if ($::DEBUG);

# where is the zebrasrv and the database to use
$::ZURL = "localhost:9999/harvest";	 # how to contact z39.50 server

# some default values
# you can override these with the values of your query form
$::OP	    = "and";	# default boolean operator for queries ("and", "or")
$::N_LINES  = 3;	# default number of lines per hit to show
$::PER_PAGE = 10;	# default number of hits per page to show
# end of configuration
###############################################################################


# where is zoomsh
$::ZOOMSH = "/usr/local/harvest/bin/zoomsh";

# do some sanity checks
test_zebra ();

# parse arguments
# no arguments
# we must be called as a cgi script
$::query = param ('query') if ($#ARGV == -1);

# we found some arguments
$::query = join (" ", @ARGV) if ($#ARGV >= 0);

if ($::query) {

    # page to show
    $::page = param ('page');
    $::page = 0 if (! $::page);

    # number of hits to show per page
    $::per_page = param ('per_page');
    $::per_page = $::PER_PAGE if (! $::per_page);

    # number of lines per entry to show
    $::n_lines = param ('n_lines');
    $::n_lines = $::N_LINES if (! $::n_lines);

    # how to combine query words
    $::op = param ('op');
    $::op = $::OP if (! $::op);

    @::terms = split_query ($::query);

    # build query string in zoomsh notation
    $::z_query = build_query ($::query, $::op, @::terms);

    $::start = $::page * $::per_page;

    $::zoomq =  "$::ZOOMSH \"connect $::ZURL\"";
    $::zoomq .= " \"set preferredRecordSyntax XML\"";
    $::zoomq .= " \"search $::z_query\"";
    $::zoomq .= " \"show $::start $::per_page\" quit";

    print_html_header ("Search result for \"$::query\"");
    print_html_query_form ($::query);
    print "Query sent to Zebra server:<br>\n<b>$::z_query</b>\n<p>\n"
	if ($::DEBUG);
    print "<b>$::query:</b>";

    #($::num_hit, $::aref) = read_hash_zoomsh ();
    ($::num_hit, $::aref) = read_hash_zoomsh_xml ();
    @::hits = @{$::aref};

    # output
    print_num_hit ($::query, $::num_hit);
    print_hit ($::page, $::per_page, \@::hits, @::terms)
	if ($::num_hit && $::num_hit > 0);

    # print pager navigation links
    print_html_page_index (uri_escape ($::query), $::num_hit,
			   $::page, $::per_page)
	if ($::num_hit && $::num_hit > $::per_page);

} else { # if ($query)
    print_html_header ("Z39.50 Search");
    print_html_query_form ();
}

print_html_footer ();
#############################################
# execution ends here			    #
#############################################

#############################################
# subroutines				    #
#############################################

#############################################
# html header				    #
#   args: title				    #
#############################################
sub print_html_header {

    my $title = shift;

    print << "EOF";
Content-type: text/html


<html>
<head>
<title>$title
</title>
<style><!--
div.pager A { color: black; }
//--></style>
</head>
<body
 bgcolor="#ffffff"
 text="#000000"
 link="#0000ff"
 vlink="#000080"
 alink="#ff0000"
>

EOF
}


#############################################
# html end tags				    #
#############################################
sub print_html_footer {

    print << "EOF";
<hr size=3 width=90% noshade>
<center>
<b>
<a href="$::HTML_BASE/help.html"><nobr>[Help formulating queries]</nobr></a>
<a href="$::HTML_BASE/register.html"><nobr>[Register new pages]</nobr></a>
<a href="$::HTML_BASE/contact.html"><nobr>[Contact information]</nobr></a>
<a href="$::HTML_BASE/about.html"><nobr>[About this search system]</nobr></a>
</b>
</center>
</body>
</html>
EOF
}


#############################################
# query form				    #
#   args: default querystring value	    #
#############################################
sub print_html_query_form {

    my $query = shift;

    $::op = $::OP unless ($::op);
    $::n_lines = $::N_LINES unless ($::n_lines);
    $::per_page = $::PER_PAGE unless ($::per_page);

    print << "EOF";
<table>
<tr>
<td><a href="$::HTML_BASE\/"><h1>Harvest</h1></a></td>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<form action=$::ME method=get>

<nobr>
<input type=text name=query
EOF

    if ($query) {
	$query =~ s/\"/&quot;/g;
	print " value=\"$query\"";
    }

    print << "EOF";
 size=40 maxsize=200>
<input type=submit name=button value=Search>
</nobr>

<input type=hidden name=op value=$::op>
<input type=hidden name=n_lines value=$::n_lines>
<input type=hidden name=per_page value=$::per_page>
EOF

    print "<input type=hidden name=DEBUG value=$::DEBUG>\n" if ($::DEBUG);

    print << "EOF";
</form>
</td>
</tr>
</table>

EOF
}


#############################################
# print number of hits for a query	    #
# args: querystring, number_of_hits	    #
#############################################
sub print_num_hit {

    my $query = shift;
    my $num_hit = shift;

    if (! $num_hit) {
	print_html_nomatch ();
	return;
    } elsif ($num_hit == 1) {
	print " $num_hit match";
    } else {
	print " $num_hit matches";
    }

    print " found\n";
    print "<p>\n";
}


###################################################################
# split string to words or group of words when enclosed in quotes #
# args: string							  #
# returns: array with words, quoted words are kept in groups	  #
###################################################################
sub split_query {

    my $query = shift;
    my (@term, $i, $quoted, @res);

    $query =~ s/^\s+//g;    # remove leading whitespaces
    $query =~ s/\s+$//g;    # remove trailing whitespaces

    @term = split /\s+/, $query;
    $quoted = 0;
    $i = 0;
    foreach (@term) {
	if ($quoted) {
	    $res[$i] .= " $_";
	} else {
	    $res[$i] = $_;
	}

	$quoted = 1 if (/^\"/);

	if ($quoted) {
	    $quoted = 0 if (/\S+\"$/);
	}

	$i++ if (!$quoted);
    }

    for ($i = 0; $i <= $#res; $i++) {
	$res[$i] =~ s/\"$//g if ($res[$i] =~ s/^\"//g);
    }

    return @res;
}


#########################################
# highlight search terms		#
# args:					#
#   line to highlight			#
#   array with search terms		#
# returns:				#
#   line with highlighting		#
#########################################
sub highlight {

    my $line  = shift;
    my @terms = @_;

    foreach (@terms) {
	next if (/^\-|[\.\*\+\?]/);
	$line =~ s/(\b$_\b)/<b>$1<\/b>/ig;
    }

    return $line;
}


#########################################
# print hits				#
# arguments:				#
#   number of page			#
#   hits per page			#
#   hash of hit arrays			#
#   array of search terms		#
#########################################
sub print_hit {

    my $page     = shift;
    my $per_page = shift;
    my $aref     = shift;
    my @terms    = @_;

    my (@hits, $i, $entry, $key,
	$title, $last_modification_time, $file_size, $url, $full_text,
	@date);

    @hits = @{$aref};

    $entry = $page * $per_page;

    for $i (0 .. $#hits) {

	$entry++;

	$url			= $hits [$i] { 'url' };
	$last_modification_time = $hits [$i] { 'last-modification-time' };
	$file_size		= $hits [$i] { 'file-size' } / 1024;
	$file_size		= 1 if ($file_size == 0);
	$title			= $hits [$i] { 'title' };
	$full_text		= $hits [$i] { 'full-text' };
	$full_text		= highlight ($full_text, @terms);

	@date = gmtime ($last_modification_time);
	$last_modification_time = sprintf ("%d-%02d-%02d",
					   $date[5] + 1900, $date[4] + 1,
					   $date[3]);

	print "<b>$entry. </b>" if ($::DEBUG);
	print << "EOF";
<b><a href="$url">$title</a></b>
<br>
$full_text
<br>
EOF

	$url =~ s/^http\:\/\///g;
	print "$url <b>$file_size";
	print "KB $last_modification_time</b><br>\n";

	if ($::DEBUG) {
	    my $score    = $hits [$i] { 'score' };
	    my $filename = $hits [$i] { 'filename' };
	    $filename =~ s/\S+\/objects-xml\///g;
	    print "Relevance score: $score\n<br>\n";
	    print "<a href=\"$::DATA_URL/$filename\">xsoif file: $filename</a>\n<br>\n";
	}

	print "<br>\n";
    }
}


###############################
# print page-navigation links #
# args:			      #
#   querystring		      #
#   number of hits	      #
#   page number		      #
#   number of items per page  #
###############################
sub print_html_page_index {

    my $query    = shift;
    my $num_hit  = shift;
    my $page     = shift;
    my $per_page = shift;

    my ($url, $num_pages, $start, $i);

    $url =  "$::ME";
    $url .= "?query=$query";
    $url .= "&op=$::op";
    $url .= "&n_lines=$::n_lines";
    $url .= "&per_page=$per_page";
    $url .= "&DEBUG=$::DEBUG" if ($::DEBUG);

    $num_pages = $num_hit / $per_page;
    $num_pages++ if ($num_hit % $per_page);

    print "<center>\n";
    print "<div class=pager>\n";

    print "<a href=$url&page=" . ($page - 1) . ">Previous</a>&nbsp;\n"
	if ($page > 0);

    $start = $page - 5 if ($page > 5);
    $start = $start - ($page + 6 - $num_pages) if ($num_pages <= $page + 5);
    $start = 0 if (!defined $start || $start < 0);

    for ($i = $start; $i < $page; $i++) {
	print "<a href=$url&page=$i>" . ($i + 1) . "</a>&nbsp;\n";
    }

    print "<font color=red><b>";
    print $page + 1;
    print "</b></font>&nbsp;\n";

    for ($i = $page + 1; $i < $num_pages && $i < $start + 11; $i++) {
	print "<a href=$url&page=$i>" . ($i + 1) . "</a>&nbsp;\n";
    }

    print "<a href=$url&page=" . ($page + 1) . ">Next</a>\n"
	if ($page < $num_pages - 1);

    print "</div>\n";
    print "</center>\n";
}


#############################################
# print error page and exit		    #
# args: error message			    #
#############################################
sub print_html_error {

    my $reason = shift;

    print_html_header ("Service temporarily not available");
    print "<h1 align=center>Service temporarily not available</h1>\n";
    print "<center>Reason: <b>$reason</b>.</center>\n";
    print_html_footer ();
    exit;
}


#############################################
# print timeout message and exit            #
#############################################
sub print_html_timeout {

    print << "EOF";
<p>
Query timed out.
<p>
Please:
<ul>
<li>Choose a more specific search term.
<li>Choose more search terms.
</ul>
<p>
EOF

    print_html_footer ();
    exit 1;
}


#############################################
# print brief query hint                    #
#############################################
sub print_html_nomatch {

    print << "EOF";
<p>
No documents were found matching your query.
<p>
Please:
<ul>
<li>Verify that the spelling of the search terms were correct.
<li>Choose fewer search terms.
<li>Choose more general search terms.
</ul>
<p>
EOF
}


##########################################################
# test that everything necessary is in place and running #
# args: none						 #
##########################################################
sub test_zebra {

    # can't find zoomsh
    print_html_error ("Can't run $::ZOOMSH") if (!-x "$::ZOOMSH");

    # can't connect to zebrasrv
    open (IN, "$::ZOOMSH \"connect $::ZURL\" quit|");
    print_html_error ("Can't connect to zebrasrv at $::ZURL") if (<IN>);
    close (IN);
}


#
# This reads the output from zoomsh and builds a hash containing data received
# from z39.50 server. It uses a simple parser without validity check of the
# returned data, but works well enough to experiment with the search system.
#
# Here are some caveats when parsing the results produced by zoomsh:
#
# - lines are continued at next line with leading whitespaces like this:
#   title: This is a very long title line which does not fit in one line and
#     it will be continued at next line, like this
#
# - long lines can look like this:
#   linkage: http://do.main/path/page.html
#	       or
#   linkage:
#     http://do.main/path/page.html
#
# - lines longer than 77 characters are broken into two lines by putting a
#   "=" at position 77 and continued at next line.
#
##################################################
# read search results from zoomsh		 #
# args: none					 #
# returns:					 #
#   number of total hits			 #
#   hash with search result			 #
##################################################
sub read_hash_zoomsh {

    my ($num_hit, @hits, $i, $j, $tmp);

    # exit after this number of seconds
    alarm (60);
    $SIG{ALRM} = \&print_html_timeout;

    open (IN, "$::zoomq|");

    $_ = <IN>;

    # parse number of hits
    ($num_hit) = /^.*: (\d+) hits$/;

    # build a list of hashes with hits
    $i = -1;
    $_ = <IN>;
    while ($_) {

	# one liners
	if (/^xsoif:$/) {
	# if (/^local-control-number: \d+$/) {
	    $i++;
	    $_ = <IN>;
	    next;
	}

	if (/^\s+rank: \d+$/) {
	    ($hits [$i] { 'rank' }) = /^\s+rank: (\d+)$/;
	    $_ = <IN>;
	    next;
	}

	if (/^\s+last-modification-time: \d+$/) {
	    ($hits [$i] { 'last-modification-time' }) =
		/^\s+last-modification-time: (\d+)$/;
	    $_ = <IN>;
	    next;
	}

	if (/^\s+file-size: \d+$/) {
	    ($hits [$i] { 'file-size' }) = /^\s+file-size: (\d+)$/;
	    $_ = <IN>;
	    next;
	}

	# multi liners
	if (/^\s+title:/) {
	    ($hits [$i] { 'title' }) = /^\s+title: (.+)$/;
	    $_ = <IN>;
	    while (($_) && (/^\s{4}.+$/)) {
		if ($hits [$i] { 'title' }) {
		    if ($hits [$i] { 'title' } =~ /\S=$/) {
			chop $hits [$i] { 'title' };
		    } else {
			$hits [$i] { 'title' } .= " ";
		    }
		}

		$_ =~ s/^\s+//g;
		chop;
		$hits [$i] { 'title' } .= $_;
		$_ = <IN>;
	    }
	    $hits [$i] { 'title' } = remove_quirks ($hits [$i] { 'title' });
	    next;
	}

	if (/^\s+full-text:/) {
	    ($hits [$i] { 'full-text' }) = /^\s+full-text: (.+)$/;
	    $_ = <IN>;
	    $j = 0;
	    while (($_) && ($j < $::n_lines) && (/^\s{4}.+$/)) {
		if ($hits [$i] { 'full-text' }) {
		    if ($hits [$i] { 'full-text' } =~ /\S=$/) {
			chop $hits [$i] { 'full-text' };
		    } else {
			$hits [$i] { 'full-text' } .= " ";
		    }
		}

		$_ =~ s/^\s+//g;
		chop;
		$hits [$i] { 'full-text' } .= $_;
		$_ = <IN>;
		$j++;
	    }
	    $hits [$i] { 'full-text' } =
		remove_quirks ($hits [$i] { 'full-text' });
	    next;
	}

	if (/^\s+url:/) {
	    ($hits [$i] { 'url' }) = /^\s+url: (.+)$/;
	    $_ = <IN>;
	    while (($_) && (/^\s{4}.+$/)) {
		if (($hits [$i] { 'url' }) &&
		    ($hits [$i] { 'url' } =~ /\S+=$/)) {
		    chop $hits [$i] { 'url' };
		}

		$_ =~ s/^\s+//g;
		chop;
		$hits [$i] { 'url' } .= $_;
		$_ = <IN>;
	    }
	    next;
	}

	$_ = <IN>;
    }

    close (IN);

    alarm (0);

    # print all parsed lines for debugging
    # keys used to print search results
    #@::keys = ("rank", "url", "last-modification-time",
    #	   "file-size", "title", "full-text");
    #foreach $i (0 .. $#hits) {
    #	foreach my $key (@::keys) {
    #	    print $i, " ->> ", $key, " ->> ", $hits [$i] { $key }, "\n";
    #	}
    #	print "---------------------\n";
    #}

    #######################################################################
    # Postprocessing							  #
    # we have to filter this after a full build of the hash because	  #
    # we don't know when both, title and sample fields are initialized.	  #
    #######################################################################
    # title is sometimes repeated at the beginning of sampletext and some #
    # documents doesn't have any title at all.				  #
    # to do search and replace, meta characters must be escaped		  #
    #######################################################################
    foreach $i (0 .. $#hits) {
	if ($hits [$i] { 'title' }) {
	    $tmp = quotemeta ($hits [$i] { 'title' });
	    $hits [$i] { 'full-text' } =~ s/^$tmp //g;
	} else {
	    $hits [$i] { 'title' } = $hits [$i] { 'url' };
	}
	if (length ($hits [$i] { 'title' }) > 60) {
	    $hits [$i] { 'title' } =
		substr ($hits [$i] { 'title' }, 0, 60) . "...";
	}
    }

    return $num_hit, \@hits;
}


##################################################
# read search results from zoomsh		 #
# args: none					 #
# returns:					 #
#   number of total hits			 #
#   hash with search result			 #
##################################################
sub read_hash_zoomsh_xml {

    my ($num_hit, @hits, $i, $one_line, $elem, $curr_elem,
	$tmp, @wanted_str, $skip);

    # exit after this number of seconds
    alarm (60);
    $SIG{ALRM} = \&print_html_timeout;

    open (IN, "$::zoomq|");

    $_ = <IN>;

    # parse number of hits
    ($num_hit) = /^.*: (\d+) hits$/;

    $i = -1;
    $one_line = 0;
    $curr_elem = "";

    @wanted_str = ("url", "title", "full-text", "file-size",
		   "last-modification-time");

    <IN>;
    while (<IN>) {
	($elem) = /<(.*?)>/;
	if ($elem) {
	    if ($elem eq "xsoif") {
		$i++;
		next;
	    }
	    if ($elem eq "\/idzebra") {
		$one_line = 0;
		next;
	    }
	    if ($elem =~ /^idzebra/) {
		$one_line = 1;
		next;
	    }
	    if (!$one_line) {
		$curr_elem = ($elem =~ /^\//) ? "" : $elem;
	    } else {
		($hits [$i] { 'filename' }) = /<filename>(\S+)<\/filename>/
		    if ($elem eq "filename");
		($hits [$i] { 'score' }) = /<score>(\d+)<\/score>/
		    if ($elem eq "score");
	    }
	} else {
	    foreach $tmp (@wanted_str) {
		if ($curr_elem eq $tmp) {
		    chop;
		    if (!$hits [$i] { "$tmp" }) {
			$skip = 0;
			$hits [$i] { "$tmp" } = $_;
		    } else {
			if (!$skip) {
			    $skip = 1 if (length ($hits [$i] { "$tmp" }) > $::n_lines * 80);
			    $hits [$i] { "$tmp" } .= " $_";
			}
		    }
		    last;
		}
	    }
	}
    }

    close (IN);

    alarm (0);

    # print all parsed lines for debugging
    # keys used to print search results
    #@::keys = ("rank", "url", "last-modification-time",
    #	   "file-size", "title", "full-text");
    #foreach $i (0 .. $#hits) {
    #	foreach my $key (@::keys) {
    #	    print $i, " ->> ", $key, " ->> ", $hits [$i] { $key }, "\n";
    #	}
    #	print "---------------------\n";
    #}

    #######################################################################
    # Postprocessing							  #
    # we have to filter this after a full build of the hash because	  #
    # we don't know when both, title and sample fields are initialized.	  #
    #######################################################################
    # title is sometimes repeated at the beginning of sampletext and some #
    # documents doesn't have any title at all.				  #
    # to do search and replace, meta characters must be escaped		  #
    #######################################################################
    foreach $i (0 .. $#hits) {
	if ($hits [$i] { 'title' }) {
	    $tmp = quotemeta ($hits [$i] { 'title' });
	    $hits [$i] { 'full-text' } =~ s/^$tmp //g;
	} else {
	    $hits [$i] { 'title' } = $hits [$i] { 'url' };
	}
	$hits [$i] { 'title' } = trim_line ($hits [$i] { 'title' }, 60);
	$hits [$i] { 'full-text' } = trim_line ($hits [$i] { 'full-text' }, $::n_lines * 80);
    }

    return $num_hit, \@hits;
}


#################################################
# build query string from input			#
# args:						#
#   query string				#
#   boolean operation				#
#   array of search terms			#
# returns: query string in Zebra's client	#
#	   notation				#
#################################################
sub build_query {

    my $query = shift;
    my $op    = shift;
    my @token = @_;

    my ($attr, $i, $z_query);

    return "" if ($#token < 0);

    # request ranking
    $attr = "\@attr 2=102 ";

    #$attr .= "\@attr 1=/xsoif ";
    #$attr .= "\@attr 1=/xsoif/url ";
    #$attr .= "\@attr 1=/xsoif/author ";
    #$attr .= "\@attr 1=/xsoif/title ";
    #$attr .= "\@attr 1=/xsoif/abstract ";
    #$attr .= "\@attr 1=/xsoif/headings ";
    #$attr .= "\@attr 1=/xsoif/keywords ";
    #$attr .= "\@attr 1=/xsoif/description ";
    #$attr .= "\@attr 1=/xsoif/full-text ";

    # make it a regexp search, if any of these special characters are found
    # in query string: ".[]*+?|"
    $attr .= "\@attr 5=102 " if ($query =~ /\.|\[|\]|\*|\+|\?|\|/);

    foreach (@token) {
	$_ = "\\\"$_\\\"" if (/\s/);
	if ($z_query) {
	    if (/^\-/) {
		s/^\-//g;
		$z_query = "\@not $z_query " . $attr . $_;
	    } else {
		$z_query = "\@$op $z_query " . $attr . $_;
	    }
	} else {
	    $z_query = $attr . $_;
	}
    }

    return $z_query;
}


#############################################
# strip html tags still left in summarized  #
# data and map dangerous letters	    #
# args: text to strip			    #
# returns: stripped text		    #
#############################################
sub remove_quirks {

    my $line = shift;

    $line =~ s/</\&lt;/g;
    $line =~ s/>/\&gt;/g;

    return $line;
}


#################################################
# truncate a line reasonably			#
# args: string, length				#
# returns: a trimmed string shorter than length	#
#################################################
sub trim_line {

    my $str = shift;
    my $len = shift;

    return $str if (length ($str) <= $len);

    $str = substr ($str, 0, $len);
    $str =~ s/(.*)\s.*/$1/g;
    $str .= " ..." if (!($str =~ /\.$/));

    return $str;
}
