Bib record browser with 'see also', etc from linked authority headings bib-auth-browse-squash
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Wed, 24 Apr 2013 14:46:27 +0000 (10:46 -0400)
committerLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Tue, 7 May 2013 19:34:34 +0000 (15:34 -0400)
This feature provides a patron-oriented OPAC interface for browsing
bibliographic records.

Users choose to browse by Author, Title, Subject, or Series. They then
enter a browse term, and the nearest match from a left-anchored search
on the headings extracted for browse purposes will be displayed in a
typical backwards/forwards paging display. Headings link to search
results pages showing the related records. If the browse heading is
linked to any authority records, and if any *other* authority records
point to those with "See also" or other non-main entry headings, those
alternative headings are displayed a linked to a search results page
showing related bib records related to the alternate heading.

The counts of holdings displayed next to headings from bibliographic
records are subject to the same visiibility tests as search. This means
that the org unit (and copy location group) dropdown on the browse
interface affects counds, and it further means that whether or not
you're looking at the browse interface through the staff client makes a
difference.

This builds on the two previous commits that provide inter-authority
linking and the linking of metabib.browse_entry rows to authority
records.

Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>
19 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Browse.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/030.schema.metabib.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/999.functions.global.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.config-metabib-interauthority.sql
Open-ILS/src/sql/Pg/upgrade/YYYY.schema.bib-auth-browse.sql
Open-ILS/src/templates/opac/advanced.tt2
Open-ILS/src/templates/opac/browse.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/css/style.css.tt2
Open-ILS/src/templates/opac/parts/config.tt2
Open-ILS/src/templates/opac/parts/qtype_selector.tt2
Open-ILS/src/templates/opac/parts/searchbar.tt2
Open-ILS/web/css/skin/default/opac/semiauto.css
docs/RELEASE_NOTES_NEXT/OPAC/BibAuthBrowse.txt [new file with mode: 0644]

index ccc7117..a76a174 100644 (file)
@@ -5679,6 +5679,19 @@ SELECT  usr,
                        <link field="record" reltype="has_a" key="id" map="" class="are"/>
                </links>
        </class>
+       <class id="aalink" controller="open-ils.cstore" oils_obj:fieldmapper="authority::authority_linking" oils_persist:tablename="authority.authority_linking" reporter:label="Authority to Authority Linking">
+               <fields oils_persist:primary="id" oils_persist:sequence="authority.authority_linking_id_seq">
+                       <field name="id" reporter:label="ID" reporter:datatype="id" />
+                       <field name="source" reporter:label="Source Record" reporter:datatype="link" />
+                       <field name="target" reporter:label="Target Record" reporter:datatype="link" />
+                       <field name="field" reporter:label="Authority Field" reporter:datatype="link" />
+               </fields>
+               <links>
+                       <link field="source" reltype="has_a" key="id" map="" class="are"/>
+                       <link field="target" reltype="has_a" key="id" map="" class="are"/>
+                       <link field="field" reltype="has_a" key="id" map="" class="acsaf"/>
+               </links>
+       </class>
        <class id="cnct" controller="open-ils.cstore" oils_obj:fieldmapper="config::non_cataloged_type" oils_persist:tablename="config.non_cataloged_type" reporter:label="Non-cataloged Type">
                <fields oils_persist:primary="id" oils_persist:sequence="config.non_cataloged_type_id_seq">
                        <field reporter:label="Circulation Duration" name="circ_duration" reporter:datatype="interval"/>
index c202dac..27f0ff5 100644 (file)
@@ -665,6 +665,8 @@ __PACKAGE__->add_search_filter( 'container' );
 # Start from a list of record ids, either bre or metarecords, depending on the #metabib modifier
 __PACKAGE__->add_search_filter( 'record_list' );
 
+__PACKAGE__->add_search_filter( 'has_browse_entry' );
+
 # used internally, but generally not user-settable
 __PACKAGE__->add_search_filter( 'preferred_language' );
 __PACKAGE__->add_search_filter( 'preferred_language_weight' );
@@ -1109,6 +1111,12 @@ sub flatten {
                     $where .= "$key ${NOT}IN (" . join(',', map { $self->QueryParser->quote_value($_) } @{$filter->args}) . ')';
                 }
 
+            } elsif ($filter->name eq 'has_browse_entry') {
+                if (@{$filter->args} >= 2) {
+                    my $entry = int(shift @{$filter->args});
+                    my $fields = join(",", map(int, @{$filter->args}));
+                    $from .= "\n" . $spc x 3 . sprintf("INNER JOIN metabib.browse_entry_def_map mbedm ON (mbedm.source = m.source AND mbedm.entry = %d AND mbedm.def IN (%s))", $entry, $fields);
+                }
             } elsif ($filter->name eq 'edit_date' or $filter->name eq 'create_date') {
                 # bre.create_date and bre.edit_date filtering
                 my $datefilter = $filter->name;
index 0838a29..244afa2 100644 (file)
@@ -19,6 +19,7 @@ use Time::HiRes;
 # EGCatLoader sub-modules 
 use OpenILS::WWW::EGCatLoader::Util;
 use OpenILS::WWW::EGCatLoader::Account;
+use OpenILS::WWW::EGCatLoader::Browse;
 use OpenILS::WWW::EGCatLoader::Search;
 use OpenILS::WWW::EGCatLoader::Record;
 use OpenILS::WWW::EGCatLoader::Container;
@@ -123,6 +124,7 @@ sub load {
     return $self->load_print_record if $path =~ m|opac/record/print|;
     return $self->load_record if $path =~ m|opac/record/\d|;
     return $self->load_cnbrowse if $path =~ m|opac/cnbrowse|;
+    return $self->load_browse if $path =~ m|opac/browse|;
 
     return $self->load_mylist_add if $path =~ m|opac/mylist/add|;
     return $self->load_mylist_delete if $path =~ m|opac/mylist/delete|;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Browse.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Browse.pm
new file mode 100644 (file)
index 0000000..059bad1
--- /dev/null
@@ -0,0 +1,331 @@
+package OpenILS::WWW::EGCatLoader;
+
+use strict;
+use warnings;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::AppUtils;
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::SettingsClient;
+
+use Digest::MD5 qw/md5_hex/;
+use Apache2::Const -compile => qw/OK/;
+use MARC::Record;
+#use Data::Dumper;
+#$Data::Dumper::Indent = 0;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $browse_cache;
+my $browse_timeout;
+
+sub _init_browse_cache {
+    if (not defined $browse_cache) {
+        my $conf = new OpenSRF::Utils::SettingsClient;
+
+        $browse_timeout = $conf->config_value(
+            "apps", "open-ils.search", "app_settings", "cache_timeout"
+        ) || 300;
+        $browse_cache = new OpenSRF::Utils::Cache("global");
+    }
+}
+
+# Returns cache key and a list of parameters for DB proc metabib.browse().
+sub prepare_browse_parameters {
+    my ($self) = @_;
+
+    no warnings 'uninitialized';
+
+    # XXX TODO add config.global_flag rows for browse limit-limit and
+    # browse offset-limit?
+
+    my $limit = int($self->cgi->param('blimit') || 10);
+    my $offset = int($self->cgi->param('boffset') || 0);
+    my $force_backward = scalar($self->cgi->param('bback'));
+
+    my @params = (
+        scalar($self->cgi->param('qtype')),
+        scalar($self->cgi->param('bterm')),
+        $self->ctx->{copy_location_group_org} ||
+            $self->ctx->{aou_tree}->()->id,
+        $self->ctx->{copy_location_group},
+        $self->ctx->{is_staff} ? 't' : 'f',
+        scalar($self->cgi->param('bpivot')),
+        $force_backward ? 't' : 'f'
+    );
+
+    # We do need $limit, $offset, and $force_backward as part of the
+    # cache key, but we also need to keep them separate from other
+    # parameters for purposes of paging link generation.
+    return (
+        "oils_browse_" . md5_hex(
+            OpenSRF::Utils::JSON->perl2JSON(
+                [@params, $limit, $offset, $force_backward]
+            )
+        ),
+        $limit, $offset, $force_backward, @params
+    );
+}
+
+sub find_authority_headings {
+    my ($self, $row) = @_;
+
+    my $acsaf_table =
+        $self->ctx->{get_authority_fields}->($row->{control_set});
+
+    $row->{headings} = [];
+
+    my $record;
+    eval {
+        $record = new_from_xml MARC::Record($row->{marc});
+    };
+    if ($@) {
+        $logger->warn("Problem with MARC from authority record #" .
+            $row->{id} . ": $@");
+        return $row;    # We're called in map(), so we must move on without
+                        # a fuss.
+    }
+
+    foreach my $acsaf (values(%$acsaf_table)) {
+        my @fields = $record->field($acsaf->tag);
+        my @headings;
+
+        foreach (@fields) {
+            my $heading = "";
+            foreach my $sf (split "", $acsaf->sf_list) {
+                $heading .= $_->subfield($sf) || "";
+            }
+            push @headings, $heading;
+        }
+
+        # Remember: main_entry is a link field, so for it to evaluate
+        # to true means that we *have* (and therefore *aren't*) a main
+        # entry.  The rest of the time when main_entry is undef we
+        # *are* a main entry.
+        #
+        # For this, we only want non-main entries.
+        push @{$row->{headings}}, {$acsaf->id => \@headings}
+            if @headings and $acsaf->main_entry;
+    }
+
+    return $row;
+}
+
+# flesh_browse_results() attaches data from authority records. It
+# changes $results and returns 1 for success, undef for failure (in which
+# case $self->editor->event should always point to the reason for failure).
+# $results must be an arrayref of result rows from the DB's metabib.browse()
+sub flesh_browse_results {
+    my ($self, $results) = @_;
+
+    # Turn comma-seprated strings of numbers in "authorities" column
+    # into arrays.
+    $_->{authorities} = [split /,/, $_->{authorities}] foreach @$results;
+
+    # Group them in one arrray, not worrying about dupes because we're about
+    # to use them in an IN () comparison in a SQL query.
+    my @auth_ids = map { @{$_->{authorities}} } @$results;
+
+    if (@auth_ids) {
+        # Get all linked authority records themselves
+        my $linked = $self->editor->json_query({
+            select => {are => [qw/id marc control_set/], aalink => ["target"]},
+            from => {
+                aalink => {
+                    are => { field => "id", fkey => "source" }
+                }
+            },
+            where => {"+aalink" => {target => \@auth_ids}}
+        }) or return;
+
+        # Then use the linked authority records' control sets to find and
+        # pick out non-main-entry headings.  Build the headings and make a
+        # combined data structure for the template's use.
+        my %linked_headings_by_auth_id =
+            map { $_->{id} => $self->find_authority_headings($_) } @$linked;
+
+        # Graft this authority heading data onto our main result set at the
+        # "authorities" column.
+        foreach my $row (@$results) {
+            $row->{authorities} = [
+                map { $linked_headings_by_auth_id{$_} } @{$row->{authorities}}
+            ];
+        }
+
+        # Get use counts of authority records, i.e. number of bibs linked to
+        # them. - XXX refine later to consider holdings visibility.
+        my $counts = $self->editor->json_query({
+            select => {
+                abl => [
+                    {column => "id", transform => "count",
+                        alias => "count", aggregate => 1},
+                    "authority"
+                ]
+            },
+            from => {abl => {}},
+            where => {"+abl" => {authority => \@auth_ids}}
+        }) or return;
+
+        my %counts_by_authority =
+            map { $_->{authority} => $_->{count} } @$counts;
+        foreach my $row(@$results) {
+            foreach my $auth (@{$row->{authorities}}) {
+                $auth->{count} = $counts_by_authority{$auth->{id}};
+            }
+        }
+    }
+
+    return 1;
+}
+
+sub load_browse_impl {
+    my ($self, $limit, $offset, $force_backward, @params) = @_;
+
+    my $inner_limit = ($offset >= 0 and not $force_backward) ?
+        $limit + 1 : $limit;
+
+    my $results = $self->editor->json_query({
+        from => [
+            "metabib.browse", (@params, $inner_limit, $offset)
+        ]
+    });
+
+    if (not $results) {  # DB error, not empty result set.
+        $logger->warn(
+            "error in browse (direct): " . $self->editor->event->{textcode}
+        );
+        $self->ctx->{browse_error} = 1;
+
+        return;
+    } elsif (not $self->flesh_browse_results($results)) {
+        $logger->warn(
+            "error in browse (flesh): " . $self->editor->event->{textcode}
+        );
+        $self->ctx->{browse_error} = 1;
+
+        return;
+    }
+
+    return $results;
+}
+
+# $results can be modified by this function.  This would be simpler
+# but for the moving pivot concept that helps us avoid paging with
+# large offsets (slow).
+sub infer_browse_paging {
+    my ($self, $results, $limit, $offset, $force_backward) = @_;
+
+    # (All these comments assume a default limit of 10).  For typical
+    # not-backwards requests not at the end of the result set, we
+    # should have an eleventh result that tells us what's next.
+    while (scalar @$results > $limit) {
+        $self->ctx->{forward_pivot} = (pop @$results)->{browse_entry};
+        $self->ctx->{more_forward} = 1;
+    }
+
+    # If we're going backwards by pivot id, we don't have an eleventh
+    # result to tell us we can page forward, but we can assume we can
+    # go forward because duh, we followed a link backward to get here.
+    if ($force_backward and $self->cgi->param('bpivot')) {
+        $self->ctx->{forward_pivot} = scalar($self->cgi->param('bpivot'));
+        $self->ctx->{more_forward} = 1;
+    }
+
+    # The pivot that the user can use for going backwards is the first
+    # of the result set.
+    if (@$results) {
+        $self->ctx->{back_pivot} = $results->[0]->{browse_entry};
+    }
+
+    # The result of these tests relate to basic limit/offset paging.
+
+    # This comparison for setting more_forward does not fold into
+    # those for setting more_back.
+    if ($offset < 0 || $force_backward) {
+        $self->ctx->{more_forward} = 1;
+    }
+
+    if ($offset > 0) {
+        $self->ctx->{more_back} = 1;
+    } elsif (scalar @$results < $limit) {
+        $self->ctx->{more_back} = 0;
+    } else {
+        $self->ctx->{more_back} = 1;
+    }
+}
+
+sub leading_article_test {
+    my ($self, $qtype, $bterm) = @_;
+
+    my $flag_name = "opac.browse.warnable_regexp_per_class";
+    my $flag = $self->ctx->{get_cgf}->($flag_name);
+
+    return unless $flag->enabled;
+
+    my $map;
+
+    eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
+    if ($@) {
+        $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
+        return;
+    }
+
+    # Don't crash over any of the things that could go wrong in here:
+    eval {
+        if ($map->{$qtype}) {
+            if ($bterm =~ qr/$map->{$qtype}/i) {
+                $self->ctx->{browse_leading_article_warning} = 1;
+            }
+        }
+    };
+    if ($@) {
+        $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
+    }
+}
+
+sub load_browse {
+    my ($self) = @_;
+
+    _init_browse_cache();
+
+    $self->ctx->{more_forward} = 0;
+    $self->ctx->{more_back} = 0;
+
+    if ($self->cgi->param('qtype') and $self->cgi->param('bterm')) {
+
+        $self->leading_article_test(
+            $self->cgi->param('qtype'),
+            $self->cgi->param('bterm')
+        );
+
+        my ($cache_key, $limit, $offset, $force_backward, @params) =
+            $self->prepare_browse_parameters;
+
+        my $results = $browse_cache->get_cache($cache_key);
+        if (not $results) {
+            $results = $self->load_browse_impl(
+                $limit, $offset, $force_backward, @params
+            );
+            if ($results) {
+                $browse_cache->put_cache($cache_key, $results, $browse_timeout);
+            }
+        }
+
+        if ($results) {
+            $self->infer_browse_paging(
+                $results, $limit, $offset, $force_backward
+            );
+            $self->ctx->{browse_results} = $results;
+        }
+
+        # We don't need an else clause to send the user a 5XX error or
+        # anything. Errors will have been logged, and $ctx will be
+        # prepared so a template can show a nicer error to the user.
+    }
+
+    return Apache2::Const::OK;
+}
+
+1;
index 93cc3e1..8e7a1ec 100644 (file)
@@ -18,7 +18,8 @@ our %cache = ( # cached data
     search_filter_groups => {en_us => {}},
     aou_tree => {en_us => undef},
     aouct_tree => {},
-    eg_cache_hash => undef
+    eg_cache_hash => undef,
+    authority_fields => {en_us => {}}
 );
 
 sub init_ro_object_cache {
@@ -227,6 +228,21 @@ sub init_ro_object_cache {
         return $cache{org_settings}{$ctx->{locale}}{$org_id}{$setting};
     };
 
+    # retrieve and cache acsaf values
+    $ro_object_subs->{get_authority_fields} = sub {
+        my ($control_set) = @_;
+
+        if (not exists $cache{authority_fields}{$ctx->{locale}}{$control_set}) {
+            my $acs = $e->search_authority_control_set_authority_field(
+                {control_set => $control_set}
+            ) or return;
+            $cache{authority_fields}{$ctx->{locale}}{$control_set} =
+                +{ map { $_->id => $_ } @$acs };
+        }
+
+        return $cache{authority_fields}{$ctx->{locale}}{$control_set};
+    };
+
     $ctx->{$_} = $ro_object_subs->{$_} for keys %$ro_object_subs;
 }
 
index 5a955d4..2738748 100644 (file)
@@ -194,6 +194,7 @@ CREATE TABLE config.metabib_field (
        browse_field    BOOL    NOT NULL DEFAULT TRUE,
        browse_xpath   TEXT,
        facet_xpath     TEXT,
+    authority_xpath TEXT,
        restrict        BOOL    DEFAULT FALSE NOT NULL
 );
 COMMENT ON TABLE config.metabib_field IS $$
index 8dbac47..fd32f3d 100644 (file)
@@ -186,8 +186,25 @@ CREATE INDEX metabib_facet_entry_source_idx ON metabib.facet_entry (source);
 CREATE TABLE metabib.browse_entry (
     id BIGSERIAL PRIMARY KEY,
     value TEXT unique,
-    index_vector tsvector
+    index_vector tsvector,
+    sort_value  TEXT NOT NULL
 );
+
+CREATE INDEX browse_entry_sort_value_idx
+    ON metabib.browse_entry USING BTREE (sort_value);
+
+CREATE OR REPLACE FUNCTION metabib.browse_entry_sort_value()
+RETURNS TRIGGER AS $$
+    BEGIN
+        NEW.sort_value = public.search_normalize(NEW.value);
+        RETURN NEW;
+    END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER mbe_sort_value
+BEFORE INSERT OR UPDATE ON metabib.browse_entry
+FOR EACH ROW EXECUTE PROCEDURE metabib.browse_entry_sort_value();
+
 CREATE INDEX metabib_browse_entry_index_vector_idx ON metabib.browse_entry USING GIN (index_vector);
 CREATE TRIGGER metabib_browse_entry_fti_trigger
     BEFORE INSERT OR UPDATE ON metabib.browse_entry
@@ -198,7 +215,8 @@ CREATE TABLE metabib.browse_entry_def_map (
     id BIGSERIAL PRIMARY KEY,
     entry BIGINT REFERENCES metabib.browse_entry (id),
     def INT REFERENCES config.metabib_field (id),
-    source BIGINT REFERENCES biblio.record_entry (id)
+    source BIGINT REFERENCES biblio.record_entry (id),
+    authority BIGINT REFERENCES authority.record_entry (id) ON DELETE SET NULL
 );
 CREATE INDEX browse_entry_def_map_def_idx ON metabib.browse_entry_def_map (def);
 CREATE INDEX browse_entry_def_map_entry_idx ON metabib.browse_entry_def_map (entry);
@@ -478,6 +496,7 @@ BEGIN
                 output_row.field = idx.id;
                 output_row.source = rid;
                 output_row.value = BTRIM(REGEXP_REPLACE(browse_text, E'\\s+', ' ', 'g'));
+                output_row.authority := NULL;
 
                 IF idx.authority_xpath IS NOT NULL AND idx.authority_xpath <> '' THEN
                     authority_text := oils_xpath_string(
@@ -1705,4 +1724,209 @@ BEGIN
 END;
 $$ LANGUAGE PLPGSQL;
 
+
+CREATE TYPE metabib.flat_browse_entry_appearance AS (
+    browse_entry    BIGINT,
+    value           TEXT,
+    fields          TEXT,
+    authorities     TEXT,
+    sources         INT,        -- visible ones, that is
+    row_number      INT         -- internal use, sort of
+);
+
+
+CREATE OR REPLACE FUNCTION metabib.browse_pivot(
+    search_field        INT[],
+    browse_term         TEXT
+) RETURNS BIGINT AS $p$
+DECLARE
+    id                  BIGINT;
+BEGIN
+    SELECT INTO id mbe.id FROM metabib.browse_entry mbe
+        JOIN metabib.browse_entry_def_map mbedm ON (
+            mbedm.entry = mbe.id AND
+            mbedm.def = ANY(search_field)
+        )
+        WHERE mbe.sort_value >= public.search_normalize(browse_term)
+        ORDER BY mbe.sort_value, mbe.value LIMIT 1;
+
+    RETURN id;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION metabib.staged_browse(
+    core_query              TEXT,
+    context_org             INT,
+    context_locations       INT[],
+    staff                   BOOL,
+    result_limit            INT,
+    use_offset              INT
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+DECLARE
+    core_cursor             REFCURSOR;
+    core_record             RECORD;
+    qpfts_query             TEXT;
+    result_row              metabib.flat_browse_entry_appearance%ROWTYPE;
+    results_skipped         INT := 0;
+    results_returned        INT := 0;
+BEGIN
+    OPEN core_cursor FOR EXECUTE core_query;
+
+    LOOP
+        FETCH core_cursor INTO core_record;
+        EXIT WHEN NOT FOUND;
+
+        qpfts_query :=
+            'SELECT NULL::BIGINT AS id, ARRAY[r] AS records, 1::INT AS rel ' ||
+            'FROM (SELECT UNNEST(' ||
+            quote_literal(core_record.records) || '::BIGINT[]) AS r) rr';
+
+        -- We use search.query_parser_fts() for visibility testing. Yes there
+        -- is a reason we feed it the records for one mbe at a time instead of
+        -- the records for `result_limit` mbe's at a time.
+        SELECT INTO result_row.sources visible
+            FROM search.query_parser_fts(
+                context_org, NULL, qpfts_query, NULL,
+                context_locations, 0, NULL, NULL, FALSE, staff, FALSE
+            ) qpfts
+            WHERE qpfts.rel IS NULL;
+
+        IF result_row.sources > 0 THEN
+            IF results_skipped < use_offset THEN
+                results_skipped := results_skipped + 1;
+                CONTINUE;
+            END IF;
+
+            result_row.browse_entry := core_record.id;
+            result_row.authorities := core_record.authorities;
+            result_row.fields := core_record.fields;
+            result_row.value := core_record.value;
+
+            -- This is needed so our caller can flip it and reverse it.
+            result_row.row_number := results_returned;
+
+            RETURN NEXT result_row;
+
+            results_returned := results_returned + 1;
+
+            EXIT WHEN results_returned >= result_limit;
+        END IF;
+    END LOOP;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+-- This is optimized to be fast for values of result_offset near zero.
+CREATE OR REPLACE FUNCTION metabib.browse(
+    search_field            INT[],
+    browse_term             TEXT,
+    context_org             INT DEFAULT NULL,
+    context_loc_group       INT DEFAULT NULL,
+    staff                   BOOL DEFAULT FALSE,
+    pivot_id                BIGINT DEFAULT NULL,
+    force_backward          BOOL DEFAULT FALSE,
+    result_limit            INT DEFAULT 10,
+    result_offset           INT DEFAULT 0   -- Can be negative!
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+DECLARE
+    core_query              TEXT;
+    whole_query             TEXT;
+    pivot_sort_value        TEXT;
+    pivot_sort_fallback     TEXT;
+    context_locations       INT[];
+    use_offset              INT;
+    results_skipped         INT := 0;
+BEGIN
+    IF pivot_id IS NULL THEN
+        pivot_id := metabib.browse_pivot(search_field, browse_term);
+    END IF;
+
+    SELECT INTO pivot_sort_value, pivot_sort_fallback
+        sort_value, value FROM metabib.browse_entry where id = pivot_id;
+
+    IF pivot_sort_value IS NULL THEN
+        RETURN;
+    END IF;
+
+    IF context_loc_group IS NOT NULL THEN
+        SELECT INTO context_locations ARRAY_AGG(location)
+            FROM asset.copy_location_group_map
+            WHERE lgroup = context_loc_group;
+    END IF;
+
+    core_query := '
+    SELECT
+        mbe.id,
+        mbe.value,
+        mbe.sort_value,
+        (SELECT ARRAY_AGG(src) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.source)) AS src
+        ) ss) AS records,
+        (SELECT ARRAY_TO_STRING(ARRAY_AGG(authority), $$,$$) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.authority)) AS authority
+        ) au) AS authorities,
+        (SELECT ARRAY_TO_STRING(ARRAY_AGG(field), $$,$$) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.def)) AS field
+        ) fi) AS fields
+    FROM metabib.browse_entry mbe
+    JOIN metabib.browse_entry_def_map mbedm ON (
+        mbedm.entry = mbe.id AND
+        mbedm.def = ANY(' || quote_literal(search_field) || ')
+    )
+    WHERE ';
+
+    -- PostgreSQL is not magic. We can't actually pass a negative offset.
+    IF result_offset >= 0 AND NOT force_backward THEN
+        use_offset := result_offset;
+        core_query := core_query ||
+            ' mbe.sort_value >= ' || quote_literal(pivot_sort_value) ||
+        ' GROUP BY 1,2,3 ORDER BY mbe.sort_value, mbe.value ';
+
+        RETURN QUERY SELECT * FROM metabib.staged_browse(
+            core_query, context_org, context_locations,
+            staff, result_limit, use_offset
+        );
+    ELSE
+        -- Part 1 of 2 to deliver what the user wants with a negative offset:
+        core_query := core_query ||
+            ' mbe.sort_value < ' || quote_literal(pivot_sort_value) ||
+        ' GROUP BY 1,2,3 ORDER BY mbe.sort_value DESC, mbe.value DESC ';
+
+        -- Part 2 of 2 to deliver what the user wants with a negative offset:
+        RETURN QUERY SELECT * FROM (SELECT * FROM metabib.staged_browse(
+            core_query, context_org, context_locations,
+            staff, result_limit, use_offset
+        )) sb ORDER BY row_number DESC;
+
+    END IF;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION metabib.browse(
+    search_class        TEXT,
+    browse_term         TEXT,
+    context_org         INT DEFAULT NULL,
+    context_loc_group   INT DEFAULT NULL,
+    staff               BOOL DEFAULT FALSE,
+    pivot_id            BIGINT DEFAULT NULL,
+    force_backward      BOOL DEFAULT FALSE,
+    result_limit        INT DEFAULT 10,
+    result_offset       INT DEFAULT 0   -- Can be negative, implying backward!
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+BEGIN
+    RETURN QUERY SELECT * FROM metabib.browse(
+        (SELECT COALESCE(ARRAY_AGG(id), ARRAY[]::INT[])
+            FROM config.metabib_field WHERE field_class = search_class),
+        browse_term,
+        context_org,
+        context_loc_group,
+        staff,
+        pivot_id,
+        force_backward,
+        result_limit,
+        result_offset
+    );
+END;
+$p$ LANGUAGE PLPGSQL;
+
+
 COMMIT;
index 56e4f7e..d96528f 100644 (file)
@@ -108,37 +108,37 @@ INSERT INTO config.xml_transform VALUES ( 'mods33', 'http://www.loc.gov/mods/v3'
 INSERT INTO config.xml_transform VALUES ( 'marc21expand880', 'http://www.loc.gov/MARC21/slim', 'marc', '' );
 
 -- Index Definitions
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field ) VALUES 
-    (1, 'series', 'seriestitle', oils_i18n_gettext(1, 'Series Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:relatedItem[@type="series"]/mods32:titleInfo$$, TRUE );
-
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath ) VALUES 
-    (2, 'title', 'abbreviated', oils_i18n_gettext(2, 'Abbreviated Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='abbreviated')]$$ );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath ) VALUES 
-    (3, 'title', 'translated', oils_i18n_gettext(3, 'Translated Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='translated')]$$ );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath ) VALUES 
-    (4, 'title', 'alternative', oils_i18n_gettext(4, 'Alternate Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='alternative')]$$ );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath ) VALUES 
-    (5, 'title', 'uniform', oils_i18n_gettext(5, 'Uniform Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='uniform')]$$ );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath ) VALUES 
-    (6, 'title', 'proper', oils_i18n_gettext(6, 'Title Proper', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleNonfiling[mods32:title and not (@type)]$$ );
-
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field ) VALUES 
-    (7, 'author', 'corporate', oils_i18n_gettext(7, 'Corporate Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='corporate' and (mods32:role/mods32:roleTerm[text()='creator'] or mods32:role/mods32:roleTerm[text()='aut'] or mods32:role/mods32:roleTerm[text()='cre'])]$$, $$//*[local-name()='namePart']$$, TRUE ); -- /* to fool vim */;
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field ) VALUES 
-    (8, 'author', 'personal', oils_i18n_gettext(8, 'Personal Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='personal' and mods32:role/mods32:roleTerm[text()='creator']]$$, $$//*[local-name()='namePart']$$, TRUE ); -- /* to fool vim */;
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field ) VALUES 
-    (9, 'author', 'conference', oils_i18n_gettext(9, 'Conference Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='conference' and mods32:role/mods32:roleTerm[text()='creator']]$$, $$//*[local-name()='namePart']$$, TRUE ); -- /* to fool vim */;
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field ) VALUES 
-    (10, 'author', 'other', oils_i18n_gettext(10, 'Other Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='personal' and not(mods32:role/mods32:roleTerm[text()='creator'])]$$, $$//*[local-name()='namePart']$$, TRUE ); -- /* to fool vim */;
-
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field ) VALUES 
-    (11, 'subject', 'geographic', oils_i18n_gettext(11, 'Geographic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:geographic$$, TRUE );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field ) VALUES 
-    (12, 'subject', 'name', oils_i18n_gettext(12, 'Name Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:name$$, $$//*[local-name()='namePart']$$, TRUE ); -- /* to fool vim */;
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field ) VALUES 
-    (13, 'subject', 'temporal', oils_i18n_gettext(13, 'Temporal Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:temporal$$, TRUE );
-INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field ) VALUES 
-    (14, 'subject', 'topic', oils_i18n_gettext(14, 'Topic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:topic$$, TRUE );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
+    (1, 'series', 'seriestitle', oils_i18n_gettext(1, 'Series Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:relatedItem[@type="series"]/mods32:titleInfo$$, TRUE, '//@xlink:href' );
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, authority_xpath ) VALUES 
+    (2, 'title', 'abbreviated', oils_i18n_gettext(2, 'Abbreviated Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='abbreviated')]$$, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, authority_xpath ) VALUES 
+    (3, 'title', 'translated', oils_i18n_gettext(3, 'Translated Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='translated')]$$, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, authority_xpath ) VALUES 
+    (4, 'title', 'alternative', oils_i18n_gettext(4, 'Alternate Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='alternative')]$$, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, authority_xpath ) VALUES 
+    (5, 'title', 'uniform', oils_i18n_gettext(5, 'Uniform Title', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleInfo[mods32:title and (@type='uniform')]$$, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, authority_xpath ) VALUES 
+    (6, 'title', 'proper', oils_i18n_gettext(6, 'Title Proper', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:titleNonfiling[mods32:title and not (@type)]$$, '//@xlink:href' );
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field , authority_xpath) VALUES 
+    (7, 'author', 'corporate', oils_i18n_gettext(7, 'Corporate Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='corporate' and (mods32:role/mods32:roleTerm[text()='creator'] or mods32:role/mods32:roleTerm[text()='aut'] or mods32:role/mods32:roleTerm[text()='cre'])]$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath ) VALUES 
+    (8, 'author', 'personal', oils_i18n_gettext(8, 'Personal Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='personal' and mods32:role/mods32:roleTerm[text()='creator']]$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath ) VALUES 
+    (9, 'author', 'conference', oils_i18n_gettext(9, 'Conference Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='conference' and mods32:role/mods32:roleTerm[text()='creator']]$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath ) VALUES 
+    (10, 'author', 'other', oils_i18n_gettext(10, 'Other Author', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:name[@type='personal' and not(mods32:role/mods32:roleTerm[text()='creator'])]$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
+
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
+    (11, 'subject', 'geographic', oils_i18n_gettext(11, 'Geographic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:geographic$$, TRUE, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_xpath, facet_field, authority_xpath ) VALUES 
+    (12, 'subject', 'name', oils_i18n_gettext(12, 'Name Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:name$$, $$//*[local-name()='namePart']$$, TRUE, '//@xlink:href' ); -- /* to fool vim */;
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
+    (13, 'subject', 'temporal', oils_i18n_gettext(13, 'Temporal Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:temporal$$, TRUE, '//@xlink:href' );
+INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, facet_field, authority_xpath ) VALUES 
+    (14, 'subject', 'topic', oils_i18n_gettext(14, 'Topic Subject', 'cmf', 'label'), 'mods32', $$//mods32:mods/mods32:subject/mods32:topic$$, TRUE, '//@xlink:href' );
 --INSERT INTO config.metabib_field ( id, field_class, name, format, xpath ) VALUES 
 --  ( id, field_class, name, xpath ) VALUES ( 'subject', 'genre', 'mods32', $$//mods32:mods/mods32:genre$$ );
 INSERT INTO config.metabib_field ( id, field_class, name, label, format, xpath, browse_field ) VALUES 
@@ -9023,6 +9023,19 @@ INSERT INTO config.global_flag (name, label, value, enabled)
         TRUE
     );
 
+INSERT INTO config.global_flag (name, value, enabled, label)
+VALUES (
+    'opac.browse.warnable_regexp_per_class',
+    '{"title": "^(a|the|an)\\s"}',
+    FALSE,
+    oils_i18n_gettext(
+        'opac.browse.warnable_regexp_per_class',
+        'Map of search classes to regular expressions to warn user about leading articles.',
+        'cgf',
+        'label'
+    )
+);
+
 
 INSERT INTO config.usr_setting_type (name,opac_visible,label,description,datatype)
     VALUES (
index 404480f..e419dae 100644 (file)
@@ -1537,7 +1537,7 @@ BEGIN
             FROM authority.control_set_authority_field
             WHERE tag IN (
                 SELECT UNNEST(
-                    XPATH('//*[starts-with(@tag,"1")]/@tag',rec_marc::XML)::TEXT[]
+                    XPATH('//*[starts-with(@tag,"1")]/@tag',rec_marc_xml)::TEXT[]
                 )
             ) LIMIT 1;
 
index 0424a77..7901876 100644 (file)
@@ -33,7 +33,7 @@ BEGIN
             FROM authority.control_set_authority_field
             WHERE tag IN (
                 SELECT UNNEST(
-                    XPATH('//*[starts-with(@tag,"1")]/@tag',rec_marc::XML)::TEXT[]
+                    XPATH('//*[starts-with(@tag,"1")]/@tag',rec_marc_xml::XML)::TEXT[]
                 )
             ) LIMIT 1;
 
index ba30e46..0a8d11c 100644 (file)
@@ -2,9 +2,7 @@ BEGIN;
 
 -- check whether patch can be applied
 -- SELECT evergreen.upgrade_deps_block_check('YYYY', :eg_version);
-
-ALTER TABLE metabib.browse_entry_def_map
-    ADD COLUMN authority BIGINT REFERENCES authority.record_entry (id)
+ALTER TABLE metabib.browse_entry_def_map ADD COLUMN authority BIGINT REFERENCES authority.record_entry (id)
         ON DELETE SET NULL;
 
 ALTER TABLE config.metabib_field ADD COLUMN authority_xpath TEXT;
@@ -184,6 +182,7 @@ BEGIN
                 output_row.field = idx.id;
                 output_row.source = rid;
                 output_row.value = BTRIM(REGEXP_REPLACE(browse_text, E'\\s+', ' ', 'g'));
+                output_row.authority := NULL;
 
                 IF idx.authority_xpath IS NOT NULL AND idx.authority_xpath <> '' THEN
                     authority_text := oils_xpath_string(
@@ -6882,4 +6881,251 @@ Revision 1.2 - Added Log Comment  2003/03/24 19:37:42  ckeith
        </xsl:template>
 </xsl:stylesheet>$$ WHERE name = 'mods33';
 
+
+INSERT INTO config.global_flag (name, value, enabled, label) 
+VALUES (
+    'opac.browse.warnable_regexp_per_class',
+    '{"title": "^(a|the|an)\\s"}',
+    FALSE,
+    oils_i18n_gettext(
+        'opac.browse.warnable_regexp_per_class',
+        'Map of search classes to regular expressions to warn user about leading articles.',
+        'cgf',
+        'label'
+    )
+);
+
+ALTER TABLE metabib.browse_entry ADD COLUMN sort_value TEXT;
+
+CREATE OR REPLACE FUNCTION metabib.browse_entry_sort_value()
+RETURNS TRIGGER AS $$
+    BEGIN
+        NEW.sort_value = public.search_normalize(NEW.value);
+        RETURN NEW;
+    END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER mbe_sort_value
+BEFORE INSERT OR UPDATE ON metabib.browse_entry
+FOR EACH ROW EXECUTE PROCEDURE metabib.browse_entry_sort_value();
+
+UPDATE metabib.browse_entry SET value = value;
+
+ALTER TABLE metabib.browse_entry ALTER COLUMN sort_value SET NOT NULL;
+
+CREATE INDEX CONCURRENTLY browse_entry_sort_value_idx
+    ON metabib.browse_entry USING BTREE (sort_value);
+
+-- NOTE If I understand ordered indices correctly, an index on sort_value DESC
+-- is not actually needed, even though we do have a query that does ORDER BY
+-- on this column in that direction.  The previous index serves for both
+-- directions, and ordering in an index is only helpful for multi-column
+-- indices, I think. See http://www.postgresql.org/docs/9.1/static/indexes-ordering.html
+
+-- CREATE INDEX CONCURRENTLY browse_entry_sort_value_idx_desc
+--     ON metabib.browse_entry USING BTREE (sort_value DESC);
+
+CREATE TYPE metabib.flat_browse_entry_appearance AS (
+    browse_entry    BIGINT,
+    value           TEXT,
+    fields          TEXT,
+    authorities     TEXT,
+    sources         INT,        -- visible ones, that is
+    row_number      INT         -- internal use, sort of
+);
+
+
+CREATE OR REPLACE FUNCTION metabib.browse_pivot(
+    search_field        INT[],
+    browse_term         TEXT
+) RETURNS BIGINT AS $p$
+DECLARE
+    id                  BIGINT;
+BEGIN
+    SELECT INTO id mbe.id FROM metabib.browse_entry mbe
+        JOIN metabib.browse_entry_def_map mbedm ON (
+            mbedm.entry = mbe.id AND
+            mbedm.def = ANY(search_field)
+        )
+        WHERE mbe.sort_value >= public.search_normalize(browse_term)
+        ORDER BY mbe.sort_value, mbe.value LIMIT 1;
+
+    RETURN id;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION metabib.staged_browse(
+    core_query              TEXT,
+    context_org             INT,
+    context_locations       INT[],
+    staff                   BOOL,
+    result_limit            INT,
+    use_offset              INT
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+DECLARE
+    core_cursor             REFCURSOR;
+    core_record             RECORD;
+    qpfts_query             TEXT;
+    result_row              metabib.flat_browse_entry_appearance%ROWTYPE;
+    results_skipped         INT := 0;
+    results_returned        INT := 0;
+BEGIN
+    OPEN core_cursor FOR EXECUTE core_query;
+
+    LOOP
+        FETCH core_cursor INTO core_record;
+        EXIT WHEN NOT FOUND;
+
+        qpfts_query :=
+            'SELECT NULL::BIGINT AS id, ARRAY[r] AS records, 1::INT AS rel ' ||
+            'FROM (SELECT UNNEST(' ||
+            quote_literal(core_record.records) || '::BIGINT[]) AS r) rr';
+
+        -- We use search.query_parser_fts() for visibility testing. Yes there
+        -- is a reason we feed it the records for one mbe at a time instead of
+        -- the records for `result_limit` mbe's at a time.
+        SELECT INTO result_row.sources visible
+            FROM search.query_parser_fts(
+                context_org, NULL, qpfts_query, NULL,
+                context_locations, 0, NULL, NULL, FALSE, staff, FALSE
+            ) qpfts
+            WHERE qpfts.rel IS NULL;
+
+        IF result_row.sources > 0 THEN
+            IF results_skipped < use_offset THEN
+                results_skipped := results_skipped + 1;
+                CONTINUE;
+            END IF;
+
+            result_row.browse_entry := core_record.id;
+            result_row.authorities := core_record.authorities;
+            result_row.fields := core_record.fields;
+            result_row.value := core_record.value;
+
+            -- This is needed so our caller can flip it and reverse it.
+            result_row.row_number := results_returned;
+
+            RETURN NEXT result_row;
+
+            results_returned := results_returned + 1;
+
+            EXIT WHEN results_returned >= result_limit;
+        END IF;
+    END LOOP;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+-- This is optimized to be fast for values of result_offset near zero.
+CREATE OR REPLACE FUNCTION metabib.browse(
+    search_field            INT[],
+    browse_term             TEXT,
+    context_org             INT DEFAULT NULL,
+    context_loc_group       INT DEFAULT NULL,
+    staff                   BOOL DEFAULT FALSE,
+    pivot_id                BIGINT DEFAULT NULL,
+    force_backward          BOOL DEFAULT FALSE,
+    result_limit            INT DEFAULT 10,
+    result_offset           INT DEFAULT 0   -- Can be negative!
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+DECLARE
+    core_query              TEXT;
+    whole_query             TEXT;
+    pivot_sort_value        TEXT;
+    pivot_sort_fallback     TEXT;
+    context_locations       INT[];
+    use_offset              INT;
+    results_skipped         INT := 0;
+BEGIN
+    IF pivot_id IS NULL THEN
+        pivot_id := metabib.browse_pivot(search_field, browse_term);
+    END IF;
+
+    SELECT INTO pivot_sort_value, pivot_sort_fallback
+        sort_value, value FROM metabib.browse_entry where id = pivot_id;
+
+    IF pivot_sort_value IS NULL THEN
+        RETURN;
+    END IF;
+
+    IF context_loc_group IS NOT NULL THEN
+        SELECT INTO context_locations ARRAY_AGG(location)
+            FROM asset.copy_location_group_map
+            WHERE lgroup = context_loc_group;
+    END IF;
+
+    core_query := '
+    SELECT
+        mbe.id,
+        mbe.value,
+        mbe.sort_value,
+        (SELECT ARRAY_AGG(src) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.source)) AS src
+        ) ss) AS records,
+        (SELECT ARRAY_TO_STRING(ARRAY_AGG(authority), $$,$$) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.authority)) AS authority
+        ) au) AS authorities,
+        (SELECT ARRAY_TO_STRING(ARRAY_AGG(field), $$,$$) FROM (
+            SELECT DISTINCT UNNEST(ARRAY_AGG(mbedm.def)) AS field
+        ) fi) AS fields
+    FROM metabib.browse_entry mbe
+    JOIN metabib.browse_entry_def_map mbedm ON (
+        mbedm.entry = mbe.id AND
+        mbedm.def = ANY(' || quote_literal(search_field) || ')
+    )
+    WHERE ';
+
+    -- PostgreSQL is not magic. We can't actually pass a negative offset.
+    IF result_offset >= 0 AND NOT force_backward THEN
+        use_offset := result_offset;
+        core_query := core_query ||
+            ' mbe.sort_value >= ' || quote_literal(pivot_sort_value) ||
+        ' GROUP BY 1,2,3 ORDER BY mbe.sort_value, mbe.value ';
+
+        RETURN QUERY SELECT * FROM metabib.staged_browse(
+            core_query, context_org, context_locations,
+            staff, result_limit, use_offset
+        );
+    ELSE
+        -- Part 1 of 2 to deliver what the user wants with a negative offset:
+        core_query := core_query ||
+            ' mbe.sort_value < ' || quote_literal(pivot_sort_value) ||
+        ' GROUP BY 1,2,3 ORDER BY mbe.sort_value DESC, mbe.value DESC ';
+
+        -- Part 2 of 2 to deliver what the user wants with a negative offset:
+        RETURN QUERY SELECT * FROM (SELECT * FROM metabib.staged_browse(
+            core_query, context_org, context_locations,
+            staff, result_limit, use_offset
+        )) sb ORDER BY row_number DESC;
+
+    END IF;
+END;
+$p$ LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION metabib.browse(
+    search_class        TEXT,
+    browse_term         TEXT,
+    context_org         INT DEFAULT NULL,
+    context_loc_group   INT DEFAULT NULL,
+    staff               BOOL DEFAULT FALSE,
+    pivot_id            BIGINT DEFAULT NULL,
+    force_backward      BOOL DEFAULT FALSE,
+    result_limit        INT DEFAULT 10,
+    result_offset       INT DEFAULT 0   -- Can be negative, implying backward!
+) RETURNS SETOF metabib.flat_browse_entry_appearance AS $p$
+BEGIN
+    RETURN QUERY SELECT * FROM metabib.browse(
+        (SELECT COALESCE(ARRAY_AGG(id), ARRAY[]::INT[])
+            FROM config.metabib_field WHERE field_class = search_class),
+        browse_term,
+        context_org,
+        context_loc_group,
+        staff,
+        pivot_id,
+        force_backward,
+        result_limit,
+        result_offset
+    );
+END;
+$p$ LANGUAGE PLPGSQL;
+
 COMMIT;
index 220c56f..5c5a2c9 100644 (file)
@@ -6,11 +6,14 @@
     loc = ctx.search_ou;
 -%]
     <div id="search-wrapper">
-        <div id="search-box">
+        <div id="search-tools">
             <span class="search_catalog_lbl">[% l('Search the Catalog') %]</span>
-            <a href="[% mkurl(ctx.opac_root _ '/home') %]"
-                id="home_adv_search_link"><span
-                class="adv_search_font">[%l('Basic Search')%]</span></a>
+            <span><a href="[% mkurl(ctx.opac_root _ '/home') %]"
+                    id="home_adv_search_link">[%l('Basic Search')%]</a></span>
+
+            <span><a href="[% mkurl(ctx.opac_root _ '/browse') %]">[%
+                    l('Browse the Catalog')%]</a></span>
+
         </div>
         <div id="adv_search_parent">
             <div id="adv_search_tabs">
diff --git a/Open-ILS/src/templates/opac/browse.tt2 b/Open-ILS/src/templates/opac/browse.tt2
new file mode 100644 (file)
index 0000000..7e7a8b7
--- /dev/null
@@ -0,0 +1,128 @@
+[%- # This is the bib and authority combined record browser.
+
+    PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/org_selector.tt2";
+    WRAPPER "opac/parts/base.tt2";
+    INCLUDE "opac/parts/topnav.tt2";
+
+    ctx.page_title = l("Browse the Catalog");
+    blimit = CGI.param('blimit') || 10;
+    boffset = CGI.param('boffset') || 0;
+
+    depart_list = ['blimit', 'bterm', 'boffset', 'bpivot', 'bback'];
+%]
+
+    <div id="search-wrapper">
+        [%# XXX TODO Give searchbar.tt2 more smarts so we can just do:
+          # INCLUDE "opac/parts/searchbar.tt2" %]
+        <div id="search-tools">
+            <span class="search_catalog_lbl"><a href="[% mkurl(ctx.opac_root _ '/home', {}, depart_list) %]">[% l('Search the Catalog') %]</a></span>
+            <span><a href="[% mkurl(ctx.opac_root _ '/advanced', {}, depart_list) %]"
+                    id="home_adv_search_link">[%l('Advanced Search')%]</a></span>
+            <span>[% l('Browse the Catalog') %]</span>
+        </div>
+    </div>
+    <div id="content-wrapper">
+        <div id="main-content">
+            <div id="browse-the-catalog">
+                <div id="browse-controls">
+                    <form method="get">
+                        <input type="hidden" name="blimit"
+                            value="[% blimit %]" />
+
+                        <label for="browse-search-class">[%
+                            l('Browse by') %]</label>
+                        [% INCLUDE "opac/parts/qtype_selector.tt2"
+                            id="browse-search-class" browse_only=1 %]
+
+                        <label for="browse-term">[% l('for') %]</label>
+                        <input type="text" name="bterm" id="browse-term"
+                            value="[% CGI.param('bterm') | html %]" />
+
+                        <label for="browse-context">[%
+                            l('held under') %]</label>
+                        [% INCLUDE build_org_selector id='browse-context'
+                            show_loc_groups=1
+                            arialabel=l('Select holding library') %]
+
+                        <input type="submit" value="[% l('Go') %]" />
+                    </form>
+                </div>
+
+                [% BLOCK browse_pager %]
+                <div class="browse-pager">
+                    [% IF ctx.more_back %]
+                    <a class="opac-button" href="[% mkurl('', {bpivot => ctx.back_pivot, bback => 1}) %]">&larr; [%l ('Back') %]</a>
+                    [% END %]
+                    [% IF browse.english_pager; # XXX how to apply i18n here?
+                        current_qtype = CGI.param('qtype') || 'title' %]
+                    <span class="browse-shortcuts">
+                        <a href="[% mkurl('', {qtype => current_qtype, bterm => '0'}, ['boffset','bpivot']) %]">0-9</a>
+                        [% FOR letter IN ['A'..'Z'] %]
+                            <a href="[% mkurl('', {qtype => current_qtype, bterm => letter}, ['boffset','bpivot']) %]">[% letter %]</a>
+                        [% END %]
+                    </span>
+                    [% END %]
+
+                    [% IF ctx.more_forward %]
+                    <a class="opac-button" href="[% mkurl('', {bpivot => ctx.forward_pivot}, ['bback']) %]">[%l ('Forward') %] &rarr;</a>
+                    [% END %]
+                </div>
+                [% END %]
+
+                [% PROCESS browse_pager %]
+
+                <div id="browse-results">
+                [% IF ctx.browse_error %]
+                    <span class="browse-error">
+                        [% l("An error occurred browsing records. " _
+                        "Please try again in a moment or report the issue " _
+                        "to library staff.") %]
+                    </span>
+                [% ELSE %]
+                    [% IF ctx.browse_leading_article_warning %]
+                    <div class="browse-leading-article-warning">
+                            [% l("Your browse term seems to begin with an article. You might get better results by omitting the article.") %]
+                    </div>
+                    [% END %]
+                    <ul class="browse-result-list">
+                    [% FOR result IN ctx.browse_results %]
+                        <li class="browse-result">
+                            <span class="browse-result-value">
+                                <a href="[% mkurl(
+                                    ctx.opac_root _ '/results', {
+                                        'fi:has_browse_entry' => (result.browse_entry _ ',' _ result.fields)
+                                    }) %]">[% result.value | html %]</a>
+                            </span>
+                            <span class="browse-result-sources">([% result.sources %])</span>
+                            [% IF result.authorities %]
+                            <ul class="browse-result-authority-headings">
+                                [% FOR a IN result.authorities;
+                                    NEXT UNLESS a.control_set;  # Can't deal.
+
+                                    # get_authority_fields is fast and cache-y.
+                                    acs = ctx.get_authority_fields(a.control_set);
+                                    FOR h IN a.headings;
+                                        field_id = h.keys.0;
+                                        field = acs.$field_id;
+                                        headings_themselves = h.values.0 %]
+                                        <li>[% field.name %] <a href="[% mkurl(ctx.opac_root _ '/results', {query => 'identifier|authority_id[' _ a.target _ ']'}) %]">[% headings_themselves.join(";") %]</a>
+                                            <span class="browse-result-authority-bib-links">([% a.count %])</span></li>
+                                    [% END %]
+                                [% END %]
+                            </ul>
+                            [% END %]
+                        </li>
+                    [% END %]
+                    </ul>
+                [% END %]
+                </div>
+
+                [% PROCESS browse_pager %]
+            </div>
+
+            <div class="common-full-pad"></div>        
+        </div>
+    </div>
+[% END %]
index 959609d..d11e016 100644 (file)
@@ -870,10 +870,6 @@ table.acct_notes th {
     padding-right: 5px;
 }
 
-.adv_search_font {
-    font-size: [% css_fonts.size_smaller %];
-}
-
 .search_catalog_lbl {
     font-size: [% css_fonts.size_bigger %];
 }
@@ -1515,3 +1511,27 @@ a.preflib_change {
 .record_author {
     font-style: italic;
 }
+
+#search-tools > span {
+    margin: 0 1em;
+}
+.browse-error {
+    font-weight: bold;
+    font-color: #c00;
+}
+.browse-result-sources, .browse-result-authority-bib-links {
+    margin-left: 1em;
+}
+.browse-pager {
+    margin: 2ex 0;
+}
+.browse-result-list {
+    list-style-type: square;
+}
+.browse-shortcuts {
+    font-size: 120%;
+}
+.browse-leading-article-warning {
+    font-style: italic;
+    font-size: 110%;
+}
index c8e87ef..39d7d71 100644 (file)
@@ -149,4 +149,13 @@ search.basic_config = {
 # Set to 1 or 'true' to enable
 ctx.google_books_preview = 0;
 
+##############################################################################
+# Browse settings
+# Set to 1 or 'true' to enable.  This controls whether or not the
+# "0-9 A B C D ..." links appear on the browse page.  We don't yet have a
+# serviceable way to internationalize these links, so sites must choose to
+# turn on this feature.
+
+browse.english_pager = 0;
+
 %]
index eda10a3..30edbc6 100644 (file)
@@ -1,16 +1,17 @@
 [%  query_types = [
     {value => "keyword", label => l("Keyword")},
-    {value => "title", label => l("Title")},
+    {value => "title", label => l("Title"), browse => 1},
     {value => "jtitle", label => l("Journal Title")},
-    {value => "author", label => l("Author")},
-    {value => "subject", label => l("Subject")},
-    {value => "series", label => l("Series")},
+    {value => "author", label => l("Author"), browse => 1},
+    {value => "subject", label => l("Subject"), browse => 1},
+    {value => "series", label => l("Series"), browse => 1},
     {value => "id|bibcn", label => l("Bib Call Number")}
 ] %]
-<select name="qtype"[% IF id; ' id="'; id ; '"' ; END -%]
+<select name="[% name || 'qtype' %]"[% IF id; ' id="'; id ; '"' ; END -%]
     aria-label="[% l('Select query type:') %]">
     [%  query_type = query_type || CGI.param('qtype') || search.default_qtypes.0;
-        FOR qt IN query_types -%]
+      FOR qt IN query_types;
+        NEXT IF browse_only AND NOT qt.browse -%]
     <option value='[% qt.value | html %]'[%
         query_type == qt.value ? ' selected="selected"' : ''
     %]>[% qt.label | html %]</option>
index 0359c83..52381a8 100644 (file)
@@ -3,11 +3,11 @@
     [% UNLESS took_care_of_form -%]
     <form action="[% ctx.opac_root %]/results" method="get">
     [%- END %]
-    <div>
+    <div id="search-tools">
         <span class="search_catalog_lbl">[% l('Search the Catalog') %]</span>
         <a href="[% mkurl(ctx.opac_root _ '/advanced') %]"
-            id="home_adv_search_link"><span
-            class="adv_search_font">[% l('Advanced Search') %]</span></a>
+            id="home_adv_search_link">[% l('Advanced Search') %]</a>
+        <span class="browse_the_catalog_lbl"><a href="[% mkurl(ctx.opac_root _ '/browse', {}, ['fi:has_browse_entry']) %]">[% l('Browse the Catalog') %]</a></span>
     </div>
     <div class="searchbar">[%- l('Search ');
         IF search.basic_config.type == 'attr';
index 88f3284..dfe8a6f 100644 (file)
@@ -19,7 +19,6 @@
 #new_cat_link_holder a { display: block; width: 675px; height: 213px; }
 .pos-rel { position: relative; }
 #search-box table { position: relative; left: -10px; }
-#home_adv_search_link { position: relative; top: -1px; left: 10px; }
 #util_back_btn { position: relative; top: 1px; left: 10px; }
 #util_help_btn { position: relative; top: 2px; left: 40px; }
 #util_forw_btn { position: relative; top: 2px; left: 50px; }
diff --git a/docs/RELEASE_NOTES_NEXT/OPAC/BibAuthBrowse.txt b/docs/RELEASE_NOTES_NEXT/OPAC/BibAuthBrowse.txt
new file mode 100644 (file)
index 0000000..279bfbd
--- /dev/null
@@ -0,0 +1,45 @@
+Bib record browser with linked authorities
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This feature provides a patron-oriented OPAC interface for browsing
+bibliographic records.
+
+Users choose to browse by Author, Title, Subject, or Series. They then
+enter a browse term, and the nearest match from a left-anchored search
+on the headings extracted for browse purposes will be displayed in a
+typical backwards/forwards paging display. Headings link to search
+results pages showing the related records. If the browse heading is
+linked to any authority records, and if any *other* authority records
+point to those with "See also" or other non-main entry headings, those
+alternative headings are displayed a linked to a search results page
+showing related bib records related to the alternate heading.
+
+The counts of holdings displayed next to headings from bibliographic
+records are subject to the same visiibility tests as search. This means
+that the org unit (and copy location group) dropdown on the browse
+interface affects counds, and it further means that whether or not
+you're looking at the browse interface through the staff client makes a
+difference.
+
+Configuration considerations for site administrators
+++++++++++++++++++++++++++++++++++++++++++++++++++++
+There are two off-by-default features that site administrators may wish
+to enable.
+
+  * Quick paging links (English): By changing the
+    ''browse.english_pager'' setting to 1 in the
+    ''opac/parts/config.tt2'' file for a site's active OPAC templates,
+    you can make shortcut browsing links ''0-9 A B C D ...'' appear
+    between the Back and Forward buttons on the browse page. I haven't
+    figured out how to make this feature internationalizable, so it's
+    off by default.  You can turn it on if it works for your language,
+    or have a look at improving it if it doesn't.
+
+  * There is a global flag by the name
+    ''opac.browse.warnable_regexp_per_class'' to control what leading
+    articles in users' entered browse terms trigger a warning about how
+    it might be better to search for "Rolling Stones" instead of "The
+    Rolling Stones" (or whatever). This is off by default, but can be
+    enabled if it suits your catalog, and can even be customized per
+    search class (author, title, series, subject).
+