[open-ils-commits] [GIT] Evergreen ILS branch master updated. 13973bc1ce189b6ede2afde59b9f8c8487be75b0

Evergreen Git git at git.evergreen-ils.org
Thu Feb 14 14:26:55 EST 2013


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  13973bc1ce189b6ede2afde59b9f8c8487be75b0 (commit)
       via  a280708fe305cfcb746ee91662e0173ebc946864 (commit)
       via  5958b038b5a5d7e0a5b3296c486b71e10381f79d (commit)
       via  7bbb842790fa7624890c69543d94cfc07ed9f21b (commit)
       via  99d7cfa1ff96e45e6925f309768e047bf67fcbd4 (commit)
       via  2ef32ae20b71628bc6b913c3df70260223426629 (commit)
       via  6801cd2653aae17da606441a1d64fa7723faed4f (commit)
       via  b7a5f1040ffb678f2db0fb8c05321c9291414007 (commit)
       via  3a2f6181daf37eeb4bb6b4357fad50762f4d1a92 (commit)
       via  61195ba35fbf3a5a557be81b03af3e70d7a938c1 (commit)
       via  9e1d009950edc1d2b9bea1aa11ff047d7f9c802d (commit)
       via  073bb779d7c11c6e74dba9a1f0023536cccd9872 (commit)
       via  23101c610ccd606764e4f531ee2a9994c935f81f (commit)
      from  eccdd75a4a9a2b5b0ea73311586d352cd5f2aa5d (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 13973bc1ce189b6ede2afde59b9f8c8487be75b0
Author: Mike Rylander <mrylander at gmail.com>
Date:   Thu Feb 14 14:24:00 2013 -0500

    Stamping upgrades for Link Checker
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 8463598..0419423 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -87,7 +87,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('0751', :eg_version); -- berick/senator
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('0754', :eg_version); -- senator/miker
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/0752.schema.url_verify.sql
similarity index 98%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
rename to Open-ILS/src/sql/Pg/upgrade/0752.schema.url_verify.sql
index ddddd41..ccf0372 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/0752.schema.url_verify.sql
@@ -1,5 +1,7 @@
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('0752', :eg_version);
+
 INSERT INTO container.biblio_record_entry_bucket_type (code, label) VALUES ('url_verify', 'URL Verification Queue');
 
 DROP SCHEMA IF EXISTS url_verify CASCADE;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/0753.functions.url_verify.sql
similarity index 98%
rename from Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
rename to Open-ILS/src/sql/Pg/upgrade/0753.functions.url_verify.sql
index e80cb25..026b4a2 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/0753.functions.url_verify.sql
@@ -1,5 +1,7 @@
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('0753', :eg_version);
+
 CREATE OR REPLACE FUNCTION url_verify.parse_url (url_in TEXT) RETURNS url_verify.url AS $$
 
 use Rose::URI;
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/0754.data.url_verify.sql
similarity index 98%
rename from Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
rename to Open-ILS/src/sql/Pg/upgrade/0754.data.url_verify.sql
index 968361b..eecbca8 100644
--- a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/0754.data.url_verify.sql
@@ -4,6 +4,8 @@
 
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('0754', :eg_version);
+
 INSERT INTO permission.perm_list (id, code, description) 
     VALUES ( 
         543, 

commit a280708fe305cfcb746ee91662e0173ebc946864
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Tue Jan 8 18:54:10 2013 -0500

    Link checker: URL extraction bugfix and usability improvements
    
    The database procedure for URL extraction did not correctly handle
    URLs in repeating subfields in the same tag.  Now fixed.
    
    You now get a hint if you try to create a session with a name you have
    used before.
    
    Some UI elements on the create session interface have been reordered to
    be more logical.
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml
index 1c458ad..c37226b 100644
--- a/Open-ILS/src/extras/ils_events.xml
+++ b/Open-ILS/src/extras/ils_events.xml
@@ -776,6 +776,9 @@
         <event code='2004' textcode='ACTOR_USER_DELETE_OPEN_XACTS'>
                 <desc xml:lang="en-US">The user you have attempted to delete cannot be deleted because it has open circulations and/or unpaid bills.</desc>
         </event>
+	<event code='2005' textcode='OBJECT_UNIQUE_IDENTIFIER_USED'>
+		<desc xml:lang="en-US">You are trying to create an object with a unique identifier (such as a 'name' field) that is already in use.</desc>
+	</event>
 
     <!-- CREDIT EVENTS -->
 	<event code='4001' textcode='CREDIT_PROCESSOR_NOT_ENABLED'>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
index cbda944..0d5e827 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -658,6 +658,13 @@ sub create_session {
     $owning_lib ||= $e->requestor->ws_ou;
     return $e->die_event unless $e->allowed("URL_VERIFY", $owning_lib);
 
+    $name .= "";
+    my $name_test = $e->search_url_verify_session({name => $name});
+    return $e->die_event unless $name_test; # db error
+    return $e->die_event(
+        new OpenILS::Event("OBJECT_UNIQUE_IDENTIFIER_USED", note => "name"),
+    ) if @$name_test;   # already existing sessions with that name
+
     my $session = Fieldmapper::url_verify::session->new;
     $session->name($name);
     $session->owning_lib($owning_lib);
diff --git a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
index 5443d51..dda7fbc 100644
--- a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
@@ -62,6 +62,7 @@ CREATE TRIGGER ingest_url_tgr
 
 CREATE OR REPLACE FUNCTION url_verify.extract_urls ( session_id INT, item_id INT ) RETURNS INT AS $$
 DECLARE
+    last_seen_tag TEXT;
     current_tag TEXT;
     current_sf TEXT;
     current_url TEXT;
@@ -86,6 +87,12 @@ BEGIN
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
+            IF current_tag IS NULL THEN
+                current_tag := last_seen_tag;
+            ELSE
+                last_seen_tag := current_tag;
+            END IF;
+
             SELECT  (XPATH(current_selector.xpath || '/@code', b.marc::XML))[current_url_pos]::TEXT INTO current_sf
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
index bed0ae3..e80cb25 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -46,6 +46,7 @@ CREATE TRIGGER ingest_url_tgr
 
 CREATE OR REPLACE FUNCTION url_verify.extract_urls ( session_id INT, item_id INT ) RETURNS INT AS $$
 DECLARE
+    last_seen_tag TEXT;
     current_tag TEXT;
     current_sf TEXT;
     current_url TEXT;
@@ -70,6 +71,12 @@ BEGIN
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
+            IF current_tag IS NULL THEN
+                current_tag := last_seen_tag;
+            ELSE
+                last_seen_tag := current_tag;
+            END IF;
+
             SELECT  (XPATH(current_selector.xpath || '/@code', b.marc::XML))[current_url_pos]::TEXT INTO current_sf
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
diff --git a/Open-ILS/src/templates/url_verify/create_session.tt2 b/Open-ILS/src/templates/url_verify/create_session.tt2
index 5c575d0..734b981 100644
--- a/Open-ILS/src/templates/url_verify/create_session.tt2
+++ b/Open-ILS/src/templates/url_verify/create_session.tt2
@@ -94,18 +94,6 @@
 
             <tr>
                 <th>
-                    <label for="no-url-selection">[% l('Process immediately?') %]</label>
-                </th>
-                <td>
-                    <input dojoType="dijit.form.CheckBox" id="no-url-selection"
-                        jsId="no_url_selection" />
-                </td>
-                <td class="note">
-                </td>
-            </tr>
-
-            <tr>
-                <th>
                     [% l('Tags and subfields to search for URLs:') %]
                 </th>
                 <td>
@@ -122,6 +110,19 @@
                 <td class="note">
                 </td>
             </tr>
+
+            <tr>
+                <th>
+                    <label for="no-url-selection">[% l('Process immediately?') %]</label>
+                </th>
+                <td>
+                    <input dojoType="dijit.form.CheckBox" id="no-url-selection"
+                        jsId="no_url_selection" />
+                </td>
+                <td class="note">
+                </td>
+            </tr>
+
         </table>
 
         <div>
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
index c17681c..7808f25 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
@@ -83,6 +83,8 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                         /* I think we're modal enough to get away with this. */
                         module.session_id = r;
                         module.save_tags();
+                    } else {
+                        module.progress_dialog.hide();
                     }
                 }
             }

commit 5958b038b5a5d7e0a5b3296c486b71e10381f79d
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Wed Nov 21 13:49:59 2012 -0500

    Link checker: Some UI tweaks suggested by George Duimovich
    
      - Make the "Filter" link above FlattenerGrids a button and not a link.
    
      - Instead of IDs as links in some grid columns, have the ID show up in
        plain text and have links with a more descriptive name sit next to the
        ID.
    
      - Correct the settings for saving grid columns on the Select URLs and
        Review Attempt interfaces.
    
      - Tiny i18n fixes (page titles)
    
      - Fix lack of horizontal scrollbar on Select URLs interface, and also
        fix the way that if you clicked on said scrollbar in a case where
        your grid was taller than your browser window, the page would
        automatically scroll up to focus on your grid header row, and you
        couldn't actually manipulate the horizontal scrollbar.  We sadly
        pay for our horiz scrollbar with a doubled vertical scrollbar, but
        possibly someone can figure the Right way to fix such layout
        problems, which actually occur widely in similar interfaces in
        Evergreen.
    
      - Add buttons to download CSV on Select URLs and Review Attempt
        interfaces.
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 120120e..d4272a5 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -12093,17 +12093,17 @@ INSERT INTO config.filter_dialog_interface (key, description) VALUES (
 );
 
 INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
-    'url_verify.select_urls',
-    'url_verify',
+    'ui.grid_columns.url_verify.select_urls',
+    'gui',
     FALSE,
     oils_i18n_gettext(
-        'url_verify.select_urls',
+        'ui.grid_columns.url_verify.select_urls',
         'Link Checker''s URL Selection interface''s saved columns',
         'cust',
         'label'
     ),
     oils_i18n_gettext(
-        'url_verify.select_urls',
+        'ui.grid_columns.url_verify.select_urls',
         'Link Checker''s URL Selection interface''s saved columns',
         'cust',
         'description'
@@ -12112,17 +12112,17 @@ INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,dat
 );
 
 INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
-    'url_verify.review_attempt',
-    'url_verify',
+    'ui.grid_columns.url_verify.review_attempt',
+    'gui',
     FALSE,
     oils_i18n_gettext(
-        'url_verify.review_attempt',
+        'ui.grid_columns.url_verify.review_attempt',
         'Link Checker''s Review Attempt interface''s saved columns',
         'cust',
         'label'
     ),
     oils_i18n_gettext(
-        'url_verify.review_attempt',
+        'ui.grid_columns.url_verify.review_attempt',
         'Link Checker''s Review Attempt interface''s saved columns',
         'cust',
         'description'
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
index 4e14754..968361b 100644
--- a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
@@ -152,17 +152,17 @@ INSERT INTO config.filter_dialog_interface (key, description) VALUES (
 
 
 INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
-    'url_verify.select_urls',
-    'url_verify',
+    'ui.grid_columns.url_verify.select_urls',
+    'gui',
     FALSE,
     oils_i18n_gettext(
-        'url_verify.select_urls',
+        'ui.grid_columns.url_verify.select_urls',
         'Link Checker''s URL Selection interface''s saved columns',
         'cust',
         'label'
     ),
     oils_i18n_gettext(
-        'url_verify.select_urls',
+        'ui.grid_columns.url_verify.select_urls',
         'Link Checker''s URL Selection interface''s saved columns',
         'cust',
         'description'
@@ -171,17 +171,17 @@ INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,dat
 );
 
 INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
-    'url_verify.review_attempt',
-    'url_verify',
+    'ui.grid_columns.url_verify.review_attempt',
+    'gui',
     FALSE,
     oils_i18n_gettext(
-        'url_verify.review_attempt',
+        'ui.grid_columns.url_verify.review_attempt',
         'Link Checker''s Review Attempt interface''s saved columns',
         'cust',
         'label'
     ),
     oils_i18n_gettext(
-        'url_verify.review_attempt',
+        'ui.grid_columns.url_verify.review_attempt',
         'Link Checker''s Review Attempt interface''s saved columns',
         'cust',
         'description'
diff --git a/Open-ILS/src/templates/url_verify/create_session.tt2 b/Open-ILS/src/templates/url_verify/create_session.tt2
index 72983e9..5c575d0 100644
--- a/Open-ILS/src/templates/url_verify/create_session.tt2
+++ b/Open-ILS/src/templates/url_verify/create_session.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER base.tt2 %]
-[% ctx.page_title = "Link Checker - Create Session" %]
+[% ctx.page_title = l("Link Checker - Create Session") %]
 <script type="text/javascript">
     dojo.require("dijit.form.Button");
     dojo.require("dijit.form.CheckBox");
diff --git a/Open-ILS/src/templates/url_verify/review_attempt.tt2 b/Open-ILS/src/templates/url_verify/review_attempt.tt2
index 79cfd70..fa4f3b5 100644
--- a/Open-ILS/src/templates/url_verify/review_attempt.tt2
+++ b/Open-ILS/src/templates/url_verify/review_attempt.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER base.tt2 %]
-[% ctx.page_title = "Link Checker - Review Verification Attempt" %]
+[% ctx.page_title = l("Link Checker - Review Verification Attempt") %]
 <script type="text/javascript">
     dojo.require("dijit.form.Button");
     dojo.require("openils.widget.FlattenerGrid");
@@ -29,6 +29,9 @@
             <button dojoType="dijit.form.Button" onClick="grid.print();">
                 [% l("Print verification results") %]
             </button>
+            <button dojoType="dijit.form.Button"
+                onClick="grid.downloadCSV('[% l("link-checker-results") %]',
+                    progress_dialog);">[% l("Download CSV") %]</button>
         </div>
     </div>
     <div class="oils-acq-basic-roomy">
diff --git a/Open-ILS/src/templates/url_verify/select_urls.tt2 b/Open-ILS/src/templates/url_verify/select_urls.tt2
index 8ad683a..e885597 100644
--- a/Open-ILS/src/templates/url_verify/select_urls.tt2
+++ b/Open-ILS/src/templates/url_verify/select_urls.tt2
@@ -1,5 +1,5 @@
-[% WRAPPER base.tt2 no_content_pane=1 %]
-[% ctx.page_title = "Link Checker - Select URLs" %]
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = l("Link Checker - Select URLs") %]
 <script type="text/javascript">
     dojo.require("dijit.form.Button");
     dojo.require("openils.widget.FlattenerGrid");
@@ -21,20 +21,25 @@
     .url-verify-attempt-info { font-style: italic; }
     #session-name-here { font-weight: normal; font-size: 90%; }
 </style>
-<div class="oils-header-panel" dojoType="dijit.layout.ContentPane" layoutAlign="top">
-    <div>[% ctx.page_title %] - <span id="session-name-here"></span></div>
-    <div class="url-verify-button">
-        <button dojoType="dijit.form.Button"
-            onClick="grid.print();">[%
-            l("Print URLs")
-        %]</button>
-        <button dojoType="dijit.form.Button"
-            onClick="module.verify_selected();">[%
-            l("Verify Selected URLs")
-        %]</button>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+        layoutAlign="top" class="oils-header-panel">
+        <div>[% ctx.page_title %] - <span id="session-name-here"></span></div>
+        <div class="url-verify-button">
+            <button dojoType="dijit.form.Button"
+                onClick="grid.print();">[%
+                l("Print URLs")
+            %]</button>
+            <button dojoType="dijit.form.Button"
+                onClick="grid.downloadCSV('[% l("link-checker-urls") %]',
+                    progress_dialog);">[% l("Download CSV") %]</button>
+            <button dojoType="dijit.form.Button"
+                onClick="module.verify_selected();">[%
+                l("Verify Selected URLs")
+            %]</button>
+        </div>
     </div>
-</div>
-<div dojoType="dijit.layout.ContentPane" layoutAlign="bottom" style="height: 85%;">
+    <div class="oils-acq-basic-roomy"><!-- XXX keep for layout reasons --></div>
     <table jsid="grid"
         dojoType="openils.widget.FlattenerGrid"
         columnPersistKey="'url_verify.select_urls'"
@@ -53,7 +58,7 @@
                 <th field="author" fpath="item.target_biblio_record_entry.simple_record.author"></th>
                 <th field="isbn" fpath="item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
                 <th field="issn" fpath="item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
-                <th field="bib_id" fpath="item.target_biblio_record_entry.id" _visible="false"></th>
+                <th style="text-align: center;" field="bib_id" fpath="item.target_biblio_record_entry.id" _visible="false"></th>
                 <!-- You do NOT want to add the "verifications" column to this
                 table with ffilter="true".  That introduces a left join to a
                 table linked to the core table in has-many relationship.  When
diff --git a/Open-ILS/src/templates/url_verify/sessions.tt2 b/Open-ILS/src/templates/url_verify/sessions.tt2
index 6458705..a9529c6 100644
--- a/Open-ILS/src/templates/url_verify/sessions.tt2
+++ b/Open-ILS/src/templates/url_verify/sessions.tt2
@@ -1,5 +1,5 @@
 [% WRAPPER base.tt2 no_content_pane=1 %]
-[% ctx.page_title = "Link Checker" %]
+[% ctx.page_title = l("Link Checker") %]
 <script type="text/javascript">
     dojo.require("dijit.form.Button");
     dojo.require("openils.widget.FlattenerGrid");
diff --git a/Open-ILS/web/js/dojo/openils/FlattenerStore.js b/Open-ILS/web/js/dojo/openils/FlattenerStore.js
index d36100c..d46daca 100644
--- a/Open-ILS/web/js/dojo/openils/FlattenerStore.js
+++ b/Open-ILS/web/js/dojo/openils/FlattenerStore.js
@@ -173,9 +173,8 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             req.queryOptions = req.queryOptions || {};
             req.abort = function() { console.warn("[unimplemented] abort()"); };
 
-            /* If we were asked to fetch without any sort order specified (as
-             * will happen when coming from fetchToPrint(), try to use the
-             * last cached sort order, if any. */
+            /* If we were asked to fetch without any sort order specified,
+             * try to use the last cached sort order, if any. */
             req.sort = req.sort || this._last_fetch_sort;
             this._last_fetch_sort = req.sort;
 
@@ -200,51 +199,6 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             });
         },
 
-        /* *** Nonstandard but public API - Please think hard about doing
-         * things the Dojo Way whenever possible before extending the API
-         * here. *** */
-
-        /* fetchToPrint() acts like a lot like fetch(), but doesn't call
-         * onBegin or onComplete.  */
-        "fetchToPrint": function(req) {
-            var callback_scope = req.scope || dojo.global;
-            var post_params;
-
-            try {
-                post_params = this._fetch_prepare(req);
-            } catch (E) {
-                if (typeof req.onError == "function")
-                    req.onError.call(callback_scope, E);
-                else
-                    throw E;
-            }
-
-            var process_fetch_all = dojo.hitch(
-                this, function(text) {
-                    this._retried_map_key_already = false;
-
-                    if (typeof req.onComplete == "function")
-                        req.onComplete.call(callback_scope, text, req);
-                }
-            );
-
-            var process_error = dojo.hitch(
-                this, function(response, ioArgs) {
-                    this._on_http_error(response, ioArgs, req, "fetchToPrint");
-                }
-            );
-
-            this._fetch_execute(
-                post_params,
-                "text",
-                "text/html",
-                process_fetch_all,
-                process_error
-            );
-
-            return req;
-        },
-
         /* *** Begin dojo.data.api.Read methods *** */
 
         "getValue": function(
@@ -359,6 +313,16 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             var callback_scope = req.scope || dojo.global;
             var post_params;
 
+            /* Special options to support special operations (print and csv): */
+            req.flattenerOptions = dojo.mixin(
+                {}, /* target object */
+                {   /* default values */
+                    "handleAs": "json",
+                    "contentType": "application/json"
+                },
+                req.flattenerOptions /* optional input */
+            );
+
             try {
                 post_params = this._fetch_prepare(req);
             } catch (E) {
@@ -399,20 +363,18 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
                     req.onBegin.call(callback_scope, might_be_a_lie, req);
                 }
 
-                console.debug(
-                    "about to call onItem for " + obj.length +
-                    " elements in the obj array"
-                );
-                dojo.forEach(
-                    obj,
-                    function(item) {
-                        /* Cache items internally. */
-                        self._current_items[item[self.fmIdentifier]] = item;
-
-                        if (typeof req.onItem == "function")
-                            req.onItem.call(callback_scope, item, req);
-                    }
-                );
+                if (req.flattenerOptions.handleAs == "json") {
+                    dojo.forEach(
+                        obj,
+                        function(item) {
+                            /* Cache items internally. */
+                            self._current_items[item[self.fmIdentifier]] = item;
+
+                            if (typeof req.onItem == "function")
+                                req.onItem.call(callback_scope, item, req);
+                        }
+                    );
+                }
 
                 if (typeof req.onComplete == "function")
                     req.onComplete.call(callback_scope, obj, req);
@@ -428,8 +390,8 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
 
             this._fetch_execute(
                 post_params,
-                "json",
-                "application/json",
+                req.flattenerOptions.handleAs,
+                req.flattenerOptions.contentType,
                 function(obj) { process_fetch(obj, fetch_time); },
                 process_error
             );
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
index 33162be..c22b8c9 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
@@ -96,10 +96,10 @@ if (!dojo._hasResource["openils.URLVerify.ReviewAttempt"]) {
     module.format_bib_id = function(id) {
         if (!id) return "";
 
-        return "<a title='" + localeStrings.MARC_EDITOR_LINK +
+        return id + " [<a title='" + localeStrings.MARC_EDITOR_LINK +
             "' href='javascript:void(0);' " +
             "onclick='openils.URLVerify.ReviewAttempt.open_marc_editor(" +
-            id + "); return false;'>" + id + "</a>";
+            id + "); return false;'>" + localeStrings.EDIT + "</a>]";
     };
 
 }());
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js b/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
index a2283e5..0e35206 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
@@ -48,11 +48,11 @@ if (!dojo._hasResource["openils.URLVerify.Sessions"]) {
         if (!str)
             return "";
 
-        return "<a href='select_urls?session_id=" + str + "' title='" +
-            localeStrings.REREVIEW + "'>" + str +
-            "</a> <a href='create_session?clone=" + str + "' title='" +
+        return str + " [<a href='select_urls?session_id=" + str + "' title='" +
+            localeStrings.REREVIEW + "'>" + localeStrings.REREVIEW +
+            "</a>] [<a href='create_session?clone=" + str + "' title='" +
             localeStrings.CLONE_SESSION + "'>" +
-            localeStrings.CLONE_SESSION + "</a>";
+            localeStrings.CLONE_SESSION + "</a>]";
     };
 
     module.format_attempts = function(list) {
@@ -62,11 +62,11 @@ if (!dojo._hasResource["openils.URLVerify.Sessions"]) {
             list, function(id) {
                 if (isNaN(id))
                     return "";
-                return "<a title='" + localeStrings.REVIEW_ATTEMPT +
+                return id + " [<a title='" + localeStrings.REVIEW_ATTEMPT +
                     "' href='review_attempt?attempt_id=" + id + "'>" +
-                    id + "</a>";
+                    localeStrings.REREVIEW + "</a>]";
             }
-        ).join(", ");
+        ).join(" / ");
     };
 
 }());
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
index c9302db..e36cd24 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
@@ -12,7 +12,7 @@
     "NOTHING_SELECTED": "No rows are selected, so no action will be taken.",
     "REVIEW_ATTEMPT": "Review this verification attempt",
     "CLONE_SESSION": "Clone",
-    "REREVIEW": "Review / Verify",
+    "REREVIEW": "Open",
     "CLONING": "Cloning existing session ...",
     "CLONE_SESSION_NAME": "Copy of ${0}",
     "XPATH": "XPath",
@@ -20,5 +20,6 @@
     "SELECT_MORE": "Click here to review all session URLs and/or select other URLs to verify",
     "MARC_EDITOR_LINK": "Click to open MARC Editor for this record",
     "MARC_EDITOR_TITLE": "Record ID #${0}",
-    "MARC_EDITOR_SAVE_RECORD": "Save Record"
+    "MARC_EDITOR_SAVE_RECORD": "Save Record",
+    "EDIT": "Edit"
 }
diff --git a/Open-ILS/web/js/dojo/openils/XUL.js b/Open-ILS/web/js/dojo/openils/XUL.js
index 9385091..d5f7cc1 100644
--- a/Open-ILS/web/js/dojo/openils/XUL.js
+++ b/Open-ILS/web/js/dojo/openils/XUL.js
@@ -96,6 +96,10 @@ if(!dojo._hasResource["openils.XUL"]) {
             "iface": Components.interfaces.nsIFileOutputStream,
             "cls": "@mozilla.org/network/file-output-stream;1"
         },
+        "COS": {
+            "iface": Components.interfaces.nsIConverterOutputStream,
+            "cls": "@mozilla.org/intl/converter-output-stream;1"
+        },
         "create": function(key) {
             return Components.classes[this[key].cls].
                 createInstance(this[key].iface);
@@ -158,9 +162,15 @@ if(!dojo._hasResource["openils.XUL"]) {
                     result == api.FP.iface.returnReplace)) {
             if (!picker.file.exists())
                 picker.file.create(0, 0644); /* XXX hardcoded = bad */
+
             var fos = api.create("FOS");
             fos.init(picker.file, 42 /* WRONLY | CREAT | TRUNCATE */, 0644, 0);
-            return fos.write(content, content.length);
+
+            var cos = api.create("COS");
+            cos.init(fos, "UTF-8", 0, 0);   /* It's the 21st century. You don't
+                                                use ISO-8859-*. */
+            cos.writeString(content);
+            return cos.close();
         } else {
             return 0;
         }
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
index d66ee66..4583a97 100644
--- a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
+++ b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
@@ -1,6 +1,8 @@
 if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
     dojo.provide("openils.widget.FlattenerGrid");
 
+    dojo.requireLocalization("openils.widget", "FlattenerGrid");
+
     dojo.require("DojoSRF");
     dojo.require("dojox.grid.DataGrid");
     dojo.require("openils.FlattenerStore");
@@ -8,10 +10,18 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
     dojo.require("openils.widget.GridColumnPicker");
     dojo.require("openils.widget.EditDialog");  /* includes EditPane */
     dojo.require("openils.widget._GridHelperColumns");
+    dojo.require("openils.XUL");
 
     dojo.declare(
         "openils.widget.FlattenerGrid",
         [dojox.grid.DataGrid, openils.widget._GridHelperColumns], {
+            /* Later, might think about whether this should really be an
+             * "object" property like this or a "class" one (in dojo speak,
+             * since those terms don't really apply in pure JS)... */
+            "localeStrings": dojo.i18n.getLocalization(
+                "openils.widget", "FlattenerGrid"
+            ),
+
             /* These potential constructor arguments are useful to
              * FlattenerGrid in their own right */
             "columnReordering": true,
@@ -444,6 +454,20 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 }
 
                 this.inherited(arguments);
+
+                this.focus.focusHeader = function() {
+                    /* This prevents an unwanted automatic scroll of the
+                     * user's browser to the header row of the grid whenever
+                     * you touch the horizontal scrollbar.  The prevented
+                     * behavior was absolutely hateful, since if your grid was
+                     * larger than your window, touching the horizontal scroll-
+                     * bar meant scrolling up so that the same scrollbar was
+                     * now off your screen, and you could not manipulate it.
+                     *
+                     * There may be a more targeted way to fix the problem,
+                     * but this will do.  */
+                    console.log("focusHeader() suppressed");
+                };
             },
 
             "canSort": function(idx, skip_structure /* API abuse */) {
@@ -540,14 +564,14 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                             this.filterSemaphoreCallback();
                     }
                     if (!this.filterAlwaysInDiv) {
-                        dojo.create(
-                            "a", {
-                                "innerHTML": "Filter",  /* XXX i18n */
-                                "href": "javascript:void(0);",
-                                "onclick": dojo.hitch(this, function() {
-                                    this.filterUi.show();
-                                })
-                            }, this.linkHolder.domNode
+                        new dijit.form.Button(
+                            {
+                                "label": "Filter", /* XXX i18n */
+                                "onClick": dojo.hitch(
+                                    this, function() { this.filterUi.show(); }
+                                )
+                            },
+                            dojo.create("span", null, this.linkHolder.domNode)
                         );
                     }
                 }
@@ -901,6 +925,51 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 ).length == 0;
             },
 
+            "downloadCSV": function(filename_prefix, progress_dialog) {
+                filename_prefix = filename_prefix || "grid";
+                var localeStrings = this.localeStrings;
+
+                var mapkey_for_filename =
+                    this.store.mapKey ? this.store.mapKey.substr(-8, 8) : "X";
+
+                var dispositionArgs = {
+                    "defaultString": filename_prefix + "-" +
+                        mapkey_for_filename + ".csv",
+                    "defaultExtension": ".csv",
+                    "filterName": localeStrings.CSV_FILTER_NAME,
+                    "filterExtension": "*.csv",
+                    "filterAll": true
+                };
+
+                var coal = this._columnOrderingAndLabels();
+                var req = {
+                    "query": this.query,
+                    "queryOptions": {
+                        "columns": coal.columns,
+                        "labels": coal.labels,
+                        "all": true
+                    },
+                    "flattenerOptions": {
+                        "contentType": "text/csv",
+                        "handleAs": "text"
+                    },
+                    "onComplete": function(text) {
+                        if (progress_dialog)
+                            progress_dialog.attr("title", "");
+                            progress_dialog.hide();
+                        openils.XUL.contentToFileSaveDialog(
+                            text, localeStrings.CSV_SAVE_DIALOG, dispositionArgs
+                        );
+                    }
+                };
+
+                if (progress_dialog) {
+                    progress_dialog.attr("title", localeStrings.FETCHING_CSV);
+                    progress_dialog.show(true);
+                }
+                this.store.fetch(req);
+            },
+
             /* Print the same data that the Flattener is feeding to the
              * grid, sorted the same way too. Remove limit and offset (i.e.,
              * print it all) unless those are passed in to the print() method.
@@ -913,6 +982,9 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         "columns": coal.columns,
                         "labels": coal.labels
                     },
+                    "flattenerOptions": {
+                        "handleAs": "text", "contentType": "text/html"
+                    },
                     "onComplete": function(text) {
                         openils.Util.printHtmlString(text);
                     }
@@ -925,7 +997,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                     req.queryOptions.all = true;
                 }
 
-                this.store.fetchToPrint(req);
+                this.store.fetch(req);
             },
 
             "printSelected": function() {
diff --git a/Open-ILS/web/js/dojo/openils/widget/nls/FlattenerGrid.js b/Open-ILS/web/js/dojo/openils/widget/nls/FlattenerGrid.js
new file mode 100644
index 0000000..054350a
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/widget/nls/FlattenerGrid.js
@@ -0,0 +1,6 @@
+{
+    "FILTER": "Filter",
+    "CSV_SAVE_DIALOG": "Save CSV Output As",
+    "CSV_FILTER_NAME": "CSV Files",
+    "FETCHING_CSV": "Retrieving CSV data from server ..."
+}

commit 7bbb842790fa7624890c69543d94cfc07ed9f21b
Author: Galen Charlton <gmc at esilibrary.com>
Date:   Fri Oct 19 15:23:55 2012 -0400

    Link checker: make bib ID visible by default in attempt review view
    
    Since a piece of workflow (opening the bib in the MARC editor) hangs
    off of it, may as well not hide it.
    
    Signed-off-by: Galen Charlton <gmc at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/templates/url_verify/review_attempt.tt2 b/Open-ILS/src/templates/url_verify/review_attempt.tt2
index a087c7f..79cfd70 100644
--- a/Open-ILS/src/templates/url_verify/review_attempt.tt2
+++ b/Open-ILS/src/templates/url_verify/review_attempt.tt2
@@ -67,7 +67,7 @@
                 <th field="author" fpath="url.item.target_biblio_record_entry.simple_record.author"></th>
                 <th field="isbn" fpath="url.item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
                 <th field="issn" fpath="url.item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
-                <th field="bib_id" fpath="url.item.target_biblio_record_entry.id" _visible="false" formatter="module.format_bib_id"></th>
+                <th field="bib_id" fpath="url.item.target_biblio_record_entry.id" formatter="module.format_bib_id"></th>
             </tr>
         </thead>
     </table>

commit 99d7cfa1ff96e45e6925f309768e047bf67fcbd4
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Fri Oct 19 14:34:49 2012 -0400

    Link checker: Links to MARC Editor in verification review interface
    
    Now you can click on the number in the Record ID column on any row in
    the verification review interface to launch the MARC Editor for that
    record.
    
    This is only supports the most basic of possible mediation workflows to
    follow a link checker run, but more could be done with future
    development.
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/templates/url_verify/review_attempt.tt2 b/Open-ILS/src/templates/url_verify/review_attempt.tt2
index 8252bfd..a087c7f 100644
--- a/Open-ILS/src/templates/url_verify/review_attempt.tt2
+++ b/Open-ILS/src/templates/url_verify/review_attempt.tt2
@@ -67,7 +67,7 @@
                 <th field="author" fpath="url.item.target_biblio_record_entry.simple_record.author"></th>
                 <th field="isbn" fpath="url.item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
                 <th field="issn" fpath="url.item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
-                <th field="bib_id" fpath="url.item.target_biblio_record_entry.id" _visible="false"></th>
+                <th field="bib_id" fpath="url.item.target_biblio_record_entry.id" _visible="false" formatter="module.format_bib_id"></th>
             </tr>
         </thead>
     </table>
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
index d9a1cf5..33162be 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
@@ -2,6 +2,7 @@ if (!dojo._hasResource["openils.URLVerify.ReviewAttempt"]) {
     dojo.require("dojo.string");
     dojo.require("openils.CGI");
     dojo.require("openils.PermaCrud");
+    dojo.require("openils.XUL");
     dojo.require("dijit.Tooltip");
 
     dojo.requireLocalization("openils.URLVerify", "URLVerify");
@@ -62,6 +63,45 @@ if (!dojo._hasResource["openils.URLVerify.ReviewAttempt"]) {
         module.progress_dialog.hide();
     };
 
+    module.open_marc_editor = function(/* bib id */ id) {
+        openils.XUL.newTabEasy(
+            "oils://remote/xul/server/cat/marcedit.xul",
+            dojo.string.substitute(localeStrings.MARC_EDITOR_TITLE, [id]), {
+                "record": {
+                    "url": "/opac/extras/supercat/retrieve/marcxml/record/"+id,
+                    "id": id,
+                    "rtype": "bre"
+                },
+                "save": {
+                    "label": localeStrings.MARC_EDITOR_SAVE_RECORD,
+                    "func": function(marcxml) {
+                        fieldmapper.standardRequest(
+                            ["open-ils.cat", "open-ils.cat.biblio.record.xml.update"], {
+                                "async": true,
+                                "params": [openils.User.authtoken, id, marcxml],
+                                "onresponse": function(r) {
+                                    /* just to do /something/ upon error */
+                                    openils.Util.readResponse(r);
+                                }
+                            }
+                        );
+                        /* marcedit.js just expects this kind of result; meh */
+                        return {"id": id, "oncomplete": function() { }};
+                    }
+                }
+            }
+        );
+    };
+
+    module.format_bib_id = function(id) {
+        if (!id) return "";
+
+        return "<a title='" + localeStrings.MARC_EDITOR_LINK +
+            "' href='javascript:void(0);' " +
+            "onclick='openils.URLVerify.ReviewAttempt.open_marc_editor(" +
+            id + "); return false;'>" + id + "</a>";
+    };
+
 }());
 
 }
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
index d2128db..c9302db 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
@@ -17,5 +17,8 @@
     "CLONE_SESSION_NAME": "Copy of ${0}",
     "XPATH": "XPath",
     "SESSION_NAME": "Session '${0}'",
-    "SELECT_MORE": "Click here to review all session URLs and/or select other URLs to verify"
+    "SELECT_MORE": "Click here to review all session URLs and/or select other URLs to verify",
+    "MARC_EDITOR_LINK": "Click to open MARC Editor for this record",
+    "MARC_EDITOR_TITLE": "Record ID #${0}",
+    "MARC_EDITOR_SAVE_RECORD": "Save Record"
 }

commit 2ef32ae20b71628bc6b913c3df70260223426629
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Wed Sep 26 17:35:05 2012 -0400

    Link checker: Allow configurable User Agent string
    
    Credit to Bill Erickson for noticing that tests were resulting in an
    inordinate number of 403 Forbidden responses, which turned out to be
    due to discrimination by sites against a libwww/* user agent string.
    
    We now use "Evergreen <version> Link Checker" by default, and it's
    configurable in opensrf.xml (grep for user_agent).
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index 64527ef..ae7da96 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -717,6 +717,7 @@ vim:et:ts=4:sw=4:
                     <max_spare_children>5</max_spare_children>
                 </unix_config>
                 <app_settings>
+                    <user_agent>Evergreen %s Link Checker</user_agent>
                 </app_settings>
             </open-ils.url_verify>
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
index ea1e4f7..cbda944 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -6,6 +6,7 @@ use base qw/OpenILS::Application/;
 use strict; use warnings;
 use OpenSRF::Utils::Logger qw(:logger);
 use OpenSRF::MultiSession;
+use OpenSRF::Utils::SettingsClient;
 use OpenILS::Utils::Fieldmapper;
 use OpenILS::Utils::CStoreEditor q/:funcs/;
 use OpenILS::Application::AppUtils;
@@ -17,6 +18,18 @@ $Data::Dumper::Indent = 0;
 
 my $U = 'OpenILS::Application::AppUtils';
 
+my $user_agent_string;
+
+sub initialize {
+    my $conf = new OpenSRF::Utils::SettingsClient;
+
+    my @confpath = qw/apps open-ils.url_verify app_settings user_agent/;
+
+    $user_agent_string =
+        sprintf($conf->config_value(@confpath), __PACKAGE__->ils_version);
+
+    $logger->info("using '$user_agent_string' as User Agent string");
+}
 
 __PACKAGE__->register_method(
     method => 'verify_session',
@@ -562,7 +575,11 @@ sub verify_one_url {
 
     $ENV{FTP_PASSIVE} = 1; # TODO: setting?
 
-    my $ua = LWP::UserAgent->new(ssl_opts => {verify_hostname => 0}); # TODO: verify_hostname setting?
+    my $ua = LWP::UserAgent->new(
+        ssl_opts => {verify_hostname => 0}, # TODO: verify_hostname setting?
+        agent => $user_agent_string
+    );
+
     $ua->timeout($timeout);
 
     my $req = HTTP::Request->new(HEAD => $url->full_url);

commit 6801cd2653aae17da606441a1d64fa7723faed4f
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Wed Sep 26 14:34:17 2012 -0400

    Link checker: technical overview (documentation)
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/docs/TechRef/LinkChecker.txt b/docs/TechRef/LinkChecker.txt
new file mode 100644
index 0000000..51b2c64
--- /dev/null
+++ b/docs/TechRef/LinkChecker.txt
@@ -0,0 +1,113 @@
+URL Verification
+================
+
+Evergreen now has ability to verify URLs, which it is hoped, will be of
+particular benefit to locations with large electronic resource collections.  
+
+Overview
+--------
+
+In order to support verification of URLs, Evergreen now has several new
+capabilities, and extensions to some existing features.
+
+A wizard-style interface that walks a staff member through the process of collecting records and URLs to verify, verifying and reviewing the URLs.
+
+URL validation sessions are built as a whole to support immediate and
+future review of any URLs.  Each session carries a name, an owner, a set
+of record search criteria, a set of tag and subfield combinations describing
+the location of URLs to validate, a record container for tracking individual
+records to verify, and a set of state and data tables for managing the
+processing of individual URLs.
+
+A set of middle-layer methods provide the business logic required to collect
+records, extract, parse and test the validity of the URLs.
+
+Workflow
+--------
+
+URL verification and update are be performed as a series of coordinated phases.
+
+    * Phase 1 - Select or Create a session
+        ** Collect the owner and name of the session, providing appropriate defaults
+        ** Collect a set of saved and immediately-entered searches for the purpose of targeting records, and store a single derived search
+        ** Collect a set of tag and subfield combinations describing the locations of interest that contain URLs within records found by the above search. Store these as xpath.
+        ** Offer a "Process immediately" option to skip Phase 4 (not skip to Phase 4, but skip Phase 4 itself) -- See below for details.
+    * Phase 2 - Search for and collect records of interest
+        ** Create a new bib record container of type "url-validate" and link this to the session record created in Phase 1
+        ** Run the search, and store the full set of record IDs in the "url-validate" container
+    * Phase 3 - Extract URLs from collected records
+        ** Inspect each record that we just placed into the container, and use the tag/subfield XPath expressions to extract any URLs
+        ** Extract any relevant data and store
+            *** Container entry pointing to the record from which it came
+            *** Tag and subfield from which it came
+            *** The full content
+            *** Scheme
+            *** Host
+            *** Domain
+            *** TLD
+            *** Path
+            *** Page (last component of Path)
+            *** Query
+            *** Fragment
+    * Phase 4 - Search/Filter/Sort URLs
+        ** Skip this step if "Process immediately" was selected during session setup in Phase 1
+        ** Else, display an interface for selecting which URLs and records to process based on any component extracted and stored during Phase 3.
+    * Phase 5 - Validate Selected/All URLs
+        ** Accept a list of extracted URL IDs from the previous step, or if no filtering done, all URLs
+        ** For each unique URL in the set, make a HTTP HEAD request to test validity 
+            *** YAOUS for timeout value
+            *** YAOUS for sleep period between each URL test
+            *** For duplicated URLs, test only once and share the result across all instances
+            *** Avoid testing URLs having the same domain sequentially
+        ** Store HTTP response code
+        ** IFF 3XX (redirect) code is returned
+            *** Parse the new target URL, linking to the original
+            *** Repeat as necessary, up to a sanity limit on redirect depth (YAOUS)
+            *** Use a lookup table of hashes of URLs already redirected to (for the given original URL), to avoid loops
+    *  Phase 6 - Validation Status Report
+        ** Display a summary breakdown of HTTP statuses and overall completion
+        ** Offer an interface to Search/Filter/Sort URLs for inspection based on any component extracted and stored during Phase 3 as well as HTTP status of the originally extracted URL.  Included in this display should be the endpoint of any HTTP redirection the server requested.
+
+Development Breakdown
+---------------------
+
+    * Database -- The database has been augmented with new tables to store and process bibliographic records as they are collected for URL verification
+        ** Session state
+        ** Session configuration
+        ** Container type
+        ** URL data and state storage
+    * Middle Layer -- Several new API calls, culminating in a new OpenSRF service created to implement the required business logic.
+        ** Session and Configuration management
+        ** Session selection
+        ** Record discovery
+        ** Progress calculation
+    * User Interface -- Several new interface components have been created to drive the use of the new OpenSRF APIs
+        ** Session management
+        ** Configuration management
+        ** Extracted URL display (Search/Filter/Sort)
+        ** Summary status display
+
+User Interface
+--------------
+The user interface embodies the workflow section above.  Displays of URLs for verification and then post-verification review make use of openils.widget.FlattenerGrid.
+
+First Change to FlattenerFilterDialog
+-------------------------------------
+
+FlattenerFilterDialog has gained the ability to save a set of filter conditions via a Save button (optionally displayed) which calls a callback at onClick. 
+
+FlattenerFilterDialog now has a clean way to load a saved set of filter conditions (this part should be largely there already, see Trigger Event Log for similar).
+
+The mechanism to which this instance of FlattenerFilterDialog saves sets of conditions (and from which it will load them) uses a dialog that allows a user to choose sets of conditions to load and uses a DB table to store them in.
+
+Second Change to FlattenerFilterDialog
+--------------------------------------
+
+We also now support IN and NOT IN operators.  The operand widget is be the same as if for a typical unary operator (any of them but 'between') plus an adder (label probably '[+]') and a multiselect, the valueset of which is augmented with every click of the adder.
+
+Here's why this is needed.  Imagine needing to filter URLs in this way: say you want urls only from the "http" scheme and matching neither the domain example.com nor example.net.  If you did this with FlattenerFilterDialog today, the result of setting up three conditions as described above would be (scheme = 'http' and (domain <> 'example.com' or domain <> 'example.net')) which is effectively the same as having no filter on domain at all.
+
+It worked that way before because until now it was only designed for equalities, not inequalities (compare to the situation where your three conditions are scheme is http, domain IS either example.com, example.net).
+
+The multiselect scheme described above allows clauses in the WHERE constraint that look like (domain not in ('example.com','example.net')).
+

commit b7a5f1040ffb678f2db0fb8c05321c9291414007
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Fri Aug 31 17:31:43 2012 -0400

    Link checker: user interface and supporting fixes (part 2)
    
    Started verification review UI, also SCHEMA CHANGES
        It just doesn't work for me to not have url_verify.url directly related
        to url_verify.session.  When dealing with the "root" URL in a redirect
        chain, you can get the related session through url_selector, but not when
        you have any later URL in the chain.  The only way for IDL perms to work
        would be to have a link to a view using a CTE to find the "root" URL.
        That's too complex, so instead of that I've just added a session fkey on
        url_verify.url.
    Corrections to the preceding commit
    Vertical scrolling UI glitches fixed
    Fix broken display of verification attempt in progress
    Implement the "process immediately" switch, hitherto unhooked up
    Verify-all now means all-matching-my-search-terms, not necessarily all-in-uvs
    let's do filter sets a little more generalized-like
    Permission fixing
    Filter set loading works.
    Filter loading: gracefully skip unknown fields, remove inital empty row
    Saving filter sets
    Fix filter dialog for pkey fields, scrolliness issue, saved filters issue
    Pretty start page for staff client menu to land on
    Staff client menu entry
    User settings for saved columns
    Session cloning, working and rather tested
    show name of session on url select page ...
        ... and link back to that on review attempt page
    IN / NOT IN for filter somewhat working, but doesn't save/load yet
    Saving/loading filter rows for IN, NOT IN operators
    Printing
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 7726438..1510df1 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -9345,7 +9345,7 @@ SELECT  usr,
         reporter:label="URL Verification Session"
     >
         <fields oils_persist:primary="id" oils_persist:sequence="url_verify.session_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="Session ID" name="id" reporter:datatype="id"/>
             <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
 			<field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit" oils_obj:required="true"/>
             <field reporter:label="Creator" name="creator" reporter:datatype="link" oils_obj:required="true"/>
@@ -9411,7 +9411,7 @@ SELECT  usr,
         reporter:label="URL Verification URL Selector"
     >
         <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_selector_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="URL Selector ID" name="id" reporter:datatype="id"/>
             <field reporter:label="XPath" name="xpath" reporter:datatype="text" oils_obj:required="true"/>
 			<field reporter:label="Session" name="session" reporter:datatype="link" oils_obj:required="true"/>
             <field reporter:label="URLs" name="urls" reporter:datatype="link" oils_persist:virtual="true"/>
@@ -9449,9 +9449,10 @@ SELECT  usr,
         reporter:label="URL Verification URL"
     >
         <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="URL ID" name="id" reporter:datatype="id"/>
 			<field reporter:label="Redirected From" name="redirect_from" reporter:datatype="link"/>
 			<field reporter:label="Container Item" name="item" reporter:datatype="link" oils_obj:required="true"/>
+			<field reporter:label="Session" name="session" reporter:datatype="link" oils_obj:required="true"/>
 			<field reporter:label="URL Selector" name="url_selector" reporter:datatype="link"/>
             <field reporter:label="Tag" name="tag" reporter:datatype="text"/>
             <field reporter:label="Subfield" name="subfield" reporter:datatype="text"/>
@@ -9465,27 +9466,30 @@ SELECT  usr,
             <field reporter:label="Page" name="page" reporter:datatype="text"/>
             <field reporter:label="Query" name="query" reporter:datatype="text"/>
             <field reporter:label="Fragment" name="fragment" reporter:datatype="text"/>
+            <field reporter:label="Verifications" name="verifications" reporter:datatype="link" oils_persist:virtual="true" />
         </fields>
 
         <links>
             <link field="redirect_from" reltype="has_a" key="id" map="" class="uvu"/>
-            <link field="item" reltype="has_a" key="id" map="" class="uvsbrem" /><!-- surprise! -->
+            <link field="item" reltype="has_a" key="id" map="" class="uvsbrem" />
+            <link field="session" reltype="has_a" key="id" map="" class="uvs"/>
             <link field="url_selector" reltype="has_a" key="id" map="" class="uvus"/>
+            <link field="verifications" reltype="has_many" key="url" map="" class="uvuv"/>
         </links>
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
                 <create permission="URL_VERIFY">
-                    <context link="url_selector" jump="session" field="owning_lib"/>
+                    <context link="session" field="owning_lib"/>
                 </create>
                 <retrieve permission="URL_VERIFY">
-                    <context link="url_selector" jump="session" field="owning_lib"/>
+                    <context link="session" field="owning_lib"/>
                 </retrieve>
                 <update permission="URL_VERIFY">
-                    <context link="url_selector" jump="session" field="owning_lib"/>
+                    <context link="session" field="owning_lib"/>
                 </update>
                 <delete permission="URL_VERIFY">
-                    <context link="url_selector" jump="session" field="owning_lib"/>
+                    <context link="session" field="owning_lib"/>
                 </delete>
             </actions>
         </permacrud>
@@ -9500,7 +9504,7 @@ SELECT  usr,
         reporter:label="URL Verification Attempt"
     >
         <fields oils_persist:primary="id" oils_persist:sequence="url_verify.verification_attempt_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="Attempt ID" name="id" reporter:datatype="id"/>
 			<field reporter:label="User" name="usr" reporter:datatype="link"/>
 			<field reporter:label="Session" name="session" reporter:datatype="link"/>
             <field reporter:label="Start Time" name="start_time" reporter:datatype="timestamp"/>
@@ -9539,7 +9543,7 @@ SELECT  usr,
         reporter:label="URL Verification"
     >
         <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_verification_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="Verification ID" name="id" reporter:datatype="id"/>
 			<field reporter:label="URL" name="url" reporter:datatype="link"/>
 			<field reporter:label="Attempt" name="attempt" reporter:datatype="link"/>
             <field reporter:label="Request Time" name="req_time" reporter:datatype="timestamp"/>
@@ -9574,37 +9578,56 @@ SELECT  usr,
 
     </class>
 
-    <class
-        id="uvfs"
-        controller="open-ils.cstore open-ils.pcrud"
-        oils_obj:fieldmapper="url_verify::filter_set"
-        oils_persist:tablename="url_verify.filter_set"
-        reporter:label="URL Verification Filter Set"
-    >
-        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_verification_id_seq">
-            <field reporter:label="ID" name="id" reporter:datatype="id"/>
-            <field reporter:label="Name" name="name" reporter:datatype="text"/>
+	<class
+		id="cfdi"
+		controller="open-ils.cstore open-ils.pcrud"
+		oils_obj:fieldmapper="config::filter_dialog_interface"
+		oils_persist:tablename="config.filter_dialog_interface"
+		reporter:label="FilterDialog Interface">
+		<fields oils_persist:primary="key" oils_persist:sequence="config.filter_dialog_interface_pkey">
+			<field reporter:label="Interface Key" name="key" reporter:datatype="text" />
+			<field reporter:label="Description" name="description" reporter:datatype="text" />
+		</fields>
+		<links>
+			<link field="filter_sets" reltype="has_many" key="interface" map="" class="cfdfs"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve />
+			</actions>
+		</permacrud>
+	</class>
+
+	<class
+		id="cfdfs"
+		controller="open-ils.cstore open-ils.pcrud"
+		oils_obj:fieldmapper="config::filter_dialog_filter_set"
+		oils_persist:tablename="config.filter_dialog_filter_set"
+		reporter:label="FilterDialog Filter Set">
+		<fields oils_persist:primary="id" oils_persist:sequence="config.filter_dialog_filter_set_id_seq">
+			<field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="Name" name="name" reporter:datatype="text"/>
 			<field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit"/>
 			<field reporter:label="Creator" name="creator" reporter:datatype="link"/>
-            <field reporter:label="Create Time" name="create_time" reporter:datatype="timestamp"/>
-			<field reporter:label="Filter" name="filter" reporter:datatype="text"/>
-        </fields>
-
-        <links>
-            <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
-            <link field="creator" reltype="has_a" key="id" map="" class="au"/>
-        </links>
-
-        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
-            <actions>
-                <create permission="URL_VERIFY" context_field="owning_lib"/>
-                <retrieve permission="URL_VERIFY" context_field="owning_lib"/>
-                <update permission="URL_VERIFY" context_field="owning_lib"/>
-                <delete permission="URL_VERIFY" context_field="owning_lib"/>
-            </actions>
-        </permacrud>
+			<field reporter:label="Interface" name="interface" reporter:datatype="link"/>
+			<field reporter:label="Create Time" name="create_time" reporter:datatype="timestamp"/>
+			<field reporter:label="Filters" name="filters" reporter:datatype="text"/>
+		</fields>
+		<links>
+			<link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
+			<link field="creator" reltype="has_a" key="id" map="" class="au"/>
+			<link field="interface" reltype="has_a" key="key" map="" class="cfdi"/>
+		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<create permission="SAVED_FILTER_DIALOG_FILTERS" context_field="owning_lib"/>
+				<retrieve permission="SAVED_FILTER_DIALOG_FILTERS" context_field="owning_lib"/>
+				<update permission="SAVED_FILTER_DIALOG_FILTERS" context_field="owning_lib"/>
+				<delete permission="SAVED_FILTER_DIALOG_FILTERS" context_field="owning_lib"/>
+			</actions>
+		</permacrud>
 
-    </class>
+	</class>
 
 	<class id="cmrtm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::marc21_rec_type_map" oils_persist:tablename="config.marc21_rec_type_map" reporter:label="MARC21 Record Type Map" oils_persist:field_safe="true">
 		<fields oils_persist:primary="code">
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
index 0e49a00..ea1e4f7 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -83,12 +83,8 @@ sub verify_session {
             select => {uvu => ['id']},
             from => {
                 uvu => { # url
-                    cbrebi => { # bucket item
-                        join => { cbreb => { # bucket
-                            join => { uvs => { # session
-                                filter => {id => $session_id}
-                            }}
-                        }}
+                    uvs => { # session
+                        filter => {id => $session_id}
                     }
                 }
             }
@@ -589,6 +585,7 @@ sub verify_one_url {
 
         if (my $loc = $res->headers->{location}) {
             $redir_url = Fieldmapper::url_verify::url->new;
+            $redir_url->session($attempt->session);
             $redir_url->redirect_from($url->id);
             $redir_url->full_url($loc);
 
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 25fa292..8463598 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -1002,4 +1002,21 @@ CREATE UNIQUE INDEX unique_wwh ON config.usr_activity_type
     (COALESCE(ewho,''), COALESCE (ewhat,''), COALESCE(ehow,''));
 
 
+CREATE TABLE config.filter_dialog_interface (
+    key         TEXT                        PRIMARY KEY,
+    description TEXT
+);  
+
+CREATE TABLE config.filter_dialog_filter_set (
+    id          SERIAL                      PRIMARY KEY,
+    name        TEXT                        NOT NULL,
+    owning_lib  INT                         NOT NULL, -- REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+    creator     INT                         NOT NULL, -- REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    interface   TEXT                        NOT NULL REFERENCES config.filter_dialog_interface (key) DEFERRABLE INITIALLY DEFERRED,
+    filters     TEXT                        NOT NULL, -- CHECK (evergreen.is_json(filters))
+    CONSTRAINT cfdfs_name_once_per_lib UNIQUE (name, owning_lib)
+);
+
+
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/075.schema.url_verify.sql b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
index db42861..7e39eba 100644
--- a/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
@@ -42,6 +42,7 @@ CREATE TABLE url_verify.url (
     id              SERIAL  PRIMARY KEY,
     redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
     item            INT     REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
+    session         INT     REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
     url_selector    INT     REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
     tag             TEXT,    
     subfield        TEXT,    
@@ -88,15 +89,5 @@ CREATE TABLE url_verify.url_verification (
     redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
 );
 
-CREATE TABLE url_verify.filter_set (
-    id          SERIAL                      PRIMARY KEY,
-    name        TEXT                        NOT NULL,
-    owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
-    creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
-    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
-    filter      TEXT                        NOT NULL,
-    CONSTRAINT uvfs_name_once_per_lib UNIQUE (name, owning_lib)
-);
- 
 COMMIT;
 
diff --git a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
index a49e5fc..5443d51 100644
--- a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
@@ -91,8 +91,8 @@ BEGIN
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
-            INSERT INTO url_verify.url (item, url_selector, tag, subfield, ord, full_url)
-              VALUES ( item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
+            INSERT INTO url_verify.url (session, item, url_selector, tag, subfield, ord, full_url)
+              VALUES ( session_id, item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
 
             current_url_pos := current_url_pos + 1;
             current_ord := current_ord + 1;
diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql
index 53be2e1..e24ca9f 100644
--- a/Open-ILS/src/sql/Pg/800.fkeys.sql
+++ b/Open-ILS/src/sql/Pg/800.fkeys.sql
@@ -126,4 +126,17 @@ ALTER TABLE config.z3950_source ADD CONSTRAINT use_perm_fkey FOREIGN KEY (use_pe
 
 ALTER TABLE config.org_unit_setting_type_log ADD CONSTRAINT config_org_unit_setting_type_log_fkey FOREIGN KEY (org) REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
 
+ALTER TABLE config.filter_dialog_filter_set
+    ADD CONSTRAINT config_filter_dialog_filter_set_owning_lib_fkey
+    FOREIGN KEY (owning_lib) REFERENCES actor.org_unit (id)
+    ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE config.filter_dialog_filter_set
+    ADD CONSTRAINT config_filter_dialog_filter_set_creator_fkey
+    FOREIGN KEY (creator) REFERENCES actor.usr (id)
+    ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
+
+ALTER TABLE config.filter_dialog_filter_set
+    ADD CONSTRAINT config_filter_dialog_filter_set_filters_check
+    CHECK (evergreen.is_json(filters))
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index d6f0547..120120e 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -1577,7 +1577,11 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 543, 'URL_VERIFY', oils_i18n_gettext( 543, 
     'Allows a user to process and verify ULSs', 'ppl', 'description')),
  ( 544, 'URL_VERIFY_UPDATE_SETTINGS', oils_i18n_gettext( 544, 
-    'Allows a user to configure URL verification org unit settings', 'ppl', 'description'))
+    'Allows a user to configure URL verification org unit settings', 'ppl', 'description')),
+ ( 545, 'SAVED_FILTER_DIALOG_FILTERS', oils_i18n_gettext( 545,
+    'Allows users to save and load sets of filters for filter dialogs, available in certain staff interfaces', 'ppl', 'description'))
+
+
 ;
 
 
@@ -11937,9 +11941,7 @@ INSERT INTO actor.search_filter_group_entry (grp, query, pos)
         (SELECT id FROM actor.search_filter_group WHERE code = 'kpac_main'),
         (SELECT id FROM actor.search_query WHERE label = 'Children''s Materials'),
         0
-    );
-INSERT INTO actor.search_filter_group_entry (grp, query, pos) 
-    VALUES (
+    ); INSERT INTO actor.search_filter_group_entry (grp, query, pos) VALUES (
         (SELECT id FROM actor.search_filter_group WHERE code = 'kpac_main'),
         (SELECT id FROM actor.search_query WHERE label = 'Young Adult Materials'),
         1
@@ -12080,6 +12082,53 @@ INSERT INTO config.org_unit_setting_type
         544
     );
 
+INSERT INTO config.filter_dialog_interface (key, description) VALUES (
+    'url_verify',
+    oils_i18n_gettext(
+        'url_verify',
+        'All Link Checker filter dialogs',
+        'cfdi',
+        'description'
+    )
+);
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'url_verify.select_urls',
+    'url_verify',
+    FALSE,
+    oils_i18n_gettext(
+        'url_verify.select_urls',
+        'Link Checker''s URL Selection interface''s saved columns',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'url_verify.select_urls',
+        'Link Checker''s URL Selection interface''s saved columns',
+        'cust',
+        'description'
+    ),
+    'string'
+);
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'url_verify.review_attempt',
+    'url_verify',
+    FALSE,
+    oils_i18n_gettext(
+        'url_verify.review_attempt',
+        'Link Checker''s Review Attempt interface''s saved columns',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'url_verify.review_attempt',
+        'Link Checker''s Review Attempt interface''s saved columns',
+        'cust',
+        'description'
+    ),
+    'string'
+);
 
 INSERT INTO config.org_unit_setting_type
     (name, grp, label, description, datatype, update_perm)
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
index 82da8ee..ddddd41 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
@@ -29,6 +29,7 @@ CREATE TABLE url_verify.url (
     redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
     item            INT     REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
     url_selector    INT     REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
+    session         INT     REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
     tag             TEXT,
     subfield        TEXT,
     ord             INT,
@@ -74,14 +75,20 @@ CREATE TABLE url_verify.url_verification (
     redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
 );
 
-CREATE TABLE url_verify.filter_set (
+CREATE TABLE config.filter_dialog_interface (
+    key         TEXT                        PRIMARY KEY,
+    description TEXT
+);
+
+CREATE TABLE config.filter_dialog_filter_set (
     id          SERIAL                      PRIMARY KEY,
     name        TEXT                        NOT NULL,
     owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
     creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
     create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
-    filter      TEXT                        NOT NULL,
-    CONSTRAINT uvfs_name_once_per_lib UNIQUE (name, owning_lib)
+    interface   TEXT                        NOT NULL REFERENCES config.filter_dialog_interface (key) DEFERRABLE INITIALLY DEFERRED,
+    filters     TEXT                        NOT NULL CHECK (evergreen.is_json(filters)),
+    CONSTRAINT cfdfs_name_once_per_lib UNIQUE (name, owning_lib)
 );
  
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
index f8a5bad..bed0ae3 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -75,8 +75,8 @@ BEGIN
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
-            INSERT INTO url_verify.url (item, url_selector, tag, subfield, ord, full_url)
-              VALUES ( item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
+            INSERT INTO url_verify.url (session, item, url_selector, tag, subfield, ord, full_url)
+              VALUES ( session_id, item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
 
             current_url_pos := current_url_pos + 1;
             current_ord := current_ord + 1;
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
index f0f9846..4e14754 100644
--- a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
@@ -30,6 +30,19 @@ INSERT INTO permission.perm_list (id, code, description)
     );
 
 
+INSERT INTO permission.perm_list (id, code, description) 
+    VALUES ( 
+        545, 
+        'SAVED_FILTER_DIALOG_FILTERS',
+        oils_i18n_gettext(
+            545, 
+            'Allows users to save and load sets of filters for filter dialogs, available in certain staff interfaces',
+            'ppl', 
+            'description'
+        )
+    );
+
+
 INSERT INTO config.settings_group (name, label)
     VALUES (
         'url_verify',
@@ -127,5 +140,54 @@ INSERT INTO config.org_unit_setting_type
     );
 
 
+INSERT INTO config.filter_dialog_interface (key, description) VALUES (
+    'url_verify',
+    oils_i18n_gettext(
+        'url_verify',
+        'All Link Checker filter dialogs',
+        'cfdi',
+        'description'
+    )
+);
+
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'url_verify.select_urls',
+    'url_verify',
+    FALSE,
+    oils_i18n_gettext(
+        'url_verify.select_urls',
+        'Link Checker''s URL Selection interface''s saved columns',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'url_verify.select_urls',
+        'Link Checker''s URL Selection interface''s saved columns',
+        'cust',
+        'description'
+    ),
+    'string'
+);
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'url_verify.review_attempt',
+    'url_verify',
+    FALSE,
+    oils_i18n_gettext(
+        'url_verify.review_attempt',
+        'Link Checker''s Review Attempt interface''s saved columns',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'url_verify.review_attempt',
+        'Link Checker''s Review Attempt interface''s saved columns',
+        'cust',
+        'description'
+    ),
+    'string'
+);
+
 COMMIT;
 
diff --git a/Open-ILS/src/templates/base.tt2 b/Open-ILS/src/templates/base.tt2
index 0bd3e5e..efdee63 100644
--- a/Open-ILS/src/templates/base.tt2
+++ b/Open-ILS/src/templates/base.tt2
@@ -26,9 +26,13 @@
                 </div>
             </div>
             <div id="oils-base-main-block" dojoType="dijit.layout.LayoutContainer" layoutAlign="client">
+                [% IF no_content_pane %]
+                [% content %]
+                [% ELSE %]
                 <div id="oils-base-content-block" dojoType="dijit.layout.ContentPane" layoutAlign="client">
                     [% content %]
                 </div>
+                [% END %]
             </div>
         </div>
     </body>
diff --git a/Open-ILS/src/templates/url_verify/create_session.tt2 b/Open-ILS/src/templates/url_verify/create_session.tt2
index d29ccd9..72983e9 100644
--- a/Open-ILS/src/templates/url_verify/create_session.tt2
+++ b/Open-ILS/src/templates/url_verify/create_session.tt2
@@ -20,6 +20,7 @@
 <style type="text/css">
     #uv-search { width: 20em; }
     .note { font-style: italic; background-color: #eee; }
+    #saved-searches { width: 15em; }
     #uv-tags-and-subfields { background-color: #ddd; }
     table.create-session-form th { text-align: right; padding-right: 1em; }
     table.create-session-form {
@@ -55,7 +56,7 @@
                     <div id="org-selector"></div>
                 </td>
                 <td class="note">
-                    [% l("This will only be used if your search doesn't contain a hand-entered filter such as site(BR1)") %]
+                    [% l("This will only be used if your search doesn't contain an explicit filter such as site(BR1)") %]
                 </td>
             </tr>
 
@@ -83,7 +84,11 @@
                     constant load time regardless of dataset size. -->
                     <select id="saved-searches" multiple="true" size="6"></select>
                 </td>
-                <td class="note">[% l("Optionally select one or more to combine with 'Search' field above.") %]
+                <td class="note">
+                    <p>[% l("Optionally select one or more to combine with 'Search' field above.") %]</p>
+                    <p class="hidden" id="clone-saved-search-warning">
+                        [% l("NOTE: When cloning sessions, any saved searches used in the original session will already be mentioned in the 'Search' field above. You should not need to select them again here.") %]
+                    </p>
                 </td>
             </tr>
 
@@ -101,16 +106,16 @@
 
             <tr>
                 <th>
-                    [% l('Tags and subfields possibly containing URLs:') %]
+                    [% l('Tags and subfields to search for URLs:') %]
                 </th>
                 <td>
                     <div id="uv-tags-and-subfields">
                     </div>
                     <div class="tag-and-subfield-add-another">
                         [% l("Tag") %]
-                        <input type="text" size="4" maxlength="3" />
+                        <input type="text" size="3" maxlength="3" />
                         [% l("Subfield(s)") %]
-                        <input type="text" size="15" />
+                        <input type="text" size="8" />
                         <a href="javascript:module.tag_and_subfields.add();">[% l('Add') %]</a>
                     </div>
                 </td>
diff --git a/Open-ILS/src/templates/url_verify/review_attempt.tt2 b/Open-ILS/src/templates/url_verify/review_attempt.tt2
new file mode 100644
index 0000000..8252bfd
--- /dev/null
+++ b/Open-ILS/src/templates/url_verify/review_attempt.tt2
@@ -0,0 +1,78 @@
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = "Link Checker - Review Verification Attempt" %]
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("openils.widget.FlattenerGrid");
+    dojo.require("openils.widget.ProgressDialog");
+    dojo.require("openils.Util");
+    dojo.require("openils.CGI");
+    dojo.require("openils.URLVerify.ReviewAttempt");
+
+    /* Minimize namespace pollution, but save us some typing later. */
+    var module = openils.URLVerify.ReviewAttempt;
+
+    openils.Util.addOnLoad(
+        function() {
+            module.setup(grid, progress_dialog);
+        }
+    );
+</script>
+<style type="text/css">
+    .url-verify-attempt-info { font-style: italic; }
+    #session-link-here { font-weight: normal; font-size: 90%; }
+</style>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+         layoutAlign="top" class="oils-header-panel">
+        <div>[% ctx.page_title %] - <span id="session-link-here"></span></div>
+        <div>
+            <button dojoType="dijit.form.Button" onClick="grid.print();">
+                [% l("Print verification results") %]
+            </button>
+        </div>
+    </div>
+    <div class="oils-acq-basic-roomy">
+        <!-- any blurb to precede grid can go here -->
+    </div>
+    <table
+        jsid="grid"
+        dojoType="openils.widget.FlattenerGrid"
+        columnPersistKey='"url_verify.review_attempt"'
+        autoHeight="10"
+        editOnEnter="false"
+        autoCoreFields="true"
+        autoCoreFieldsFilter="true"
+        autoCoreFieldsUnsorted="true"
+        savedFiltersInterface="'url_verify'"
+        fetchLock="true"
+        mapExtras="{attempt_id:{path:'attempt.id',filter:true,_label:'[% l("Attempt ID") %]'}}"
+        showLoadFilter="true"
+        fmClass="'uvuv'">
+        <thead>
+            <tr>
+                <th field="redirect_from" fpath="url.redirect_from"></th><!-- From here through fragment, these are fields from uvu.  We could have just used autoFieldFields on the FlattenerGrid, but by naming them explicitly we use the same field names that we use in the URL selection interface, allowing the two interfaces to use common saved filter sets. -->
+                <th field="tag" fpath="url.tag" ffilter="true"></th>
+                <th field="subfield" fpath="url.subfield" ffilter="true"></th>
+                <th field="ord" fpath="url.ord" ffilter="true"></th>
+                <th field="full_url" fpath="url.full_url" ffilter="true"></th>
+                <th field="scheme" fpath="url.scheme" _visible="false" ffilter="true"></th>
+                <th field="host" fpath="url.host" _visible="false" ffilter="true"></th>
+                <th field="domain" fpath="url.domain" _visible="false" ffilter="true"></th>
+                <th field="tld" fpath="url.tld" _visible="false" ffilter="true"></th>
+                <th field="path" fpath="url.path" _visible="false" ffilter="true"></th>
+                <th field="page" fpath="url.page" _visible="false" ffilter="true"></th>
+                <th field="query" fpath="url.query" _visible="false" ffilter="true"></th>
+                <th field="fragment" fpath="url.fragment" _visible="false" ffilter="true"></th>
+                <th field="title" fpath="url.item.target_biblio_record_entry.simple_record.title"></th>
+                <th field="author" fpath="url.item.target_biblio_record_entry.simple_record.author"></th>
+                <th field="isbn" fpath="url.item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
+                <th field="issn" fpath="url.item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
+                <th field="bib_id" fpath="url.item.target_biblio_record_entry.id" _visible="false"></th>
+            </tr>
+        </thead>
+    </table>
+</div>
+<div class="hidden">
+    <div dojoType="openils.widget.ProgressDialog" jsId="progress_dialog"></div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/url_verify/select_urls.tt2 b/Open-ILS/src/templates/url_verify/select_urls.tt2
index b3985d9..8ad683a 100644
--- a/Open-ILS/src/templates/url_verify/select_urls.tt2
+++ b/Open-ILS/src/templates/url_verify/select_urls.tt2
@@ -1,4 +1,4 @@
-[% WRAPPER base.tt2 %]
+[% WRAPPER base.tt2 no_content_pane=1 %]
 [% ctx.page_title = "Link Checker - Select URLs" %]
 <script type="text/javascript">
     dojo.require("dijit.form.Button");
@@ -19,34 +19,32 @@
 </script>
 <style type="text/css">
     .url-verify-attempt-info { font-style: italic; }
+    #session-name-here { font-weight: normal; font-size: 90%; }
 </style>
-<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
-    <div dojoType="dijit.layout.ContentPane"
-         layoutAlign="top" class="oils-header-panel">
-        <div>[% ctx.page_title %]</div>
-        <div>
-            <button dojoType="dijit.form.Button"
-                onClick="module.verify_selected();">[%
-                l("Verify Selected URLs")
-            %]</button>
-        </div>
+<div class="oils-header-panel" dojoType="dijit.layout.ContentPane" layoutAlign="top">
+    <div>[% ctx.page_title %] - <span id="session-name-here"></span></div>
+    <div class="url-verify-button">
+        <button dojoType="dijit.form.Button"
+            onClick="grid.print();">[%
+            l("Print URLs")
+        %]</button>
+        <button dojoType="dijit.form.Button"
+            onClick="module.verify_selected();">[%
+            l("Verify Selected URLs")
+        %]</button>
     </div>
-    <div class="oils-acq-basic-roomy url-verify-attempt-info">
-        <div id="url-verify-attempt-id"></div>
-        <div id="url-verify-attempt-start"></div>
-        <div id="url-verify-attempt-finish"></div>
-    </div>
-    <table
-        jsid="grid"
+</div>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="bottom" style="height: 85%;">
+    <table jsid="grid"
         dojoType="openils.widget.FlattenerGrid"
-        columnPersistKey='"url_verify.select_url"'
-        autoHeight="10"
+        columnPersistKey="'url_verify.select_urls'"
         editOnEnter="false"
-        autoFieldFields="null"
         autoCoreFields="true"
+        autoCoreFieldsFilter="true"
         autoCoreFieldsUnsorted="true"
+        savedFiltersInterface="'url_verify'"
         fetchLock="true"
-        mapExtras="{session_id: {path: 'item.session.id', filter: true}}"
+        mapExtras="{session_id: {path: 'session.id', filter: true}}"
         showLoadFilter="true"
         fmClass="'uvu'">
         <thead>
@@ -56,6 +54,13 @@
                 <th field="isbn" fpath="item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
                 <th field="issn" fpath="item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
                 <th field="bib_id" fpath="item.target_biblio_record_entry.id" _visible="false"></th>
+                <!-- You do NOT want to add the "verifications" column to this
+                table with ffilter="true".  That introduces a left join to a
+                table linked to the core table in has-many relationship.  When
+                PCRUD tries a query like that, it can return fewer objects than
+                its LIMIT clause called for, even when there are more objects
+                left.  This may need to be a bug report against flattener or
+                cstore/pcrud(?) but I'm not sure I understand the expectations. -->
             </tr>
         </thead>
     </table>
diff --git a/Open-ILS/src/templates/url_verify/sessions.tt2 b/Open-ILS/src/templates/url_verify/sessions.tt2
new file mode 100644
index 0000000..6458705
--- /dev/null
+++ b/Open-ILS/src/templates/url_verify/sessions.tt2
@@ -0,0 +1,51 @@
+[% WRAPPER base.tt2 no_content_pane=1 %]
+[% ctx.page_title = "Link Checker" %]
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("openils.widget.FlattenerGrid");
+    dojo.require("openils.widget.OrgUnitFilteringSelect")
+    dojo.require("openils.URLVerify.Sessions");
+
+    /* Minimize namespace pollution, but save us some typing later. */
+    var module = openils.URLVerify.Sessions;
+
+    openils.Util.addOnLoad(
+        function() {
+            module.setup(grid, org_selector);
+        }
+    );
+</script>
+<div class="oils-header-panel" dojoType="dijit.layout.ContentPane" layoutAlign="top">
+    <div>[% ctx.page_title %]</div>
+    <div>
+        <strong><a href="create_session">[% l("New Link Checker Session") %]</a></strong>
+    </div>
+</div>
+<div class="oils-acq-basic-roomy" dojoType="dijit.layout.ContentPane" layoutAlign="top">
+    <label for="org_selector">[% l("View existing sessions started at:") %]</label>
+    <select id="org_selector" jsId="org_selector"
+        dojoType="openils.widget.OrgUnitFilteringSelect"
+        searchAttr="name" labelAttr="name">
+    </select>
+</div>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="bottom" style="height: 85%;">
+    <table jsid="grid"
+        dojoType="openils.widget.FlattenerGrid"
+        editOnEnter="false"
+        hideSelector="true"
+        fetchLock="true"
+        fmClass="'uvs'">
+        <thead>
+            <tr>
+                <th field="id" fpath="id" formatter="module.format_id"></th>
+                <th field="name" fpath="name" width="auto"></th>
+                <th field="attempts" fpath="attempts.id" formatter="module.format_attempts" width="auto">[% l("Verification Attempts") %]</th>
+                <th field="creator" fpath="creator.usrname">[% l("Creator ") %]</th>
+                <th field="create_time" fpath="create_time" width="auto"></th>
+                <th field="search" fpath="search" width="auto"></th>
+                <th field="selectors" fpath="selectors.xpath" width="auto">[% l("URL Selectors") %]</th>
+            </tr>
+        </thead>
+    </table>
+</div>
+[% END %]
diff --git a/Open-ILS/web/js/dojo/openils/FlattenerStore.js b/Open-ILS/web/js/dojo/openils/FlattenerStore.js
index 6f3f989..d36100c 100644
--- a/Open-ILS/web/js/dojo/openils/FlattenerStore.js
+++ b/Open-ILS/web/js/dojo/openils/FlattenerStore.js
@@ -182,7 +182,9 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             if (!this.mapKey)
                 this._get_map_key();
 
-            return this._build_flattener_params(req);
+            var p = this._build_flattener_params(req);
+            console.debug("_fetch_prepare() returning " + dojo.toJson(p));
+            return p;
         },
 
         "_fetch_execute": function(params,handle_as,mime_type,onload,onerror) {
@@ -390,9 +392,17 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
                     else
                         might_be_a_lie += obj.length;
 
+                    console.debug(
+                        "process_fetch() calling onBegin with " +
+                        might_be_a_lie + ", " + dojo.toJson(req)
+                    );
                     req.onBegin.call(callback_scope, might_be_a_lie, req);
                 }
 
+                console.debug(
+                    "about to call onItem for " + obj.length +
+                    " elements in the obj array"
+                );
                 dojo.forEach(
                     obj,
                     function(item) {
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
index cb79e82..c17681c 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
@@ -3,8 +3,10 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
     dojo.require("dojox.jsonPath");
     dojo.require("fieldmapper.OrgUtils");
     dojo.require("openils.Util");
+    dojo.require("openils.CGI");
     dojo.require("openils.PermaCrud");
     dojo.require("openils.widget.FilteringTreeSelect");
+    dojo.require("openils.URLVerify.Verify");
 
     dojo.requireLocalization("openils.URLVerify", "URLVerify");
 
@@ -87,7 +89,7 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
         );
     };
 
-    /* 2) save the tag/subfield sets for URL extraction, */
+    /* 2a) save the tag/subfield sets for URL extraction, */
     module.save_tags = function() {
         module.progress_dialog.attr("title", localeStrings.SAVING_TAGS);
         module.progress_dialog.show(); /* sic */
@@ -95,7 +97,8 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
         uvus_progress = 0;
 
         /* Note we're not using openils.PermaCrud, which is inadequate
-         * when you want transactions. Thanks for figuring it out, Bill. */
+         * when you need one big transaction. Thanks for figuring it
+         * out Bill. */
         var pcrud_raw = new OpenSRF.ClientSession("open-ils.pcrud");
 
         pcrud_raw.connect();
@@ -106,9 +109,7 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
             "oncomplete": function(r) {
                 module._create_uvus_one_at_a_time(
                     pcrud_raw,
-                    module.tag_and_subfields.generate_uvus(
-                        module.session_id
-                    )
+                    module.tag_and_subfields.generate_uvus(module.session_id)
                 );
             }
         }).send();
@@ -125,7 +126,7 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                     {"maximum": uvus_list.length, "progress": ++uvus_progress}
                 );
 
-                uvus_list.shift();  /* /now/ actually shorten the list */
+                uvus_list.shift();  /* /now/ actually shorten working list */
 
                 if (uvus_list.length < 1) {
                     pcrud_raw.request({
@@ -147,8 +148,9 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
     };
 
     /* 3) search and populate the container (API call). */
-    var search_result_count = 0;
     module.perform_search = function() {
+        var search_result_count = 0;
+
         module.progress_dialog.attr("title", localeStrings.PERFORMING_SEARCH);
         module.progress_dialog.show(true);
 
@@ -180,11 +182,13 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                     module.progress_dialog.show(true);
 
                     if (no_url_selection.checked) {
-                        location.href = oilsBasePath +
-                            "/url_verify/validation_review?" +
-                            "session_id=" + module.session_id +
-                            "&validate=1";
+                        /* verify URLs and ultimately redirect to review page */
+                        openils.URLVerify.Verify.go(
+                            module.session_id, null, module.progress_dialog
+                        );
                     } else {
+                        /* go to the URL selection page, allowing users to
+                         * selectively verify URLs */
                         location.href = oilsBasePath +
                             "/url_verify/select_urls?session_id=" +
                             module.session_id;
@@ -200,14 +204,12 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
      * fewer moving parts that can go haywire anyway.
      */
     module._populate_saved_searches = function(node) {
-        var pcrud = new openils.PermaCrud();
-        var list = pcrud.retrieveAll(
+        var list = module.pcrud.retrieveAll(
             "asq", {"order_by": {"asq": "label"}}
         );
 
         dojo.forEach(
-            list,
-            function(o) {
+            list, function(o) {
                 dojo.create(
                     "option", {
                         "innerHTML": o.label(),
@@ -217,8 +219,6 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                 );
             }
         );
-
-        pcrud.disconnect();
     };
 
     /* set up an all-org-units-in-the-tree selector */
@@ -234,7 +234,34 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
         module.org_selector = widget;
     };
 
+    /* Can only be called by setup() */
+    module._clone = function(id) {
+        module.progress_dialog.attr("title", localeStrings.CLONING);
+        var old_session = module.pcrud.retrieve(
+            "uvs", id, {"flesh": 1, "flesh_fields": {"uvs": ["selectors"]}}
+        );
+
+        /* Set name to "Copy of [old name]" */
+        uv_session_name.attr(
+            "value", dojo.string.substitute(
+                localeStrings.CLONE_SESSION_NAME, [old_session.name()]
+            )
+        );
+
+        /* Set search field. */
+        uv_search.attr("value", old_session.search());
+
+        /* Explain to user why we don't affect the saved searches picker. */
+        if (old_session.search().match(/saved_query/))
+            openils.Util.show("clone-saved-search-warning");
+
+        /* Add related xpaths (URL selectors) to TagAndSubfieldsMgr. */
+        module.tag_and_subfields.add_xpaths(old_session.selectors());
+    };
+
     module.setup = function(saved_search_id, org_selector_id, progress_dialog) {
+        module.pcrud = new openils.PermaCrud(); /* only used for setup */
+
         module.progress_dialog = progress_dialog;
 
         module.progress_dialog.attr("title", localeStrings.INTERFACE_SETUP);
@@ -243,6 +270,12 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
         module._populate_saved_searches(dojo.byId(saved_search_id));
         module._prepare_org_selector(dojo.byId(org_selector_id));
 
+        var cgi = new openils.CGI();
+        if (cgi.param("clone"))
+            module._clone(cgi.param("clone"));
+
+        module.pcrud.disconnect();
+
         module.progress_dialog.hide();
     };
 
@@ -286,8 +319,37 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                     "innerHTML": "[X]" /* XXX i18n */
                 }, div, "last"
             );
+        };
 
-            this.counter++;
+        this.add_xpaths = function(xpaths) {
+            if (!dojo.isArray(xpaths)) {
+                console.info("No xpaths to add");
+                return;
+            }
+
+            dojo.forEach(
+                xpaths, dojo.hitch(this, function(xpath) {
+                    var newid = "t-and-s-row-" + String(this.counter++);
+                    var div = dojo.create(
+                        "div", {
+                            "id": newid,
+                            "innerHTML": localeStrings.XPATH +
+                                " <span class='t-and-s-xpath'>" +
+                                xpath.xpath() + "</span>"
+                        }, this.container_id, "last"
+                    );
+                    dojo.create(
+                        "a", {
+                            "href": "javascript:void(0);",
+                            "onclick": function() {
+                                var me = dojo.byId(newid);
+                                me.parentNode.removeChild(me);
+                            },
+                            "innerHTML": "[X]" /* XXX i18n */
+                        }, div, "last"
+                    );
+                })
+            );
         };
 
         /* return a boolean indicating whether or not we have any rows */
@@ -307,27 +369,52 @@ if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
                 '[id^="t-and-s-row-"]', dojo.byId(this.container_id)
             ).forEach(
                 function(row) {
-                    var tag = dojo.query(".t-and-s-tag", row)[0].innerHTML;
-                    var subfield = dojo.query(".t-and-s-subfields", row)[0].innerHTML;
-
-                    var existing;
-                    if ((existing = uniquely_grouped[tag])) { /* sic, assignment */
-                        existing = openils.Util.uniqueElements(
-                            (existing + subfield).split("")
-                        ).sort().join("");
+                    var holds_xpath = dojo.query(".t-and-s-xpath", row);
+                    if (holds_xpath.length) {
+                        uniquely_grouped.xpath = uniquely_grouped.xpath || [];
+                        uniquely_grouped.xpath.push(holds_xpath[0].innerHTML);
                     } else {
-                        uniquely_grouped[tag] = subfield;
+                        var tag = dojo.query(".t-and-s-tag", row)[0].innerHTML;
+                        var subfield =
+                            dojo.query(".t-and-s-subfields", row)[0].innerHTML;
+
+                        var existing;
+                        if ((existing = uniquely_grouped[tag])) { // assignment
+                            existing = openils.Util.uniqueElements(
+                                (existing + subfield).split("")
+                            ).sort().join("");
+                        } else {
+                            uniquely_grouped[tag] = subfield;
+                        }
                     }
                 }
             );
 
             var uvus_list = [];
+            /* Handle things that are already in XPath form first (these
+             * come from cloning link checker sessions. */
+            if (uniquely_grouped.xpath) {
+                dojo.forEach(
+                    uniquely_grouped.xpath,
+                    function(xpath) {
+                        var obj = new uvus();
+
+                        obj.session(session_id);
+                        obj.xpath(xpath);
+                        uvus_list.push(obj);
+                    }
+                );
+                delete uniquely_grouped.xpath;
+            }
+
+            /* Now handle anything entered by hand. */
             for (var tag in uniquely_grouped) {
                 var obj = new uvus();
 
                 obj.session(session_id);
 
-                /* XXX TODO handle control fields (no subfields) */
+                /* XXX TODO Handle control fields (No subfields. but would
+                 * control fields ever contain URLs? Don't know.) */
                 obj.xpath(
                     "//*[@tag='" + tag + "']/*[" +
                     uniquely_grouped[tag].split("").map(
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
new file mode 100644
index 0000000..d9a1cf5
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
@@ -0,0 +1,67 @@
+if (!dojo._hasResource["openils.URLVerify.ReviewAttempt"]) {
+    dojo.require("dojo.string");
+    dojo.require("openils.CGI");
+    dojo.require("openils.PermaCrud");
+    dojo.require("dijit.Tooltip");
+
+    dojo.requireLocalization("openils.URLVerify", "URLVerify");
+
+    dojo._hasResource["openils.URLVerify.ReviewAttempt"] = true;
+    dojo.provide("openils.URLVerify.ReviewAttempt");
+
+    dojo.declare("openils.URLVerify.ReviewAttempt", null, {});
+
+    /* Take care that we add nothing to the global namespace.
+     * This is not an OO module so much as a container for
+     * functions needed by a specific interface. */
+
+(function() {
+    var module = openils.URLVerify.ReviewAttempt;
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
+
+    module._display_session_name = function() {
+        var pcrud = new openils.PermaCrud();
+
+        var attempt = pcrud.retrieve(
+            "uvva", module.attempt_id, {
+                "flesh": 1, "flesh_fields": {"uvva": ["session"]}
+            }
+        );
+
+        dojo.byId("session-link-here").innerHTML =
+            "<a href='select_urls?session_id=" + attempt.session().id() + "'>" +
+            dojo.string.substitute(
+                localeStrings.SESSION_NAME, [attempt.session().name()]
+            ) + "</a>";
+
+        pcrud.disconnect();
+
+        new dijit.Tooltip({
+            "connectId": "session-link-here",
+            "label": localeStrings.SELECT_MORE
+        });
+    };
+
+    module.setup = function(grid, progress_dialog) {
+        module.progress_dialog = progress_dialog;
+        module.progress_dialog.attr("title", localeStrings.INTERFACE_SETUP);
+        module.progress_dialog.show(true);
+
+        var cgi = new openils.CGI();
+        module.attempt_id = cgi.param("attempt_id");
+
+        module.grid = grid;
+
+        module.grid.setBaseQuery({"attempt_id": module.attempt_id});
+
+        module.grid.refresh();
+
+        module._display_session_name();
+
+        module.progress_dialog.hide();
+    };
+
+}());
+
+}
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js b/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
index b8992ac..83e5fdf 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
@@ -2,6 +2,8 @@ if (!dojo._hasResource["openils.URLVerify.SelectURLs"]) {
     dojo.require("dojo.string");
     dojo.require("openils.CGI");
     dojo.require("openils.Util");
+    dojo.require("openils.PermaCrud");
+    dojo.require("openils.URLVerify.Verify");
 
     dojo.requireLocalization("openils.URLVerify", "URLVerify");
 
@@ -20,84 +22,78 @@ if (!dojo._hasResource["openils.URLVerify.SelectURLs"]) {
         dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
 
     module.setup = function(grid, progress_dialog) {
+        module.progress_dialog = progress_dialog;
+        module.progress_dialog.attr("title", localeStrings.INTERFACE_SETUP);
+        module.progress_dialog.show(true);
+
         var cgi = new openils.CGI();
         module.session_id = cgi.param("session_id");
 
         module.grid = grid;
 
-        module.grid.attr("query", {"session_id": module.session_id});
+        module.grid.setBaseQuery({"session_id": module.session_id});
+
         module.grid.refresh();
         // Alternative to grid.refresh() once filter is set up
         //module.grid.fetchLock = false;
         //module.grid.filterUi.doApply();
+
+        module._display_session_name();
+
+        module.progress_dialog.hide();
     };
 
-    module.verify_selected = function() {
-        var really_everything = false;
-
-        if (module.grid.everythingSeemsSelected())
-            really_everything = confirm(localeStrings.VERIFY_ALL);
-
-        module.clear_attempt_display();
-        progress_dialog.attr("title", localeStrings.VERIFICATION_BEGIN);
-        progress_dialog.show();
-
-        fieldmapper.standardRequest(
-            ["open-ils.url_verify", "open-ils.url_verify.session.verify"], {
-                "params": [
-                    openils.User.authtoken,
-                    module.session_id,
-                    really_everything ? null : module.grid.getSelectedIDs()
-                ],
+    module._display_session_name = function() {
+        var pcrud = new openils.PermaCrud();
+
+        pcrud.retrieve(
+            "uvs", module.session_id, {
                 "async": true,
-                "onresponse": function(r) {
+                "oncomplete": function(r) {
                     if (r = openils.Util.readResponse(r)) {
-                        progress_dialog.attr(
-                            "title",
+                        dojo.byId("session-name-here").innerHTML =
                             dojo.string.substitute(
-                                localeStrings.VERIFICATION_PROGRESS,
-                                [r.total_processed]
-                            )
-                        );
-                        progress_dialog.update({
-                            "maximum": r.url_count,
-                            "progress": r.total_excluding_redirects
-                        });
-
-                        if (r.attempt)
-                            module.update_attempt_display(r.attempt);
+                                localeStrings.SESSION_NAME, [r.name()]
+                            );
+
+                        pcrud.disconnect();
                     }
                 }
             }
-        )
-
-        module.grid.getSelectedIDs();   
+        );
     };
 
-    module.clear_attempt_display = function() {
-        dojo.empty(dojo.byId("url-verify-attempt-id"));
-        dojo.empty(dojo.byId("url-verify-attempt-start"));
-        dojo.empty(dojo.byId("url-verify-attempt-finish"));
-    };
+    module.verify_selected = function() {
+        if (module.grid.getSelectedItems().length < 1) {
+            alert(localeStrings.NOTHING_SELECTED);
+            return;
+        }
 
-    module.update_attempt_display = function(attempt) {
-        dojo.byId("url-verify-attempt-id").innerHTML =
-            dojo.string.substitute(
-                localeStrings.VERIFICATION_ATTEMPT_ID,
-                [attempt.id()]
-            );
-        dojo.byId("url-verify-attempt-start").innerHTML =
-            dojo.string.substitute(
-                localeStrings.VERIFICATION_ATTEMPT_START,
-                [attempt.start_time()]
+        if (module.grid.everythingSeemsSelected() &&
+            confirm(localeStrings.VERIFY_ALL)) {
+            /* If we're here, the user wants to verify all URLs matching
+             * the grid's current filters. We need to reach down to the
+             * grid's store to do a special fetch to get all those IDs. */
+
+            module.grid.store.fetch({
+                "query": dojo.clone(module.grid.query),
+                "queryOptions": {"all": true},
+                "onComplete": function(rows) {
+                    openils.URLVerify.Verify.go(
+                        module.session_id,
+                        dojo.map(rows, function(row) { return row.id; }),
+                        module.progress_dialog
+                    );
+                }
+            });
+        } else {
+            /* If we're here, the user wants to verify just the rows he
+             * specifically selected with the checkboxes. */
+            openils.URLVerify.Verify.go(
+                module.session_id,
+                module.grid.getSelectedIDs(),
+                module.progress_dialog
             );
-
-        if (attempt.finish_time()) {
-            dojo.byId("url-verify-attempt-finish").innerHTML =
-                dojo.string.substitute(
-                    localeStrings.VERIFICATION_ATTEMPT_FINISH,
-                    [attempt.finish_time()]
-                );
         }
     };
 
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js b/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
new file mode 100644
index 0000000..a2283e5
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
@@ -0,0 +1,74 @@
+if (!dojo._hasResource["openils.URLVerify.Sessions"]) {
+    dojo.require("dojo.string");
+    dojo.require("openils.Util");
+    dojo.require("openils.URLVerify.Verify");
+
+    dojo.requireLocalization("openils.URLVerify", "URLVerify");
+
+    dojo._hasResource["openils.URLVerify.Sessions"] = true;
+    dojo.provide("openils.URLVerify.Sessions");
+
+    dojo.declare("openils.URLVerify.Sessions", null, {});
+
+    /* Take care that we add nothing to the global namespace.
+     * This is not an OO module so much as a container for
+     * functions needed by a specific interface. */
+
+(function() {
+    var module = openils.URLVerify.Sessions;
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
+
+    module.setup = function(grid, org_selector) {
+        module.grid = grid;
+
+        module.setup_org_selector_for_grid(org_selector);
+    };
+
+    module.setup_org_selector_for_grid = function(org_selector) {
+        function filter_grid_by_selected_org() {
+            module.grid.query = {
+                "owning_lib": org_selector.attr("value")
+            };
+            module.grid.refresh();
+        }
+
+        new openils.User().buildPermOrgSelector(
+            "URL_VERIFY", org_selector, null,
+            function() {
+                dojo.connect(
+                    org_selector, "onChange", filter_grid_by_selected_org
+                );
+                filter_grid_by_selected_org();
+            }
+        );
+    };
+
+    module.format_id = function(str) {
+        if (!str)
+            return "";
+
+        return "<a href='select_urls?session_id=" + str + "' title='" +
+            localeStrings.REREVIEW + "'>" + str +
+            "</a> <a href='create_session?clone=" + str + "' title='" +
+            localeStrings.CLONE_SESSION + "'>" +
+            localeStrings.CLONE_SESSION + "</a>";
+    };
+
+    module.format_attempts = function(list) {
+        if (!dojo.isArray(list)) return "";
+
+        return dojo.map(
+            list, function(id) {
+                if (isNaN(id))
+                    return "";
+                return "<a title='" + localeStrings.REVIEW_ATTEMPT +
+                    "' href='review_attempt?attempt_id=" + id + "'>" +
+                    id + "</a>";
+            }
+        ).join(", ");
+    };
+
+}());
+
+}
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/Verify.js b/Open-ILS/web/js/dojo/openils/URLVerify/Verify.js
new file mode 100644
index 0000000..1839af8
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/Verify.js
@@ -0,0 +1,59 @@
+if (!dojo._hasResource["openils.URLVerify.Verify"]) {
+
+    dojo.requireLocalization("openils.URLVerify", "URLVerify");
+
+    dojo._hasResource["openils.URLVerify.Verify"] = true;
+    dojo.provide("openils.URLVerify.Verify");
+
+    dojo.declare("openils.URLVerify.Verify", null, {});
+
+    /* Take care that we add nothing to the global namespace.
+     * This is not an OO module so much as a container for
+     * functions needed by specific interfaces. */
+
+(function() {
+    var module = openils.URLVerify.Verify;
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
+
+    module.go = function(session_id, url_id_list, progress_dialog) {
+        progress_dialog.attr("title", localeStrings.VERIFICATION_DIALOG);
+        progress_dialog.show();
+
+        fieldmapper.standardRequest(
+            ["open-ils.url_verify", "open-ils.url_verify.session.verify"], {
+                "params": [openils.User.authtoken, session_id, url_id_list],
+                "async": true,
+                "onresponse": function(r) {
+                    if (r = openils.Util.readResponse(r)) {
+                        progress_dialog.update({
+                            "maximum": r.url_count,
+                            "progress": r.total_excluding_redirects
+                        });
+
+                        if (r.attempt) {
+                            module.attempt = r.attempt;
+                            progress_dialog.show(
+                                false,
+                                dojo.string.substitute(
+                                    localeStrings.VERIFICATION_ATTEMPT_ID,
+                                    [r.attempt.id()]
+                                )
+                            );
+                        }
+                    }
+                },
+                "oncomplete": function() {
+                    progress_dialog.show(true);
+                    progress_dialog.attr("title", localeStrings.REDIRECTING);
+                    location.href = oilsBasePath +
+                        "/url_verify/review_attempt?attempt_id=" +
+                        module.attempt.id();
+                }
+            }
+        );
+    };
+
+}());
+
+}
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
index d0b6f3d..d2128db 100644
--- a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
@@ -3,13 +3,19 @@
     "SAVING_TAGS": "Saving tag/subfield selectors ...",
     "PERFORMING_SEARCH": "Performing search ...",
     "EXTRACTING_URLS": "Extracting URLs ...",
-    "NEED_UVUS": "You must add some tag and subfield combinations",
+    "NEED_UVUS": "You must add some tag and subfield combinations.",
     "REDIRECTING": "Loading next interface ...",
     "INTERFACE_SETUP": "Setting up interface ...",
-    "VERIFY_ALL": "Click 'OK' to verify ALL the URLs found in this session.  Click 'Cancel' if the selected, visible URLs are the only ones you want verified.",
-    "VERIFICATION_BEGIN": "Verifying URLs. This can take a moment ...",
-    "VERIFICATION_PROGRESS": "Verifying URLs: ${0} URLs processed ...",
-    "VERIFICATION_ATTEMPT_ID": "Last verification attempt ID: ${0}",
-    "VERIFICATION_ATTEMPT_START": "Attempt start time: ${0}",
-    "VERIFICATION_ATTEMPT_FINISH": "Attempt finish time: ${0}"
+    "VERIFY_ALL": "Click 'OK' to verify ALL the URLs that belong to this session and match all your search terms.  Click 'Cancel' if the selected, visible URLs are the only ones you want verified.",
+    "VERIFICATION_DIALOG": "Verifying URLs ...",
+    "VERIFICATION_ATTEMPT_ID": "Verification attempt ID: ${0}",
+    "NOTHING_SELECTED": "No rows are selected, so no action will be taken.",
+    "REVIEW_ATTEMPT": "Review this verification attempt",
+    "CLONE_SESSION": "Clone",
+    "REREVIEW": "Review / Verify",
+    "CLONING": "Cloning existing session ...",
+    "CLONE_SESSION_NAME": "Copy of ${0}",
+    "XPATH": "XPath",
+    "SESSION_NAME": "Session '${0}'",
+    "SELECT_MORE": "Click here to review all session URLs and/or select other URLs to verify"
 }
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js
index 187b69f..bad2762 100644
--- a/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js
+++ b/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js
@@ -6,6 +6,24 @@ if (!dojo._hasResource["openils.widget.FlattenerFilterDialog"]) {
     dojo.declare(
         "openils.widget.FlattenerFilterDialog", [
             dijit.Dialog, openils.widget.FlattenerFilterPane
-        ]
+        ], {
+            "constructor": function() {
+                dojo.connect(
+                    this, "postCreate", this,
+                    function() {
+                        /* Of course I don't *want* to hardcode 400px below,
+                         * but without some kind of maxHeight, this dialog
+                         * can just grow forever, until it's no longer
+                         * possible to access the close button on the top or
+                         * the buttons at the bottom. */
+                        dojo.style(
+                            this.domNode, {
+                                "maxHeight": "400px", "overflow": "auto"
+                            }
+                        );
+                    }
+                );
+            }
+        }
     );
 }
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
index e83fca9..d66ee66 100644
--- a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
+++ b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
@@ -18,7 +18,9 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
             "columnPersistKey": null,
             "autoCoreFields": false,
             "autoCoreFieldsUnsorted": false,
+            "autoCoreFieldsFilter": false,
             "autoFieldFields": null,
+            "autoFieldFieldsUnsorted": null, /* array, subset of autoFieldFields */
             "showLoadFilter": false,    /* use FlattenerFilter(Dialog|Pane) */
             "filterAlwaysInDiv": null,  /* use FlattenerFilterPane and put its
                                            content in this HTML element */
@@ -31,6 +33,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                                    OU selectors so that it should get mixed
                                    correctly with the generated query from the
                                    filter dialog. */
+            "savedFiltersInterface": null,
 
             /* These potential constructor arguments may be useful to
              * FlattenerGrid in their own right, and are passed to
@@ -286,16 +289,23 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 return {"labels": labels, "columns": columns};
             },
 
-            "_getAutoFieldFields": function(fmclass) {
-                return dojo.clone(
+            "_getAutoFieldFields": function(fmclass, path) {
+                var field_list = dojo.clone(
                     fieldmapper.IDL.fmclasses[fmclass].fields)
                 .filter(
-                    function(field) {
-                        return !field.virtual && field.datatype != "link";
-                    }
-                ).sort(
-                    function(a, b) { return a.label > b.label ? 1 : -1; }
+                    function(f) { return !f.virtual && f.datatype != "link"; }
                 );
+                
+                /* Sort fields unless the path is named in grid property
+                 * 'autoFieldFieldsUnsorted' (array). */
+                if (!dojo.isArray(this.autoFieldFieldsUnsorted) ||
+                        this.autoFieldFieldsUnsorted.indexOf(path) == -1) {
+                    field_list = field_list.sort(
+                        function(a, b) { return a.label > b.label ? 1 : -1; }
+                    );
+                }
+
+                return field_list;
             },
 
             /* Take our core class (this.fmClass) and add table columns for
@@ -314,7 +324,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 }
 
                 dojo.forEach(
-                    fields, function(f) {
+                    fields, dojo.hitch(this, function(f) {
                         if (f.datatype == "link" || f.virtual)
                             return;
 
@@ -329,10 +339,10 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         cell_list.push({
                             "field": f.name,
                             "name": f.label,
-                            "fsort": true /*,
-                            "_visible": false */
+                            "fsort": true,
+                            "ffilter": this.autoCoreFieldsFilter
                         });
-                    }
+                    })
                 );
             },
 
@@ -350,7 +360,9 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                             return;
                         } else {
                             dojo.forEach(
-                                self._getAutoFieldFields(beginning.fmClass),
+                                self._getAutoFieldFields(
+                                    beginning.fmClass, path
+                                ),
                                 function(field) {
                                     var would_be_path =
                                         path + "." + field.name;
@@ -506,10 +518,10 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                             "fmClass": this.fmClass,
                             "mapTerminii": this.mapTerminii,
                             "useDiv": this.filterAlwaysInDiv,
-                            "compact": true,
                             "initializers": this.filterInitializers,
                             "widgetBuilders": this.filterWidgetBuilders,
-                            "suppressFilterFields": this.suppressFilterFields
+                            "suppressFilterFields": this.suppressFilterFields,
+                            "savedFiltersInterface": this.savedFiltersInterface
                         });
 
                     this.filterUi.onApply = dojo.hitch(
@@ -922,6 +934,13 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                     this.getSelectedIDs();
 
                 this.print(null, null, id_blob);
+            },
+
+            "setBaseQuery": function(query) {   /* sets a persistent query
+                                                   that always gets mixed in
+                                                   with whatever you do in the
+                                                   filter dialog */
+                this._baseQuery = dojo.clone(this.query = query);
             }
         }
     );
diff --git a/Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js b/Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js
index 205deb4..4c6af87 100644
--- a/Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js
+++ b/Open-ILS/web/js/dojo/openils/widget/PCrudFilterPane.js
@@ -35,6 +35,8 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
     dojo.require('openils.widget.AutoFieldWidget');
     dojo.require('dijit.form.FilteringSelect');
     dojo.require('dijit.form.Button');
+    dojo.require('dijit.form.DropDownButton');
+    dojo.require('dijit.TooltipDialog');
     dojo.require('dojo.data.ItemFileReadStore');
     dojo.require('openils.Util');
 
@@ -99,6 +101,18 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                         "param_count": 1,
                         "strict": true
                     }, {
+                        "name": "in",
+                        "label": pcFilterLocaleStrings.OPERATOR_IN,
+                        "param_count": null,    /* arbitrary number, special */
+                        "strict": true,
+                        "minimal": true
+                    }, {
+                        "name": "not in",
+                        "label": pcFilterLocaleStrings.OPERATOR_NOT_IN,
+                        "param_count": null,    /* arbitrary number, special */
+                        "strict": true,
+                        "minimal": true
+                    }, {
                         "name": "between",
                         "label": pcFilterLocaleStrings.OPERATOR_BETWEEN,
                         "param_count": 2,
@@ -151,7 +165,7 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
 
         var ops = openils.Util.objectProperties(clause);
         var op = ops.pop();
-        var matches = op.match(/^not (\w+)$/);
+        var matches = op.match(/^not [lb].+$/); /* "not in" needs no change */
         if (matches) {
             clause[matches[1]] = clause[op];
             delete clause[op];
@@ -160,6 +174,30 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         return false;
     }
 
+    /* Given a value, add it to selector options if it's not already there,
+     * and select it. */
+    function _add_or_at_least_select(value, selector) {
+        var found = false;
+
+        for (var i = 0; i < selector.options.length; i++) {
+            var option = selector.options[i];
+            if (option.value == value) {
+                found = true;
+                option.selected = true;
+            }
+        }
+
+        if (!found) {
+            dojo.create(
+                "option", {
+                    "innerHTML": value,
+                    "value": value,
+                    "selected": "selected"
+                }, selector
+            );
+        }
+    }
+
     /* This is not the dijit per se. Search further in this file for
      * "dojo.declare" for the beginning of the dijit.
      *
@@ -249,20 +287,58 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
             return result;
         };
 
-        this.add_row = function(initializer) {
+        this._validate_initializer = function(initializer, onsuccess) {
+            this.field_store.fetchItemByIdentity({
+                "identity": initializer.field,
+                "onItem": dojo.hitch(this, function(item) {
+                    if (item) {
+                        onsuccess();
+                    } else {
+                        console.debug(
+                            "skipping initializer for field " +
+                            initializer.field + " not present here"
+                        );
+                    }
+                })
+            });
+        };
+
+        this._proceed_add_row = function(initializer) {
+            var row_id_list = openils.Util.objectProperties(this.rows);
+
+            /* Kill initial empty row when adding pre-initialized rows. */
+            if (row_id_list.length == 1 && initializer) {
+                var existing_row_id = row_id_list.shift();
+                if (this.rows[existing_row_id].is_unset())
+                    this.remove_row(existing_row_id, true /* no_apply */);
+            }
+
             this.hide_empty_placeholder();
             var row_id = this.row_index++;
             this.rows[row_id] = new PCrudFilterRow(this, row_id, initializer);
         };
 
-        this.remove_row = function(row_id) {
+        this.add_row = function(initializer) {
+            if (initializer) {
+                this._validate_initializer(
+                    initializer,
+                    dojo.hitch(this, function() {
+                        this._proceed_add_row(initializer);
+                    })
+                );
+            } else {
+                this._proceed_add_row(initializer);
+            }
+        };
+
+        this.remove_row = function(row_id, no_apply) {
             this.rows[row_id].destroy();
             delete this.rows[row_id];
 
             if (openils.Util.objectProperties(this.rows).length < 1)
                 this.show_empty_placeholder();
 
-            if (this.compact)
+            if (this.compact && !no_apply)
                 this.do_apply();
         };
 
@@ -320,6 +396,20 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
             }
         };
 
+        /* This is for generating a data structure so that we can store
+         * a representation of the state of the filter rows.  Not for
+         * generating a query to be used in search.  You want compile() for
+         * that. */
+        this.serialize = function() {
+            var serialized = [];
+            for (var rowkey in this.rows) { /* row order doesn't matter */
+                var row_ser = this.rows[rowkey].serialize();
+                if (row_ser)
+                    serialized.push(row_ser);
+            }
+            return dojo.toJson(serialized);
+        };
+
         this._init.apply(this, arguments);
     }
 
@@ -383,6 +473,11 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                     "scrollOnFocus": false,
                     "onChange": function(value) {
                         self.update_selected_field(value);
+                        if (this.and_then) {    /* ugh. also, self != this. */
+                            var once = this.and_then;
+                            delete this.and_then;
+                            once();
+                        }
                     },
                     "store": this.filter_row_manager.field_store
                 }, dojo.create("span", {}, td)
@@ -413,12 +508,12 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         };
 
         this._create_value_slot = function(use_element) {
+            var how = {"innerHTML": "-"};
+
             if (use_element)
-                this.value_slot = dojo.create(
-                    "span", {"innerHTML": "-"}, use_element
-                );
+                this.value_slot = dojo.create("span", how, use_element);
             else
-                this.value_slot = dojo.create("td",{"innerHTML":"-"},this.tr);
+                this.value_slot = dojo.create("td", how, this.tr);
         };
 
         this._create_remover = function(use_element) {
@@ -438,7 +533,10 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         this._clear_value_slot = function() {
             if (this.value_widgets) {
                 this.value_widgets.forEach(
-                    function(autowidg) { autowidg.widget.destroy(); }
+                    function(autowidg) {
+                        if (autowidg.widget)
+                            autowidg.widget.destroy();
+                    }
                 );
                 delete this.value_widgets;
             }
@@ -454,28 +552,112 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
 
             this.value_widgets = [];
 
-            var param_count = this.operator_selector.item.param_count;
-
-            /* This is where find and deploy custom widget builders. */
+            /* This is where find custom widget builders to deploy shortly. */
             var widget_builder_key = this.selected_field_fm_class + ":" +
                 this.selected_field_fm_field;
             var constr =
                 this.filter_row_manager.widget_builders[widget_builder_key] ||
                 openils.widget.AutoFieldWidget;
 
-            for (var i = 0; i < param_count; i++) {
-                var widg = new constr({
-                    "fmClass": this.selected_field_fm_class,
-                    "fmField": this.selected_field_fm_field,
-                    "parentNode": dojo.create("span", {}, this.value_slot),
-                    "dijitArgs": {"scrollOnFocus": false}
-                });
+            /* How many value widgets do we need for this operator? */
+            var param_count =
+                this.operator_selector.store.getValue(
+                    this.operator_selector.item, "param_count"
+                );
 
-                widg.build();
-                this.value_widgets.push(widg);
+            if (param_count === null) {
+                /* When param_count is null, we invoke the special case of
+                 * preparing widgets for building a dynamic set of values.
+                 * All other cases are handled by the else branch. */
+                this._build_set_value_widgets(constr);
+            } else {
+                for (var i = 0; i < param_count; i++) {
+                    this.value_widgets.push(
+                        this._build_one_value_widget(constr)
+                    );
+                }
             }
         };
 
+        this._build_set_value_widgets = function(constr) {
+            var value_widget = dojo.create(
+                "select", {
+                    "multiple": "multiple",
+                    "size": 4,
+                    "style": {
+                        "width": "6em",
+                        "verticalAlign": "middle",
+                        "margin": "0 0.75em"
+                    }
+                },
+                this.value_slot
+            );
+            var entry_widget = this._build_one_value_widget(constr);
+            var adder = dojo.create(
+                "a", {
+                    "href": "javascript:void(0);",
+                    "style": {"verticalAlign": "middle", "margin": "0 0.75em"},
+                    "innerHTML": "[+]", /* XXX i18n? */
+                    "onclick": dojo.hitch(this, function() {
+                        _add_or_at_least_select(
+                            this._value_for_compile(entry_widget),
+                            value_widget
+                        );
+                        entry_widget.widget.attr("value", ""); /* clear */
+                    })
+                }, this.value_slot
+            );
+            this.value_widgets.push(value_widget);
+        };
+
+
+        /* Create just one value widget (used by higher-level functions
+         * that worry about how many are needed). */
+        this._build_one_value_widget = function(constr) {
+            var widg = new constr({
+                "fmClass": this.selected_field_fm_class,
+                "fmField": this.selected_field_fm_field,
+                "noDisablePkey": true,
+                "parentNode": dojo.create(
+                    "span", {
+                        "style": {"verticalAlign": "middle"}
+                    }, this.value_slot
+                ),
+                "dijitArgs": {"scrollOnFocus": false}
+            });
+
+            widg.build();
+            return widg;
+        };
+
+        this._value_for_serialize = function(widg) {
+            if (!widg.widget)   /* widg is <select> */
+                return dojo.filter(
+                    widg.options,
+                    function(o) { return o.selected; }
+                ).map(
+                    function(o) { return o.value; }
+                );
+            else
+                return widg.widget.attr("value");
+        };
+
+        this._value_for_compile = function(widg) {
+            if (!widg.widget)   /* widg is <select> */
+                return dojo.filter(
+                    widg.options,
+                    function(o) { return o.selected; }
+                ).map(
+                    function(o) { return o.value; }
+                );
+            else if (widg.useCorrectly)
+                return widg.widget.attr("value");
+            else if (this.selected_field_is_indirect)
+                return widg.widget.attr("displayedValue");
+            else
+                return widg.getFormattedValue();
+        }
+
         /* for ugly special cases in compilation */
         this._null_clause = function() {
             var opname = this.get_selected_operator_name();
@@ -498,8 +680,15 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         };
 
         this.get_selected_operator_name = function() {
-            var op = this.get_selected_operator();
-            return op ? op.name : null;
+            var item = this.get_selected_operator();
+            if (item) {
+                return this.operator_selector.store.getValue(item, "name");
+            } else {
+                console.warn(
+                    "Could not determine selected operator. " +
+                    "Something is about to break."
+                );
+            }
         };
 
         this.update_selected_operator = function(value) {
@@ -526,17 +715,46 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
             }
         };
 
+        this.serialize = function() {
+            if (!this.selected_field)
+                return;
+
+            var serialized = {
+                "field": this.selected_field,
+                "operator": this.get_selected_operator_name()
+            };
+
+            var values;
+
+            if (this.value_widgets) {
+                values = this.value_widgets.map(
+                    dojo.hitch(
+                        this, function(w) {
+                            return this._value_for_serialize(w);
+                        }
+                    )
+                );
+            }
+
+            /* The following grew organically to be very silly and confusing.
+             * Could use a rethink (PCrudFilterRow.initialize() would also need
+             * matching changes). */
+            if (values.length == 1) {
+                if (dojo.isArray(values[0]))
+                    serialized.values = values[0];
+                else
+                    serialized.value = values[0];
+            } else if (values.length > 1) {
+                serialized.values = values;
+            }
+
+            return serialized;
+        };
+
         this.compile = function() {
             if (this.value_widgets) {
                 var values = this.value_widgets.map(
-                    function(widg) {
-                        if (widg.useCorrectly)
-                            return widg.widget.attr("value");
-                        else if (self.selected_field_is_indirect)
-                            return widg.widget.attr("displayedValue");
-                        else
-                            return widg.getFormattedValue();
-                    }
+                    dojo.hitch(this, this._value_for_compile)
                 );
 
                 if (!values.length) {
@@ -545,7 +763,13 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                     var clause = {};
                     var op = this.get_selected_operator_name();
 
-                    var prep_function = function(o) { return o; /* no-op */ };
+                    var prep_function = function(o) {
+                        if (dojo.isArray(o) && !o.length)
+                            throw new Error(pcFilterLocaleStrings.EMPTY_LIST);
+
+                        return o;
+                    };
+
                     if (String(op).match(/like/))
                         prep_function = this._add_like_wildcards;
 
@@ -570,19 +794,43 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         };
 
         this.initialize = function(initializer) {
+            /* and_then is a nasty kludge callback called once at onChange */
+            this.field_selector.and_then = dojo.hitch(
+                this, function() {
+                    this.operator_selector.attr("value", initializer.operator);
+
+                    /* Caller supplies value for one value, values (array) for
+                     * multiple. */
+                    if (typeof initializer.value !== "undefined" &&
+                            !initializer.values) {
+                        initializer.values = [initializer.value];
+                    }
+                    initializer.values = initializer.values || [];
+
+                    if (initializer.operator.match(/^(not ?)in$/)) {
+                        /* "in" and "not in" need special treatement */
+                        dojo.forEach(
+                            initializer.values, dojo.hitch(this, function(v) {
+                                _add_or_at_least_select(
+                                    v, this.value_widgets[0]
+                                );
+                            })
+                        );
+                    } else {
+                        /* other operators work this way: */
+                        for (var i = 0; i < initializer.values.length; i++) {
+                            this.value_widgets[i].widget.attr(
+                                "value", initializer.values[i]
+                            );
+                        }
+                    }
+                }
+            );
             this.field_selector.attr("value", initializer.field);
-            this.operator_selector.attr("value", initializer.operator);
-
-            /* Caller supplies value for one value, values (array) for
-             * multiple. */
-            if (!initializer.values || !dojo.isArray(initializer.values))
-                initializer.values = [initializer.values || initializer.value];
+        };
 
-            for (var i = 0; i < initializer.values.length; i++) {
-                this.value_widgets[i].widget.attr(
-                    "value", initializer.values[i]
-                );
-            }
+        this.is_unset = function() {
+            return !Boolean(this.field_selector.attr("value"));
         };
 
         this._init.apply(this, arguments);
@@ -593,15 +841,16 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
         {
             "useDiv": null, /* should always be null for subclass dialogs */
             "initializers": null,
-            "compact": false,
             "widgetBuilders": null,
             "suppressFilterFields": null,
+            "savedFiltersInterface": null,
 
             "constructor": function(args) {
                 for(var k in args)
                     this[k] = args[k];
                 this.widgetIndex = 0;
                 this.widgetCache = {};
+                this.compact = Boolean(this.useDiv);
 
                 /* Meaningless in a pane, but better here than in
                  * PCrudFilterDialog so that we don't need to load i18n
@@ -609,6 +858,124 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                 this.title = this.title || pcFilterLocaleStrings.DEFAULT_DIALOG_TITLE;
             },
 
+            "_buildSavedFilterControlsIfPerms": function(holder) {
+                (new openils.User()).getPermOrgList(
+                    "SAVED_FILTER_DIALOG_FILTERS",
+                    dojo.hitch(this, function(id_list) {
+                        this._buildSavedFilterControls(id_list, holder);
+                    }),
+                    true, true
+                );
+            },
+
+            "_buildSavedFilterControls": function(id_list, holder) {
+                if (!id_list || !id_list.length) {
+                    console.info("Not showing saved filter controls; no perm");
+                    return;
+                }
+
+                var fs_list = (new openils.PermaCrud()).search(
+                    "cfdfs", {
+                        "owning_lib": id_list,
+                        "interface": this.savedFiltersInterface
+                    }, {
+                        "order_by": [
+                            {"class": "cfdfs", "field": "owning_lib"},
+                            {"class": "cfdfs", "field": "name"}
+                        ],
+                        "async": true,
+                        "oncomplete": dojo.hitch(this, function(r) {
+                            if (r = openils.Util.readResponse(r)) {
+                                this._buildSavedFilterLoader(r, holder);
+                            }
+                        })
+                    }
+                );
+
+                this._buildSavedFilterSaver(holder);
+            },
+
+            "_buildSavedFilterLoader": function(fs_list, holder) {
+                var self = this;
+                var load_content = dojo.create(
+                    "div", {
+                        "innerHTML": pcFilterLocaleStrings.CHOOSE_FILTER_TO_LOAD
+                    }
+                );
+
+                var selector = dojo.create(
+                    "select", {
+                        "multiple": "multiple",
+                        "size": 4,
+                        "style": {
+                            "verticalAlign": "middle", "margin": "0 0.75em"
+                        }
+                    }, load_content, "last"
+                );
+
+                dojo.forEach(
+                    fs_list, function(fs) {
+                        dojo.create(
+                            "option", {
+                                "innerHTML": fs.name(),
+                                "value": dojo.toJson([fs.id(),
+                                    dojo.fromJson(fs.filters())])
+                            }, selector
+                        );
+                    }
+                );
+
+                var applicator = dojo.create(
+                    "a", {
+                        "href": "javascript:void(0);",
+                        "onclick": function() {
+                            dojo.filter(
+                                selector.options,
+                                function(o){return o.selected;}
+                            ).map(
+                                function(o){return dojo.fromJson(o.value)[1];}
+                            ).forEach(
+                                function(o){
+                                    o.forEach(
+                                        function(p) {
+                                            self.filter_row_manager.add_row(p);
+                                        }
+                                    );
+                                }
+                            );
+                            dijit.popup.close(self.filter_set_loader.dropDown);
+                        },
+                        "innerHTML": pcFilterLocaleStrings.APPLY
+                    }, load_content, "last"
+                );
+
+                this.filter_set_loader = new dijit.form.DropDownButton({
+                    "dropDown": new dijit.TooltipDialog({
+                        "content": load_content
+                    }),
+                    "label": pcFilterLocaleStrings.LOAD_FILTERS
+                }, dojo.create("span", {}, holder));
+            },
+
+            "_buildSavedFilterSaver": function(holder) {
+                this.filter_set_loader = new dijit.form.Button({
+                    "onClick": dojo.hitch(
+                        this, function() {
+                            this.saveFilters(
+                                /* XXX I know some find prompt() objectionable
+                                 * somehow, but I can't seem to type into any
+                                 * text inputs that I put inside TooltipDialog
+                                 * instances, so meh. */
+                                prompt(
+                                    pcFilterLocaleStrings.NAME_SAVED_FILTER_SET
+                                )
+                            );
+                        }
+                    ),
+                    "label": pcFilterLocaleStrings.SAVE_FILTERS
+                }, dojo.create("span", {}, holder));
+            },
+
             "_buildButtons": function() {
                 var self = this;
 
@@ -649,6 +1016,9 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                         }, dojo.create("span", {}, button_holder)
                     );
                 }
+
+                if (this.savedFiltersInterface)
+                    this._buildSavedFilterControlsIfPerms(button_holder);
             },
 
             "_buildFieldStore": function() {
@@ -692,6 +1062,35 @@ if (!dojo._hasResource['openils.widget.PCrudFilterPane']) {
                 });
             },
 
+            "saveFilters": function(name, oncomplete) {
+                var filters_value = this.filter_row_manager.serialize();
+                var filter_set = new cfdfs();
+                filter_set.name(name);
+                filter_set.interface(this.savedFiltersInterface);
+                filter_set.owning_lib(openils.User.user.ws_ou());
+                filter_set.creator(openils.User.user.id()); /* not reliable */
+                filter_set.filters(filters_value);
+
+                (new openils.PermaCrud()).create(
+                    filter_set, {
+                        "oncomplete": dojo.hitch(this, function() {
+                            var selector = dojo.query(
+                                "select[multiple]",
+                                this.filter_set_loader.dropDown.domNode
+                            )[0];
+                            dojo.create(
+                                "option", {
+                                    "innerHTML": name,
+                                    "value": dojo.toJson([-1,
+                                        dojo.fromJson(filters_value)])
+                                }, selector
+                            );
+                            if (oncomplete) oncomplete();
+                        })
+                    }
+                );
+            },
+
             "hide": function() {
                 try {
                     this.inherited(arguments);
diff --git a/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterPane.js b/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterPane.js
index 8e76dd5..761c1d3 100644
--- a/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterPane.js
+++ b/Open-ILS/web/js/dojo/openils/widget/nls/PCrudFilterPane.js
@@ -7,6 +7,8 @@
     "OPERATOR_GT": "is greater than",
     "OPERATOR_LTE": "is less than or equal to",
     "OPERATOR_GTE": "is greater than or equal to",
+    "OPERATOR_IN": "is in the set",
+    "OPERATOR_NOT_IN": "is not in the set",
     "OPERATOR_BETWEEN": "is between",
     "OPERATOR_NOT_BETWEEN": "is not between",
     "OPERATOR_LIKE": "is like",
@@ -16,5 +18,11 @@
     "DEFAULT_DIALOG_TITLE": "Filter Results",
     "ADD_ROW": "Add Row",
     "APPLY": "Apply",
-    "CANCEL": "Cancel"
+    "CANCEL": "Cancel",
+    "LOAD_FILTERS": "Load Filters",
+    "SAVE_FILTERS": "Save Filters",
+    "CHOOSE_FILTER_TO_LOAD": "Choose filter sets to load",
+    "NAME_SAVED_FILTER_SET": "Enter a name for your saved filter set:",
+    "NEED_NAME": "You must enter a name for the saved filters.",
+    "EMPTY_LIST": "Cannot compile search filter.  Empty lists not allowed."
 }
diff --git a/Open-ILS/web/opac/locale/en-US/lang.dtd b/Open-ILS/web/opac/locale/en-US/lang.dtd
index 2721d40..b700232 100644
--- a/Open-ILS/web/opac/locale/en-US/lang.dtd
+++ b/Open-ILS/web/opac/locale/en-US/lang.dtd
@@ -964,6 +964,8 @@
 <!ENTITY staff.main.menu.cat.vandelay.label "MARC Batch Import/Export">
 <!ENTITY staff.main.menu.cat.z39_50_import.accesskey "Z">
 <!ENTITY staff.main.menu.cat.z39_50_import.label "Import Record from Z39.50">
+<!ENTITY staff.main.menu.cat.url_verify.label "Link Checker">
+<!ENTITY staff.main.menu.cat.url_verify.accesskey "K">
 
 <!ENTITY staff.main.menu.acq.label "Acquisitions">
 <!ENTITY staff.main.menu.acq.accesskey "A">
diff --git a/Open-ILS/xul/staff_client/chrome/content/main/menu.js b/Open-ILS/xul/staff_client/chrome/content/main/menu.js
index 54320d4..322ca8a 100644
--- a/Open-ILS/xul/staff_client/chrome/content/main/menu.js
+++ b/Open-ILS/xul/staff_client/chrome/content/main/menu.js
@@ -181,7 +181,7 @@ main.menu.prototype = {
         }
 
 
-        function open_eg_web_page(path, labelKey, event) {
+        function open_eg_web_page(path, labelKey, event, content_params) {
             
             // tab label
             labelKey = labelKey || 'menu.cmd_open_conify.tab';
@@ -190,11 +190,14 @@ main.menu.prototype = {
             // URL
             var loc = urls.XUL_BROWSER + '?url=' + window.escape(obj.url_prefix('EG_WEB_BASE/') + path);
 
+            content_params = content_params || {
+                'no_xulG': false,
+                'show_print_button': true,
+                'show_nav_buttons': true 
+            };
+
             obj.command_tab(
-                event,
-                loc, 
-                {tab_name : label, browser : false }, 
-                {no_xulG : false, show_print_button : true, show_nav_buttons : true }
+                event, loc, {tab_name: label, browser: false}, content_params
             );
         }
 
@@ -1193,6 +1196,21 @@ main.menu.prototype = {
                     obj.command_tab( event, url, {}, { 'id' : obj.data.last_patron } );
                 }
             ],
+
+            'cmd_url_verify' : [
+                ['oncommand'],
+                function(event) {
+                    open_eg_web_page(
+                        "/eg/url_verify/sessions",
+                        "menu.cmd_url_verify.tab",
+                        event, {
+                            'no_xulG': false,
+                            'show_print_button': false,
+                            'show_nav_buttons': true 
+                        }
+                    );
+                }
+            ],
             
             'cmd_retrieve_last_record' : [
                 ['oncommand'],
diff --git a/Open-ILS/xul/staff_client/chrome/content/main/menu_frame_menus.xul b/Open-ILS/xul/staff_client/chrome/content/main/menu_frame_menus.xul
index 0d2126c..7fe88a2 100644
--- a/Open-ILS/xul/staff_client/chrome/content/main/menu_frame_menus.xul
+++ b/Open-ILS/xul/staff_client/chrome/content/main/menu_frame_menus.xul
@@ -24,6 +24,7 @@
     <command id="cmd_cat_main" />
     <command id="cmd_create_marc" />
     <command id="cmd_authority_manage" />
+    <command id="cmd_url_verify" />
     <command id="cmd_circ_checkout" />
     <command id="cmd_patron_search" />
     <command id="cmd_circ_checkin" />
@@ -427,6 +428,8 @@
         <menuitem label="&staff.main.menu.cat.retrieve_last_record.label;" accesskey="&staff.main.menu.cat.retrieve_last_record.accesskey;" command="cmd_retrieve_last_record"/>
         <menuseparator />
         <menuitem label="&staff.main.menu.cat.authority_manage.label;" accesskey="&staff.main.menu.cat.authority_manage.accesskey;" command="cmd_authority_manage"/>
+        <menuseparator />
+        <menuitem label="&staff.main.menu.cat.url_verify.label;" accesskey="&staff.main.menu.cat.url_verify.accesskey;" command="cmd_url_verify"/>
     </menupopup>
 </menu>
 
diff --git a/Open-ILS/xul/staff_client/chrome/locale/en-US/offline.properties b/Open-ILS/xul/staff_client/chrome/locale/en-US/offline.properties
index 2405b95..c2f6212 100644
--- a/Open-ILS/xul/staff_client/chrome/locale/en-US/offline.properties
+++ b/Open-ILS/xul/staff_client/chrome/locale/en-US/offline.properties
@@ -250,6 +250,7 @@ menu.cmd_acq_po.tab=Purchase Orders
 menu.cmd_acq_user_requests.tab=Patron Requests
 menu.cmd_acq_claim_eligible.tab=Claim-Ready Items
 menu.cmd_serial_batch_receive.tab=Batch Receive
+menu.cmd_url_verify.tab=Link Checker
 menu.cmd_booking_resource.tab=Resources
 menu.cmd_booking_reservation.tab=Reservations
 menu.cmd_booking_reservation_pickup.tab=Reservation Pickup

commit 3a2f6181daf37eeb4bb6b4357fad50762f4d1a92
Author: Bill Erickson <berick at esilibrary.com>
Date:   Fri Aug 31 09:59:58 2012 -0400

    Link checker: URLVerify.pm; response throttling repairs
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
index 86d95c3..0e49a00 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -22,6 +22,7 @@ __PACKAGE__->register_method(
     method => 'verify_session',
     api_name => 'open-ils.url_verify.session.verify',
     stream => 1,
+    max_chunk_size => 0,
     signature => {
         desc => q/
             Performs verification on all (or a subset of the) URLs within the requested session.
@@ -197,17 +198,18 @@ sub verify_session {
                     $total_processed++;
 
                     if ($options->{report_all} or ($total_processed % $resp_window == 0)) {
+
                         $client->respond({
                             url_count => $url_count,
                             current_verification => $content,
                             total_excluding_redirects => $total_excluding_redirects,
                             total_processed => $total_processed
                         });
-                    }
 
-                    # start off responding quickly, then throttle
-                    # back to only relaying every 256 messages.
-                    $resp_window *= 2 unless $resp_window >= 256;
+                        # start off responding quickly, then throttle
+                        # back to only relaying every 256 messages.
+                        $resp_window *= 2 unless $resp_window >= 256;
+                    }
                 }
             }
         },

commit 61195ba35fbf3a5a557be81b03af3e70d7a938c1
Author: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
Date:   Fri Aug 17 12:17:00 2012 -0400

    Link checker: user interface and supporting fixes (part 1)
    
    Add open-ils.url_verify service to example OpenSRF configs
    ML methods to create sessions and do the searching/bucketing
        We can't use PCRUD to create url_verify.session objects because a) you
        couldn't trust the creator field if we allowed that, and b) the
        container foreign key has a not-null constraint, so you have to create
        that first, and you can't do that with PCRUD.
        I've removed the C, U and D perms for PCRUD for url_verify.session, but
        I left the R in case we wind up using that.
    Beginnings for the big session kick-off UI.  Not yet functional.
    Get all search results, not just first 10
    Check for session ownership and for previous searchitude
    Deal with moved publish_fieldmapper() method
        This is a companion commit to
        fac45ab9b1cb8924 / Move Fieldmapper API call to Application.pm
        Without it, Flattener and Action/Trigger stop working with errors like
        this:
        [Mon Aug 20 13:50:18 2012] [error] [client XXX.XXX.XXX.XXX] Exception:
        OpenSRF::EX::ERROR 2012-08-20T13:50:18 main -e:0 System ERROR:
        Exception: OpenSRF::DomainObject::oilsMethodException
        2012-08-20T13:50:18 OpenSRF::AppRequest
        /usr/local/share/perl/5.10.1/OpenSRF/AppSession.pm:1064 <500>   *** Call
        to [open-ils.fielder.flattened_search.execute.atomic] failed for session
        [1345485018.767884163.96534353976], thread trace [1]:\nNo field by the
        name publish_fieldmapper in Fieldmapper! at
        /usr/local/share/perl/5.10.1/OpenILS/Utils/Fieldmapper.pm line
        270.\n\n\n\n, referer:
        http://XXXXXXX/eg/conify/global/actor/search_filter_group
    Use a perm that actually exists
    More UI work. Saved search selector & search scope OU selector & cosmetics
    Fix subtle Perl issue
        Not a syntax error that the compiler will catch, but see
        "perldoc -f do" which will lead you do "perldoc perlsyn"
    Buckets and their items aren't designed to be PCRUD accessible,
        so we need a handy view to link URL Verify Sessions to the bib
        contained.  We can leverage this in flattener queries.
    Pretty much finished session create UI but for cloning
    Permisison fixing
    whitespace
    Fix previously nonfunctional stored procedure url_verify.extract_urls(INT,INT)
    Call URL extraction phase from UI
    Fix xpath generation to match what works
    Various fixes, largely UI
    Refactor create_session as dojo module.
    Fix IDL permissions that require jumps
    Essentials for URL selecting interface
    Verification sorta works
    A note about open-ils.url_verify.verify_url for future reference
    
    Signed-off-by: Lebbeous Fogle-Weekley <lebbeous at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index c774122..7726438 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -9366,15 +9366,43 @@ SELECT  usr,
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <retrieve permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <update permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <delete permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <retrieve permission="URL_VERIFY" context_field="owning_lib"/>
             </actions>
         </permacrud>
 
     </class>
 
+    <class id="uvsbrem" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="url_verify::session_biblio_record_entry_map" oils_persist:readonly="true" reporter:label="URL Verify Session Biblio Record Entry Map">
+        <oils_persist:source_definition>
+            SELECT
+                cbrebi.id AS id,  -- so we can have a pkey in our view
+                uvs.id AS session,
+                uvs.owning_lib,
+                cbrebi.target_biblio_record_entry
+            FROM url_verify.session uvs
+            JOIN container.biblio_record_entry_bucket cbreb
+                ON (uvs.container = cbreb.id)
+            JOIN container.biblio_record_entry_bucket_item cbrebi
+                ON (cbrebi.bucket = cbreb.id)
+        </oils_persist:source_definition>
+        <fields oils_persist:primary="id" oils_persist:sequence="container.biblio_record_entry_bucket_item_id_seq">
+            <field reporter:label="Bucket Item ID" name="id" reporter:datatype="id" />
+            <field reporter:label="Session" name="session" reporter:datatype="link" />
+            <field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit" />
+            <field reporter:label="Target Biblio Record Entry" name="target_biblio_record_entry" reporter:datatype="link" />
+        </fields>
+        <links>
+            <link field="target_biblio_record_entry" reltype="has_a" key="id" map="" class="bre" />
+            <link field="session" reltype="has_a" key="id" map="" class="uvs" />
+            <link field="owning_lib" reltype="has_a" key="id" map="" class="aou" />
+        </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="URL_VERIFY" context_field="owning_lib" />
+            </actions>
+        </permacrud>
+    </class>
+
     <class
         id="uvus"
         controller="open-ils.cstore open-ils.pcrud"
@@ -9396,16 +9424,16 @@ SELECT  usr,
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY">
+                <create permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </create>
-                <retrieve permission="ADMIN_URL_VERIFY">
+                <retrieve permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </retrieve>
-                <update permission="ADMIN_URL_VERIFY">
+                <update permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </update>
-                <delete permission="ADMIN_URL_VERIFY">
+                <delete permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </delete>
             </actions>
@@ -9441,23 +9469,23 @@ SELECT  usr,
 
         <links>
             <link field="redirect_from" reltype="has_a" key="id" map="" class="uvu"/>
-            <link field="item" reltype="has_a" key="id" map="" class="cbrebi"/>
+            <link field="item" reltype="has_a" key="id" map="" class="uvsbrem" /><!-- surprise! -->
             <link field="url_selector" reltype="has_a" key="id" map="" class="uvus"/>
         </links>
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY">
-                    <context link="url_selector session" field="owning_lib"/>
+                <create permission="URL_VERIFY">
+                    <context link="url_selector" jump="session" field="owning_lib"/>
                 </create>
-                <retrieve permission="ADMIN_URL_VERIFY">
-                    <context link="url_selector session" field="owning_lib"/>
+                <retrieve permission="URL_VERIFY">
+                    <context link="url_selector" jump="session" field="owning_lib"/>
                 </retrieve>
-                <update permission="ADMIN_URL_VERIFY">
-                    <context link="url_selector session" field="owning_lib"/>
+                <update permission="URL_VERIFY">
+                    <context link="url_selector" jump="session" field="owning_lib"/>
                 </update>
-                <delete permission="ADMIN_URL_VERIFY">
-                    <context link="url_selector session" field="owning_lib"/>
+                <delete permission="URL_VERIFY">
+                    <context link="url_selector" jump="session" field="owning_lib"/>
                 </delete>
             </actions>
         </permacrud>
@@ -9486,16 +9514,16 @@ SELECT  usr,
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY">
+                <create permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </create>
-                <retrieve permission="ADMIN_URL_VERIFY">
+                <retrieve permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </retrieve>
-                <update permission="ADMIN_URL_VERIFY">
+                <update permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </update>
-                <delete permission="ADMIN_URL_VERIFY">
+                <delete permission="URL_VERIFY">
                     <context link="session" field="owning_lib"/>
                 </delete>
             </actions>
@@ -9529,17 +9557,17 @@ SELECT  usr,
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY">
-                    <context link="attempt session" field="owning_lib"/>
+                <create permission="URL_VERIFY">
+                    <context link="attempt" jump="session" field="owning_lib"/>
                 </create>
-                <retrieve permission="ADMIN_URL_VERIFY">
-                    <context link="attempt session" field="owning_lib"/>
+                <retrieve permission="URL_VERIFY">
+                    <context link="attempt" jump="session" field="owning_lib"/>
                 </retrieve>
-                <update permission="ADMIN_URL_VERIFY">
-                    <context link="attempt session" field="owning_lib"/>
+                <update permission="URL_VERIFY">
+                    <context link="attempt" jump="session" field="owning_lib"/>
                 </update>
-                <delete permission="ADMIN_URL_VERIFY">
-                    <context link="attempt session" field="owning_lib"/>
+                <delete permission="URL_VERIFY">
+                    <context link="attempt" jump="session" field="owning_lib"/>
                 </delete>
             </actions>
         </permacrud>
@@ -9569,10 +9597,10 @@ SELECT  usr,
 
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
-                <create permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <retrieve permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <update permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
-                <delete permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <create permission="URL_VERIFY" context_field="owning_lib"/>
+                <retrieve permission="URL_VERIFY" context_field="owning_lib"/>
+                <update permission="URL_VERIFY" context_field="owning_lib"/>
+                <delete permission="URL_VERIFY" context_field="owning_lib"/>
             </actions>
         </permacrud>
 
diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index be398d6..64527ef 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -700,6 +700,26 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.trigger>
 
+            <open-ils.url_verify>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::URLVerify</implementation>
+                <max_requests>199</max_requests>
+                <unix_config>
+                    <unix_sock>open-ils.url_verify_unix.sock</unix_sock>
+                    <unix_pid>open-ils.url_verify_unix.pid</unix_pid>
+                    <max_requests>1000</max_requests>
+                    <unix_log>open-ils.url_verify_unix.log</unix_log>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                </app_settings>
+            </open-ils.url_verify>
+
             <opensrf.math>
                 <keepalive>3</keepalive>
                 <stateless>1</stateless>
@@ -1264,6 +1284,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.permacrud</appname>  
                 <appname>open-ils.pcrud</appname>  
                 <appname>open-ils.trigger</appname>  
+                <appname>open-ils.url_verify</appname>
                 <appname>open-ils.fielder</appname>  
                 <appname>open-ils.vandelay</appname>  
                 <appname>open-ils.serial</appname>  
diff --git a/Open-ILS/examples/opensrf_core.xml.example b/Open-ILS/examples/opensrf_core.xml.example
index 7bc022f..39ddbf8 100644
--- a/Open-ILS/examples/opensrf_core.xml.example
+++ b/Open-ILS/examples/opensrf_core.xml.example
@@ -34,6 +34,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.resolver</service>
           <service>open-ils.search</service>
           <service>open-ils.supercat</service>
+          <service>open-ils.url_verify</service>
           <service>open-ils.vandelay</service>
           <service>open-ils.serial</service>
         </services>
diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml
index 1be90e9..1c458ad 100644
--- a/Open-ILS/src/extras/ils_events.xml
+++ b/Open-ILS/src/extras/ils_events.xml
@@ -753,6 +753,13 @@
 		<desc xml:lang="en-US">Attempt to suspend a hold after it has been captured.</desc>
 	</event>
 
+	<event code='1900' textcode='URL_VERIFY_NOT_SESSION_CREATOR'>
+		<desc xml:lang="en-US">You did not create this URL Verify session, so you cannot change it.  You may be able to clone it.</desc>
+	</event>
+
+	<event code='1901' textcode='URL_VERIFY_SESSION_ALREADY_SEARCHED'>
+		<desc xml:lang="en-US">This session has already been searched.</desc>
+	</event>
 
 	<event code='2000' textcode='BAD_PARAMS'>
 		<desc xml:lang="en-US">Invalid parameters were encountered in a method</desc>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm
index 8bc8eda..c967247 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm
@@ -20,7 +20,7 @@ $Data::Dumper::Indent = 0;
 sub _fm_link_from_class {
     my ($class, $field) = @_;
 
-    return Fieldmapper->publish_fieldmapper->{$class}{links}{$field};
+    return OpenILS::Application->publish_fieldmapper->{$class}{links}{$field};
 }
 
 sub _flattened_search_single_flesh_wad {
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger.pm
index 2f28416..24488e8 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger.pm
@@ -369,15 +369,15 @@ __PACKAGE__->register_method(
  
 sub _fm_hint_by_class {
     my $class = shift;
-    return Fieldmapper->publish_fieldmapper->{$class}->{hint};
+    return OpenILS::Application->publish_fieldmapper->{$class}->{hint};
 }
 
 sub _fm_class_by_hint {
     my $hint = shift;
 
     my ($class) = grep {
-        Fieldmapper->publish_fieldmapper->{$_}->{hint} eq $hint
-    } keys %{ Fieldmapper->publish_fieldmapper };
+        OpenILS::Application->publish_fieldmapper->{$_}->{hint} eq $hint
+    } keys %{ OpenILS::Application->publish_fieldmapper };
 
     return $class;
 }
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Event.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Event.pm
index 1aca73e..0f52b73 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Event.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Event.pm
@@ -484,8 +484,8 @@ sub _fm_class_by_hint {
     my $hint = shift;
 
     my ($class) = grep {
-        Fieldmapper->publish_fieldmapper->{$_}->{hint} eq $hint
-    } keys %{ Fieldmapper->publish_fieldmapper };
+        OpenILS::Application->publish_fieldmapper->{$_}->{hint} eq $hint
+    } keys %{ OpenILS::Application->publish_fieldmapper };
 
     return $class;
 }
@@ -530,15 +530,15 @@ sub _object_by_path {
 
     my $step = shift(@$path);
 
-    my $fhint = Fieldmapper->publish_fieldmapper->{$context->class_name}{links}{$step}{class};
+    my $fhint = OpenILS::Application->publish_fieldmapper->{$context->class_name}{links}{$step}{class};
     my $fclass = $self->_fm_class_by_hint( $fhint );
 
     OpenSRF::EX::ERROR->throw(
         "$step is not a field on ".$context->class_name."  Please repair the environment.")
         unless $fhint;
 
-    my $ffield = Fieldmapper->publish_fieldmapper->{$context->class_name}{links}{$step}{key};
-    my $rtype = Fieldmapper->publish_fieldmapper->{$context->class_name}{links}{$step}{reltype};
+    my $ffield = OpenILS::Application->publish_fieldmapper->{$context->class_name}{links}{$step}{key};
+    my $rtype = OpenILS::Application->publish_fieldmapper->{$context->class_name}{links}{$step}{reltype};
 
     my $meth = 'retrieve_';
     my $multi = 0;
@@ -572,7 +572,7 @@ sub _object_by_path {
             $obj = $_object_by_path_cache{$def_id}{$str_path}{$step}{$ffield}{$lval} ||
                 (
                     (grep /cstore/, @{
-                        Fieldmapper->publish_fieldmapper->{$fclass}{controller}
+                        OpenILS::Application->publish_fieldmapper->{$fclass}{controller}
                     }) ? $ed : ($red ||= new_rstore_editor(xact=>1))
                 )->$meth( ($multi) ? { $ffield => $lval } : $lval);
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
index 4e86dcd..86d95c3 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -1,4 +1,7 @@
 package OpenILS::Application::URLVerify;
+
+# For code searchability, I'm telling you this is the "link checker."
+
 use base qw/OpenILS::Application/;
 use strict; use warnings;
 use OpenSRF::Utils::Logger qw(:logger);
@@ -8,12 +11,16 @@ use OpenILS::Utils::CStoreEditor q/:funcs/;
 use OpenILS::Application::AppUtils;
 use LWP::UserAgent;
 
+use Data::Dumper;
+
+$Data::Dumper::Indent = 0;
+
 my $U = 'OpenILS::Application::AppUtils';
 
 
 __PACKAGE__->register_method(
-    method => 'validate_session',
-    api_name => 'open-ils.url_verify.session.validate',
+    method => 'verify_session',
+    api_name => 'open-ils.url_verify.session.verify',
     stream => 1,
     signature => {
         desc => q/
@@ -22,7 +29,7 @@ __PACKAGE__->register_method(
         params => [
             {desc => 'Authentication token', type => 'string'},
             {desc => 'Session ID (url_verify.session.id)', type => 'number'},
-            {desc => 'URL ID list (optional).  An empty list will result in no URLs being processed', type => 'array'},
+            {desc => 'URL ID list (optional).  An empty list will result in no URLs being processed, but null will result in all the URLs for the session being processed', type => 'array'},
             {
                 desc => q/
                     Options (optional).
@@ -51,13 +58,16 @@ __PACKAGE__->register_method(
     }
 );
 
-sub validate_session {
+# "verify_session" sounds like something to do with authentication, but it
+# actually means for a given session, verify all the URLs associated with
+# that session.
+sub verify_session {
     my ($self, $client, $auth, $session_id, $url_ids, $options) = @_;
     $options ||= {};
 
     my $e = new_editor(authtoken => $auth, xact => 1);
     return $e->die_event unless $e->checkauth;
-    return $e->die_event unless $e->allowed('VERIFY_URL');
+    return $e->die_event unless $e->allowed('URL_VERIFY');
 
     my $session = $e->retrieve_url_verify_session($session_id)
         or return $e->die_event;
@@ -142,6 +152,7 @@ sub validate_session {
         $e->create_url_verify_verification_attempt($attempt)
             or return $e->die_event;
 
+        $attempt = $e->data;
         $e->commit;
     }
 
@@ -153,7 +164,8 @@ sub validate_session {
         $session->owning_lib,
         'url_verify.verification_batch_size', $e) || 5;
 
-    my $num_processed = 0; # total number processed, including redirects
+    my $total_excluding_redirects = 0;
+    my $total_processed = 0; # total number processed, including redirects
     my $resp_window = 1;
 
     # before we start the real work, let the caller know
@@ -161,7 +173,8 @@ sub validate_session {
 
     $client->respond({
         url_count => $url_count,
-        total_processed => $num_processed,
+        total_processed => $total_processed,
+        total_excluding_redirects => $total_excluding_redirects,
         attempt => $attempt
     });
 
@@ -181,19 +194,20 @@ sub validate_session {
 
                 if ($content) {
 
-                    $num_processed++;
+                    $total_processed++;
 
-                    if ($options->{report_all} or ($num_processed % $resp_window == 0)) {
+                    if ($options->{report_all} or ($total_processed % $resp_window == 0)) {
                         $client->respond({
                             url_count => $url_count,
                             current_verification => $content,
-                            total_processed => $num_processed
+                            total_excluding_redirects => $total_excluding_redirects,
+                            total_processed => $total_processed
                         });
                     }
 
                     # start off responding quickly, then throttle
                     # back to only relaying every 256 messages.
-                    $resp_window *= 2 unless $resp_window == 256;
+                    $resp_window *= 2 unless $resp_window >= 256;
                 }
             }
         },
@@ -206,7 +220,9 @@ sub validate_session {
         }
     );
 
-    sort_and_fire_domains($e, $auth, $attempt, $url_ids, $multises);
+    sort_and_fire_domains(
+        $e, $auth, $attempt, $url_ids, $multises, \$total_excluding_redirects
+    );
 
     # Wait for all requests to be completed
     $multises->session_wait(1);
@@ -215,24 +231,36 @@ sub validate_session {
     $attempt->finish_time('now');
 
     $e->xact_begin;
-    $e->update_url_verify_verification_attempt($attempt) or return $e->die_event;
+    $e->update_url_verify_verification_attempt($attempt) or
+        return $e->die_event;
+
     $e->xact_commit;
 
+    # This way the caller gets an actual timestamp in the "finish_time" field
+    # instead of the string "now".
+    $attempt = $e->retrieve_url_verify_verification_attempt($e->data) or
+        return $e->die_event;
+
+    $e->disconnect;
+
     return {
         url_count => $url_count,
-        total_processed => $num_processed,
+        total_processed => $total_processed,
+        total_excluding_redirects => $total_excluding_redirects,
         attempt => $attempt
     };
 }
 
-# retrieves the URL domains and sorts them into buckets
+# retrieves the URL domains and sorts them into buckets*
 # Iterates over the buckets and fires the multi-session call
 # the main drawback to this domain sorting approach is that
 # any domain used a lot more than the others will be the
 # only domain standing after the others are exhausted, which
 # means it will take a beating at the end of the batch.
+#
+# * local data structures, not container.* buckets
 sub sort_and_fire_domains {
-    my ($e, $auth, $attempt, $url_ids, $multises) = @_;
+    my ($e, $auth, $attempt, $url_ids, $multises, $count) = @_;
 
     # there is potential here for data sets to be too large
     # for delivery, but it's not likely, since we're only
@@ -263,11 +291,16 @@ sub sort_and_fire_domains {
             $multises->request(
                 'open-ils.url_verify.verify_url',
                 $auth, $attempt->id, $url_id);
+            
+            $$count++;  # sic, a reference to a scalar
         }
     }
 }
 
 
+# XXX I really want to move this method to open-ils.storage, so we don't have
+# to authenticate a zillion times. LFW
+
 __PACKAGE__->register_method(
     method => 'verify_url',
     api_name => 'open-ils.url_verify.verify_url',
@@ -316,7 +349,7 @@ sub verify_url {
         collect_verify_attempt_and_settings($e, $attempt_id);
 
     return $e->event unless $e->allowed(
-        'VERIFY_URL', $attempt->session->owning_lib);
+        'URL_VERIFY', $attempt->session->owning_lib);
 
     my $cur_url = $url;
     my $loop_detected = 0;
@@ -584,4 +617,233 @@ sub verify_one_url {
 }
 
 
+__PACKAGE__->register_method(
+    method => "create_session",
+    api_name => "open-ils.url_verify.session.create",
+    signature => {
+        desc => q/Create a URL verify session. Also automatically create and
+            link a container./,
+        params => [
+            {desc => "Authentication token", type => "string"},
+            {desc => "session name", type => "string"},
+            {desc => "QueryParser search", type => "string"},
+            {desc => "owning_lib (defaults to ws_ou)", type => "number"},
+        ],
+        return => {desc => "ID of new session or event on error", type => "number"}
+    }
+);
+
+sub create_session {
+    my ($self, $client, $auth, $name, $search, $owning_lib) = @_;
+
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+
+    $owning_lib ||= $e->requestor->ws_ou;
+    return $e->die_event unless $e->allowed("URL_VERIFY", $owning_lib);
+
+    my $session = Fieldmapper::url_verify::session->new;
+    $session->name($name);
+    $session->owning_lib($owning_lib);
+    $session->creator($e->requestor->id);
+    $session->search($search);
+
+    my $container = Fieldmapper::container::biblio_record_entry_bucket->new;
+    $container->btype("url_verify");
+    $container->owner($e->requestor->id);
+    $container->name($name);
+    $container->description("Automatically generated");
+
+    $e->create_container_biblio_record_entry_bucket($container) or
+        return $e->die_event;
+
+    $session->container($e->data->id);
+    $e->create_url_verify_session($session) or
+        return $e->die_event;
+
+    $e->commit or return $e->die_event;
+
+    return $e->data->id;
+}
+
+# _check_for_existing_bucket_items() is used later by session_search_and_extract()
+sub _check_for_existing_bucket_items {
+    my ($e, $session) = @_;
+
+    my $items = $e->json_query(
+        {
+            select => {cbrebi => ['id']},
+            from => {cbrebi => {}},
+            where => {bucket => $session->container},
+            limit => 1
+        }
+    ) or return $e->die_event;
+
+    return new OpenILS::Event("URL_VERIFY_SESSION_ALREADY_SEARCHED") if @$items;
+
+    return;
+}
+
+# _get_all_search_results() is used later by session_search_and_extract()
+sub _get_all_search_results {
+    my ($client, $session) = @_;
+
+    my @result_ids;
+
+    # Don't loop if the user has specified their own offset.
+    if ($session->search =~ /offset\(\d+\)/) {
+        my $res = $U->simplereq(
+            "open-ils.search",
+            "open-ils.search.biblio.multiclass.query.staff",
+            {}, $session->search
+        );
+
+        return new OpenILS::Event("UNKNOWN") unless $res;
+        return $res if $U->is_event($res);
+
+        @result_ids = map { shift @$_ } @{$res->{ids}}; # IDs nested in array
+    } else {
+        my $count;
+        my $so_far = 0;
+
+        LOOP: { do {    # Fun fact: you cannot "last" out of a do/while in Perl
+                        # unless you wrap it another loop structure.
+            my $search = $session->search . " offset(".scalar(@result_ids).")";
+
+            my $res = $U->simplereq(
+                "open-ils.search",
+                "open-ils.search.biblio.multiclass.query.staff",
+                {}, $search
+            );
+
+            return new OpenILS::Event("UNKNOWN") unless $res;
+            return $res if $U->is_event($res);
+
+            # Search only returns the total count when offset is 0.
+            # We can't get more than one superpage this way, XXX TODO ?
+            $count = $res->{count} unless defined $count;
+
+            my @this_batch = map { shift @$_ } @{$res->{ids}}; # unnest IDs
+            push @result_ids, @this_batch;
+
+            # Send a keepalive in case search is slow, although it'll probably
+            # be the query for the first ten results that's slowest.
+            $client->status(new OpenSRF::DomainObject::oilsContinueStatus);
+
+            last unless @this_batch; # Protect against getting fewer results
+                                     # than count promised.
+
+        } while ($count - scalar(@result_ids) > 0); }
+    }
+
+    return (undef, @result_ids);
+}
+
+
+__PACKAGE__->register_method(
+    method => "session_search_and_extract",
+    api_name => "open-ils.url_verify.session.search_and_extract",
+    stream => 1,
+    signature => {
+        desc => q/
+            Perform the search contained in the session,
+            populating the linked bucket, and extracting URLs /,
+        params => [
+            {desc => "Authentication token", type => "string"},
+            {desc => "url_verify.session id", type => "number"},
+        ],
+        return => {
+            desc => q/stream of numbers: first number of search results, then
+                numbers of extracted URLs for each record, grouped into arrays
+                of 100/,
+            type => "number"
+        }
+    }
+);
+
+sub session_search_and_extract {
+    my ($self, $client, $auth, $ses_id) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->die_event unless $e->checkauth;
+
+    my $session = $e->retrieve_url_verify_session(int($ses_id));
+
+    return $e->die_event unless
+        $session and $e->allowed("URL_VERIFY", $session->owning_lib);
+
+    if ($session->creator != $e->requestor->id) {
+        $e->disconnect;
+        return new OpenILS::Event("URL_VERIFY_NOT_SESSION_CREATOR");
+    }
+
+    my $delete_error =
+        _check_for_existing_bucket_items($e, $session);
+
+    if ($delete_error) {
+        $e->disconnect;
+        return $delete_error;
+    }
+
+    my ($search_error, @result_ids) =
+        _get_all_search_results($client, $session);
+
+    if ($search_error) {
+        $e->disconnect;
+        return $search_error;
+    }
+
+    $e->xact_begin;
+
+    # Make and save a bucket item for each search result.
+
+    my $pos = 0;
+    my @item_ids;
+
+    # There's an opportunity below to parallelize the extraction of URLs if
+    # we need to.
+
+    foreach my $bre_id (@result_ids) {
+        my $bucket_item =
+            Fieldmapper::container::biblio_record_entry_bucket_item->new;
+
+        $bucket_item->bucket($session->container);
+        $bucket_item->target_biblio_record_entry($bre_id);
+        $bucket_item->pos($pos++);
+
+        $e->create_container_biblio_record_entry_bucket_item($bucket_item) or
+            return $e->die_event;
+
+        push @item_ids, $e->data->id;
+    }
+
+    $e->xact_commit;
+
+    $client->respond($pos); # first response: the number of items created
+                            # (number of search results)
+
+    # For each contain item, extract URLs.  Report counts of URLs extracted
+    # from each record in batches at every hundred records.  XXX Arbitrary.
+
+    my @url_counts;
+    foreach my $item_id (@item_ids) {
+        my $res = $e->json_query({
+            from => ["url_verify.extract_urls", $ses_id, $item_id]
+        }) or return $e->die_event;
+
+        push @url_counts, $res->[0]{"url_verify.extract_urls"};
+
+        if (scalar(@url_counts) % 100 == 0) {
+            $client->respond([ @url_counts ]);
+            @url_counts = ();
+        }
+    }
+
+    $client->respond([ @url_counts ]) if @url_counts;
+
+    $e->disconnect;
+    return;
+}
+
+
 1;
diff --git a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
index 89478ad..a49e5fc 100644
--- a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
@@ -74,19 +74,19 @@ BEGIN
     FOR current_selector IN SELECT * FROM url_verify.url_selector s WHERE s.session = session_id LOOP
         current_url_pos := 1;
         LOOP
-            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc))[current_url_pos]::TEXT INTO current_url
+            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc::XML))[current_url_pos]::TEXT INTO current_url
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
             EXIT WHEN current_url IS NULL;
 
-            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc))[current_url_pos]::TEXT INTO current_tag
+            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc::XML))[current_url_pos]::TEXT INTO current_tag
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
 
-            SELECT  (XPATH(current_selector.xpath || '/@subfield', b.marc))[current_url_pos]::TEXT INTO current_sf
+            SELECT  (XPATH(current_selector.xpath || '/@code', b.marc::XML))[current_url_pos]::TEXT INTO current_sf
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
index 2990382..f8a5bad 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -58,23 +58,23 @@ BEGIN
     FOR current_selector IN SELECT * FROM url_verify.url_selector s WHERE s.session = session_id LOOP
         current_url_pos := 1;
         LOOP
-            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc))[current_url_pos]::TEXT INTO current_url
+            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc::XML))[current_url_pos]::TEXT INTO current_url
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
-    
+
             EXIT WHEN current_url IS NULL;
-    
-            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc))[current_url_pos]::TEXT INTO current_tag
+
+            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc::XML))[current_url_pos]::TEXT INTO current_tag
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
-    
-            SELECT  (XPATH(current_selector.xpath || '/@subfield', b.marc))[current_url_pos]::TEXT INTO current_sf
+
+            SELECT  (XPATH(current_selector.xpath || '/@code', b.marc::XML))[current_url_pos]::TEXT INTO current_sf
               FROM  biblio.record_entry b
                     JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
               WHERE c.id = item_id;
-    
+
             INSERT INTO url_verify.url (item, url_selector, tag, subfield, ord, full_url)
               VALUES ( item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
 
diff --git a/Open-ILS/src/templates/url_verify/create_session.tt2 b/Open-ILS/src/templates/url_verify/create_session.tt2
new file mode 100644
index 0000000..d29ccd9
--- /dev/null
+++ b/Open-ILS/src/templates/url_verify/create_session.tt2
@@ -0,0 +1,130 @@
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = "Link Checker - Create Session" %]
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("dijit.form.CheckBox");
+    dojo.require("dijit.form.TextBox");
+    dojo.require("openils.Util");
+    dojo.require("openils.widget.ProgressDialog");
+    dojo.require("openils.URLVerify.CreateSession");
+
+    var module;
+
+    openils.Util.addOnLoad(
+        function() {
+            module = openils.URLVerify.CreateSession;
+            module.setup("saved-searches", "org-selector", progress_dialog);
+        }
+    );
+</script>
+<style type="text/css">
+    #uv-search { width: 20em; }
+    .note { font-style: italic; background-color: #eee; }
+    #uv-tags-and-subfields { background-color: #ddd; }
+    table.create-session-form th { text-align: right; padding-right: 1em; }
+    table.create-session-form {
+        border-collapse: separate;
+        border-spacing: 0.5ex;
+    }
+</style>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+         layoutAlign="top" class="oils-header-panel">
+        <div> [% ctx.page_title %] </div>
+        <div> <!-- buttons could go here --></div>
+    </div>
+    <div>
+        <table class="create-session-form">
+            <tr>
+                <th>
+                    <label for="uv-session-name">[% l("Sesssion name:") %]</label>
+                </th>
+                <td>
+                    <input dojoType="dijit.form.TextBox"
+                        id="uv-session-name" jsId="uv_session_name" />
+                </td>
+                <td class="note">
+                </td>
+            </tr>
+
+            <tr>
+                <th>
+                    <label for="org-selector">[% l('Search scope:') %]</label>
+                </th>
+                <td>
+                    <div id="org-selector"></div>
+                </td>
+                <td class="note">
+                    [% l("This will only be used if your search doesn't contain a hand-entered filter such as site(BR1)") %]
+                </td>
+            </tr>
+
+            <!-- XXX TODO I bet we want a depth selector here too -->
+
+            <tr>
+                <th>
+                    <label for="uv-search">[% l('Search:') %]</label>
+                </th>
+                <td>
+                    <input dojoType="dijit.form.TextBox" id="uv-search"
+                        jsId="uv_search" />
+                </td>
+                <td class="note">
+                </td>
+            </tr>
+
+            <tr>
+                <th>
+                    <label for="saved-searches">[% l("Saved searches:") %]</label>
+                </th>
+                <td><!-- XXX we're just assuming this list won't grow so
+                    large as to be unrepresentable in a multiselect?  We
+                    could switch to a PCrudAutocompleteBox if needed for
+                    constant load time regardless of dataset size. -->
+                    <select id="saved-searches" multiple="true" size="6"></select>
+                </td>
+                <td class="note">[% l("Optionally select one or more to combine with 'Search' field above.") %]
+                </td>
+            </tr>
+
+            <tr>
+                <th>
+                    <label for="no-url-selection">[% l('Process immediately?') %]</label>
+                </th>
+                <td>
+                    <input dojoType="dijit.form.CheckBox" id="no-url-selection"
+                        jsId="no_url_selection" />
+                </td>
+                <td class="note">
+                </td>
+            </tr>
+
+            <tr>
+                <th>
+                    [% l('Tags and subfields possibly containing URLs:') %]
+                </th>
+                <td>
+                    <div id="uv-tags-and-subfields">
+                    </div>
+                    <div class="tag-and-subfield-add-another">
+                        [% l("Tag") %]
+                        <input type="text" size="4" maxlength="3" />
+                        [% l("Subfield(s)") %]
+                        <input type="text" size="15" />
+                        <a href="javascript:module.tag_and_subfields.add();">[% l('Add') %]</a>
+                    </div>
+                </td>
+                <td class="note">
+                </td>
+            </tr>
+        </table>
+
+        <div>
+            <button dojoType="dijit.form.Button"
+                onClick="module.begin();">[% l("Begin") %]</button>
+
+        </div>
+    </div>
+</div>
+<div dojoType="openils.widget.ProgressDialog" jsId="progress_dialog"></div>
+[% END %]
diff --git a/Open-ILS/src/templates/url_verify/select_urls.tt2 b/Open-ILS/src/templates/url_verify/select_urls.tt2
new file mode 100644
index 0000000..b3985d9
--- /dev/null
+++ b/Open-ILS/src/templates/url_verify/select_urls.tt2
@@ -0,0 +1,66 @@
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = "Link Checker - Select URLs" %]
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("openils.widget.FlattenerGrid");
+    dojo.require("openils.widget.ProgressDialog");
+    dojo.require("openils.Util");
+    dojo.require("openils.CGI");
+    dojo.require("openils.URLVerify.SelectURLs");
+
+    /* Minimize namespace pollution, but save us some typing later. */
+    var module = openils.URLVerify.SelectURLs;
+
+    openils.Util.addOnLoad(
+        function() {
+            module.setup(grid, progress_dialog);
+        }
+    );
+</script>
+<style type="text/css">
+    .url-verify-attempt-info { font-style: italic; }
+</style>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+         layoutAlign="top" class="oils-header-panel">
+        <div>[% ctx.page_title %]</div>
+        <div>
+            <button dojoType="dijit.form.Button"
+                onClick="module.verify_selected();">[%
+                l("Verify Selected URLs")
+            %]</button>
+        </div>
+    </div>
+    <div class="oils-acq-basic-roomy url-verify-attempt-info">
+        <div id="url-verify-attempt-id"></div>
+        <div id="url-verify-attempt-start"></div>
+        <div id="url-verify-attempt-finish"></div>
+    </div>
+    <table
+        jsid="grid"
+        dojoType="openils.widget.FlattenerGrid"
+        columnPersistKey='"url_verify.select_url"'
+        autoHeight="10"
+        editOnEnter="false"
+        autoFieldFields="null"
+        autoCoreFields="true"
+        autoCoreFieldsUnsorted="true"
+        fetchLock="true"
+        mapExtras="{session_id: {path: 'item.session.id', filter: true}}"
+        showLoadFilter="true"
+        fmClass="'uvu'">
+        <thead>
+            <tr>
+                <th field="title" fpath="item.target_biblio_record_entry.simple_record.title"></th>
+                <th field="author" fpath="item.target_biblio_record_entry.simple_record.author"></th>
+                <th field="isbn" fpath="item.target_biblio_record_entry.simple_record.isbn" _visible="false"></th>
+                <th field="issn" fpath="item.target_biblio_record_entry.simple_record.issn" _visible="false"></th>
+                <th field="bib_id" fpath="item.target_biblio_record_entry.id" _visible="false"></th>
+            </tr>
+        </thead>
+    </table>
+</div>
+<div class="hidden">
+    <div dojoType="openils.widget.ProgressDialog" jsId="progress_dialog"></div>
+</div>
+[% END %]
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
new file mode 100644
index 0000000..cb79e82
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
@@ -0,0 +1,352 @@
+if (!dojo._hasResource["openils.URLVerify.CreateSession"]) {
+    dojo.require("dojo.data.ItemFileWriteStore");
+    dojo.require("dojox.jsonPath");
+    dojo.require("fieldmapper.OrgUtils");
+    dojo.require("openils.Util");
+    dojo.require("openils.PermaCrud");
+    dojo.require("openils.widget.FilteringTreeSelect");
+
+    dojo.requireLocalization("openils.URLVerify", "URLVerify");
+
+    dojo._hasResource["openils.URLVerify.CreateSession"] = true;
+    dojo.provide("openils.URLVerify.CreateSession");
+
+    dojo.declare("openils.URLVerify.CreateSession", null, {});
+
+    /* Take care that we add nothing to the global namespace. */
+
+(function() {
+    var module = openils.URLVerify.CreateSession;
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
+    var uvus_progress = 0;
+
+    /* Take search text box input, selected saved search ids, and selected
+     * scope shortname to produce one search string. */
+    module._prepare_search = function(basic, saved, scope) {
+        if (saved.length) {
+            basic += " " + dojo.map(
+                saved, function(s) { return "saved_query(" + s + ")"; }
+            ).join(" ");
+        }
+
+        if (scope && !basic.match(/site\(.+\)/))
+            basic += " site(" + scope + ")";
+
+        return basic;
+    };
+
+    /* Reacting to the interface's "Begin" button, this function triggers the
+     * first of three server-side processes necessary to create a session:
+     *
+     * 1) create the session itself (API call), */
+    module.begin = function() {
+        var name = uv_session_name.attr("value");
+
+        var scope;
+        try {
+            scope = module.org_selector.store.getValue(
+                module.org_selector.item,
+                "shortname"
+            );
+        } catch (E) {
+            /* probably nothing valid is selected; move on */
+            void(0);
+        }
+
+        var search = module._prepare_search(
+            uv_search.attr("value"),
+            dojo.filter(
+                dojo.byId("saved-searches").options,
+                function(o) { return o.selected; }
+            ).map(
+                function(o) { return o.value; }
+            ),
+            scope
+        );
+
+        if (!module.tag_and_subfields.any()) {
+            alert(localeStrings.NEED_UVUS);
+            return;
+        }
+
+        module.progress_dialog.attr("title", localeStrings.CREATING);
+        module.progress_dialog.show(true);
+        fieldmapper.standardRequest(
+            ["open-ils.url_verify", "open-ils.url_verify.session.create"], {
+                "params": [openils.User.authtoken, name, search],
+                "async": true,
+                "onresponse": function(r) {
+                    if (r = openils.Util.readResponse(r)) {
+                        /* I think we're modal enough to get away with this. */
+                        module.session_id = r;
+                        module.save_tags();
+                    }
+                }
+            }
+        );
+    };
+
+    /* 2) save the tag/subfield sets for URL extraction, */
+    module.save_tags = function() {
+        module.progress_dialog.attr("title", localeStrings.SAVING_TAGS);
+        module.progress_dialog.show(); /* sic */
+
+        uvus_progress = 0;
+
+        /* Note we're not using openils.PermaCrud, which is inadequate
+         * when you want transactions. Thanks for figuring it out, Bill. */
+        var pcrud_raw = new OpenSRF.ClientSession("open-ils.pcrud");
+
+        pcrud_raw.connect();
+
+        pcrud_raw.request({
+            "method": "open-ils.pcrud.transaction.begin",
+            "params": [openils.User.authtoken],
+            "oncomplete": function(r) {
+                module._create_uvus_one_at_a_time(
+                    pcrud_raw,
+                    module.tag_and_subfields.generate_uvus(
+                        module.session_id
+                    )
+                );
+            }
+        }).send();
+    };
+
+    /* 2b */
+    module._create_uvus_one_at_a_time = function(pcrud_raw, uvus_list) {
+        pcrud_raw.request({
+            "method": "open-ils.pcrud.create.uvus",
+            "params": [openils.User.authtoken, uvus_list[0]],
+            "oncomplete": function(r) {
+                var new_uvus = openils.Util.readResponse(r);
+                module.progress_dialog.update(
+                    {"maximum": uvus_list.length, "progress": ++uvus_progress}
+                );
+
+                uvus_list.shift();  /* /now/ actually shorten the list */
+
+                if (uvus_list.length < 1) {
+                    pcrud_raw.request({
+                        "method": "open-ils.pcrud.transaction.commit",
+                        "params": [openils.User.authtoken],
+                        "oncomplete": function(r) {
+                            pcrud_raw.disconnect();
+                            module.perform_search();
+                        }
+                    }).send();
+
+                } else {
+                    module._create_uvus_one_at_a_time(
+                        pcrud_raw, uvus_list
+                    );
+                }
+             }
+        }).send();
+    };
+
+    /* 3) search and populate the container (API call). */
+    var search_result_count = 0;
+    module.perform_search = function() {
+        module.progress_dialog.attr("title", localeStrings.PERFORMING_SEARCH);
+        module.progress_dialog.show(true);
+
+        fieldmapper.standardRequest(
+            ["open-ils.url_verify",
+                "open-ils.url_verify.session.search_and_extract"], {
+                "params": [openils.User.authtoken, module.session_id],
+                "async": true,
+                "onresponse": function(r) {
+                    r = openils.Util.readResponse(r);
+                    if (!search_result_count) {
+                        search_result_count = Number(r);
+
+                        module.progress_dialog.show(); /* sic */
+                        module.progress_dialog.attr(
+                            "title", localeStrings.EXTRACTING_URLS
+                        );
+                        module.progress_dialog.update(
+                            {"maximum": search_result_count, "progress": 0}
+                        );
+                    } else {
+                        module.progress_dialog.update({"progress": r.length})
+                    }
+                },
+                "oncomplete": function() {
+                    module.progress_dialog.attr(
+                        "title", localeStrings.REDIRECTING
+                    );
+                    module.progress_dialog.show(true);
+
+                    if (no_url_selection.checked) {
+                        location.href = oilsBasePath +
+                            "/url_verify/validation_review?" +
+                            "session_id=" + module.session_id +
+                            "&validate=1";
+                    } else {
+                        location.href = oilsBasePath +
+                            "/url_verify/select_urls?session_id=" +
+                            module.session_id;
+                    }
+                }
+            }
+        );
+    };
+
+    /* At least in Dojo 1.3.3 (I know, I know), dijit.form.MultiSelect does
+     * not behave like FilteringSelect, like you might expect, or work from a
+     * data store.  So we'll use a native <select> control, which will have
+     * fewer moving parts that can go haywire anyway.
+     */
+    module._populate_saved_searches = function(node) {
+        var pcrud = new openils.PermaCrud();
+        var list = pcrud.retrieveAll(
+            "asq", {"order_by": {"asq": "label"}}
+        );
+
+        dojo.forEach(
+            list,
+            function(o) {
+                dojo.create(
+                    "option", {
+                        "innerHTML": o.label(),
+                        "value": o.id(),
+                        "title": o.query_text()
+                    }, node, "last"
+                );
+            }
+        );
+
+        pcrud.disconnect();
+    };
+
+    /* set up an all-org-units-in-the-tree selector */
+    module._prepare_org_selector = function(node) {
+        var widget = new openils.widget.FilteringTreeSelect(null, node);
+        widget.searchAttr = "name";
+        widget.labelAttr = "name";
+        widget.tree = fieldmapper.aou.globalOrgTree;
+        widget.parentField = 'parent_ou';
+        widget.startup();
+        widget.attr("value", openils.User.user.ws_ou());
+
+        module.org_selector = widget;
+    };
+
+    module.setup = function(saved_search_id, org_selector_id, progress_dialog) {
+        module.progress_dialog = progress_dialog;
+
+        module.progress_dialog.attr("title", localeStrings.INTERFACE_SETUP);
+        module.progress_dialog.show(true);
+
+        module._populate_saved_searches(dojo.byId(saved_search_id));
+        module._prepare_org_selector(dojo.byId(org_selector_id));
+
+        module.progress_dialog.hide();
+    };
+
+    /* This is the thing that lets you add/remove rows of tab/subfield pairs */
+    function TagAndSubfieldsMgr(container_id) {
+        var self = this;
+
+        this.container_id = container_id;
+        this.counter = 0;
+
+        this.read_new = function() {
+            var controls = dojo.query(".tag-and-subfield-add-another input");
+
+            return {
+                "tag": controls[0].value,
+                "subfields": openils.Util.uniqueElements(
+                    controls[1].value.replace(/[^0-9a-z]/g, "").split("")
+                ).sort().join("")
+            };
+        };
+
+        this.add = function() {
+            var newdata = this.read_new();
+            var newid = "t-and-s-row-" + String(this.counter++);
+            var div = dojo.create(
+                "div", {
+                    "id": newid,
+                    "innerHTML": "<span class='t-and-s-tag'>" +
+                        newdata.tag +
+                        "</span> \u2021<span class='t-and-s-subfields'>" +
+                        newdata.subfields + "</span> "
+                }, this.container_id, "last"
+            );
+            dojo.create(
+                "a", {
+                    "href": "javascript:void(0);",
+                    "onclick": function() {
+                        var me = dojo.byId(newid);
+                        me.parentNode.removeChild(me);
+                    },
+                    "innerHTML": "[X]" /* XXX i18n */
+                }, div, "last"
+            );
+
+            this.counter++;
+        };
+
+        /* return a boolean indicating whether or not we have any rows */
+        this.any = function() {
+            return Boolean(
+                dojo.query(
+                    '[id^="t-and-s-row-"]', dojo.byId(this.container_id)
+                ).length
+            );
+        };
+
+        /* Return one uvus object for each unique tag we have a row for,
+         * and use the given session_id for the uvus.session field. */
+        this.generate_uvus = function(session_id) {
+            var uniquely_grouped = {};
+            dojo.query(
+                '[id^="t-and-s-row-"]', dojo.byId(this.container_id)
+            ).forEach(
+                function(row) {
+                    var tag = dojo.query(".t-and-s-tag", row)[0].innerHTML;
+                    var subfield = dojo.query(".t-and-s-subfields", row)[0].innerHTML;
+
+                    var existing;
+                    if ((existing = uniquely_grouped[tag])) { /* sic, assignment */
+                        existing = openils.Util.uniqueElements(
+                            (existing + subfield).split("")
+                        ).sort().join("");
+                    } else {
+                        uniquely_grouped[tag] = subfield;
+                    }
+                }
+            );
+
+            var uvus_list = [];
+            for (var tag in uniquely_grouped) {
+                var obj = new uvus();
+
+                obj.session(session_id);
+
+                /* XXX TODO handle control fields (no subfields) */
+                obj.xpath(
+                    "//*[@tag='" + tag + "']/*[" +
+                    uniquely_grouped[tag].split("").map(
+                        function(c) { return "@code='" + c + "'"; }
+                    ).join(" or ") +
+                    "]"
+                );
+
+                uvus_list.push(obj);
+            }
+
+            return uvus_list;
+        };
+
+    }
+
+    module.tag_and_subfields =
+        new TagAndSubfieldsMgr("uv-tags-and-subfields");
+
+}());
+
+}
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js b/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
new file mode 100644
index 0000000..b8992ac
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
@@ -0,0 +1,106 @@
+if (!dojo._hasResource["openils.URLVerify.SelectURLs"]) {
+    dojo.require("dojo.string");
+    dojo.require("openils.CGI");
+    dojo.require("openils.Util");
+
+    dojo.requireLocalization("openils.URLVerify", "URLVerify");
+
+    dojo._hasResource["openils.URLVerify.SelectURLs"] = true;
+    dojo.provide("openils.URLVerify.SelectURLs");
+
+    dojo.declare("openils.URLVerify.SelectURLs", null, {});
+
+    /* Take care that we add nothing to the global namespace.
+     * This is not an OO module so much as a container for
+     * functions needed by a specific interface. */
+
+(function() {
+    var module = openils.URLVerify.SelectURLs;
+    var localeStrings =
+        dojo.i18n.getLocalization("openils.URLVerify", "URLVerify");
+
+    module.setup = function(grid, progress_dialog) {
+        var cgi = new openils.CGI();
+        module.session_id = cgi.param("session_id");
+
+        module.grid = grid;
+
+        module.grid.attr("query", {"session_id": module.session_id});
+        module.grid.refresh();
+        // Alternative to grid.refresh() once filter is set up
+        //module.grid.fetchLock = false;
+        //module.grid.filterUi.doApply();
+    };
+
+    module.verify_selected = function() {
+        var really_everything = false;
+
+        if (module.grid.everythingSeemsSelected())
+            really_everything = confirm(localeStrings.VERIFY_ALL);
+
+        module.clear_attempt_display();
+        progress_dialog.attr("title", localeStrings.VERIFICATION_BEGIN);
+        progress_dialog.show();
+
+        fieldmapper.standardRequest(
+            ["open-ils.url_verify", "open-ils.url_verify.session.verify"], {
+                "params": [
+                    openils.User.authtoken,
+                    module.session_id,
+                    really_everything ? null : module.grid.getSelectedIDs()
+                ],
+                "async": true,
+                "onresponse": function(r) {
+                    if (r = openils.Util.readResponse(r)) {
+                        progress_dialog.attr(
+                            "title",
+                            dojo.string.substitute(
+                                localeStrings.VERIFICATION_PROGRESS,
+                                [r.total_processed]
+                            )
+                        );
+                        progress_dialog.update({
+                            "maximum": r.url_count,
+                            "progress": r.total_excluding_redirects
+                        });
+
+                        if (r.attempt)
+                            module.update_attempt_display(r.attempt);
+                    }
+                }
+            }
+        )
+
+        module.grid.getSelectedIDs();   
+    };
+
+    module.clear_attempt_display = function() {
+        dojo.empty(dojo.byId("url-verify-attempt-id"));
+        dojo.empty(dojo.byId("url-verify-attempt-start"));
+        dojo.empty(dojo.byId("url-verify-attempt-finish"));
+    };
+
+    module.update_attempt_display = function(attempt) {
+        dojo.byId("url-verify-attempt-id").innerHTML =
+            dojo.string.substitute(
+                localeStrings.VERIFICATION_ATTEMPT_ID,
+                [attempt.id()]
+            );
+        dojo.byId("url-verify-attempt-start").innerHTML =
+            dojo.string.substitute(
+                localeStrings.VERIFICATION_ATTEMPT_START,
+                [attempt.start_time()]
+            );
+
+        if (attempt.finish_time()) {
+            dojo.byId("url-verify-attempt-finish").innerHTML =
+                dojo.string.substitute(
+                    localeStrings.VERIFICATION_ATTEMPT_FINISH,
+                    [attempt.finish_time()]
+                );
+        }
+    };
+
+}());
+
+}
diff --git a/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
new file mode 100644
index 0000000..d0b6f3d
--- /dev/null
+++ b/Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
@@ -0,0 +1,15 @@
+{
+    "CREATING": "Creating session ...",
+    "SAVING_TAGS": "Saving tag/subfield selectors ...",
+    "PERFORMING_SEARCH": "Performing search ...",
+    "EXTRACTING_URLS": "Extracting URLs ...",
+    "NEED_UVUS": "You must add some tag and subfield combinations",
+    "REDIRECTING": "Loading next interface ...",
+    "INTERFACE_SETUP": "Setting up interface ...",
+    "VERIFY_ALL": "Click 'OK' to verify ALL the URLs found in this session.  Click 'Cancel' if the selected, visible URLs are the only ones you want verified.",
+    "VERIFICATION_BEGIN": "Verifying URLs. This can take a moment ...",
+    "VERIFICATION_PROGRESS": "Verifying URLs: ${0} URLs processed ...",
+    "VERIFICATION_ATTEMPT_ID": "Last verification attempt ID: ${0}",
+    "VERIFICATION_ATTEMPT_START": "Attempt start time: ${0}",
+    "VERIFICATION_ATTEMPT_FINISH": "Attempt finish time: ${0}"
+}
diff --git a/Open-ILS/web/js/dojo/openils/widget/FilteringTreeSelect.js b/Open-ILS/web/js/dojo/openils/widget/FilteringTreeSelect.js
index 6443f89..7791369 100644
--- a/Open-ILS/web/js/dojo/openils/widget/FilteringTreeSelect.js
+++ b/Open-ILS/web/js/dojo/openils/widget/FilteringTreeSelect.js
@@ -16,6 +16,7 @@ tree1.startup();
 if(!dojo._hasResource["openils.widget.FilteringTreeSelect"]){
     dojo.provide("openils.widget.FilteringTreeSelect");
     dojo.require("dijit.form.FilteringSelect");
+    dojo.require("dojo.data.ItemFileWriteStore");
 
     dojo.declare(
         "openils.widget.FilteringTreeSelect", [dijit.form.FilteringSelect], {
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
index 54e67c2..e83fca9 100644
--- a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
+++ b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
@@ -17,6 +17,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
             "columnReordering": true,
             "columnPersistKey": null,
             "autoCoreFields": false,
+            "autoCoreFieldsUnsorted": false,
             "autoFieldFields": null,
             "showLoadFilter": false,    /* use FlattenerFilter(Dialog|Pane) */
             "filterAlwaysInDiv": null,  /* use FlattenerFilterPane and put its
@@ -304,10 +305,14 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 var cell_list = this.structure[0].cells[0];
                 var fields = dojo.clone(
                     fieldmapper.IDL.fmclasses[this.fmClass].fields
-                ).sort(
-                    function(a, b) { return a.label > b.label ? 1 : -1; }
                 );
 
+                if (!this.autoCoreFieldsUnsorted) {
+                    fields = fields.sort(
+                        function(a, b) { return a.label > b.label ? 1 : -1; }
+                    );
+                }
+
                 dojo.forEach(
                     fields, function(f) {
                         if (f.datatype == "link" || f.virtual)
@@ -324,8 +329,8 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         cell_list.push({
                             "field": f.name,
                             "name": f.label,
-                            "fsort": true,
-                            "_visible": false
+                            "fsort": true /*,
+                            "_visible": false */
                         });
                     }
                 );
@@ -869,6 +874,21 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 );
             },
 
+            /* Return true if every row known to the grid is selected. Code
+             * that calls this function will do so when it thinks the user
+             * might actually mean "select everything this grid could show"
+             * even though we don't necessarily know (and the user hasn't
+             * necessarily noticed) whether the grid has been scrolled as far
+             * down as possible and all the possible results have been
+             * fetched by the grid's store. */
+            "everythingSeemsSelected": function() {
+                return dojo.query(
+                    "[name=autogrid.selector]", this.domNode
+                ).filter(
+                    function(c) { return (!c.disabled && !c.checked) }
+                ).length == 0;
+            },
+
             /* Print the same data that the Flattener is feeding to the
              * grid, sorted the same way too. Remove limit and offset (i.e.,
              * print it all) unless those are passed in to the print() method.

commit 9e1d009950edc1d2b9bea1aa11ff047d7f9c802d
Author: Mike Rylander <mrylander at gmail.com>
Date:   Mon Aug 13 16:10:38 2012 -0400

    Link checker: DB layer fixes
    
    Function for ingesting URLs from container item + session
    New container type for url verification sessions
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
index 23dedab..89478ad 100644
--- a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
@@ -60,5 +60,48 @@ CREATE TRIGGER ingest_url_tgr
     BEFORE INSERT ON url_verify.url
     FOR EACH ROW EXECUTE PROCEDURE url_verify.ingest_url(); 
 
+CREATE OR REPLACE FUNCTION url_verify.extract_urls ( session_id INT, item_id INT ) RETURNS INT AS $$
+DECLARE
+    current_tag TEXT;
+    current_sf TEXT;
+    current_url TEXT;
+    current_ord INT;
+    current_url_pos INT;
+    current_selector url_verify.url_selector%ROWTYPE;
+BEGIN
+    current_ord := 1;
+
+    FOR current_selector IN SELECT * FROM url_verify.url_selector s WHERE s.session = session_id LOOP
+        current_url_pos := 1;
+        LOOP
+            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc))[current_url_pos]::TEXT INTO current_url
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+
+            EXIT WHEN current_url IS NULL;
+
+            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc))[current_url_pos]::TEXT INTO current_tag
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+
+            SELECT  (XPATH(current_selector.xpath || '/@subfield', b.marc))[current_url_pos]::TEXT INTO current_sf
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+
+            INSERT INTO url_verify.url (item, url_selector, tag, subfield, ord, full_url)
+              VALUES ( item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
+
+            current_url_pos := current_url_pos + 1;
+            current_ord := current_ord + 1;
+        END LOOP;
+    END LOOP;
+
+    RETURN current_ord - 1;
+END;
+$$ LANGUAGE PLPGSQL;
+
 COMMIT;
 
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index fe4952c..d6f0547 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -4859,6 +4859,7 @@ INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('staf
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('bookbag', oils_i18n_gettext('bookbag', 'Book Bag', 'cbrebt', 'label'));
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('reading_list', oils_i18n_gettext('reading_list', 'Reading List', 'cbrebt', 'label'));
 INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('template_merge',oils_i18n_gettext('template_merge','Template Merge Container', 'cbrebt', 'label'));
+INSERT INTO container.biblio_record_entry_bucket_type (code,label) VALUES ('url_verify', oils_i18n_gettext('url_verify', 'URL Verification Queue', 'cbrebt', 'label'));
 
 INSERT INTO container.user_bucket_type (code,label) VALUES ('misc', oils_i18n_gettext('misc', 'Miscellaneous', 'cubt', 'label'));
 INSERT INTO container.user_bucket_type (code,label) VALUES ('folks', oils_i18n_gettext('folks', 'Friends', 'cubt', 'label'));
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
index e001f9f..82da8ee 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
@@ -1,3 +1,7 @@
+BEGIN;
+
+INSERT INTO container.biblio_record_entry_bucket_type (code, label) VALUES ('url_verify', 'URL Verification Queue');
+
 DROP SCHEMA IF EXISTS url_verify CASCADE;
 
 CREATE SCHEMA url_verify;
@@ -80,3 +84,4 @@ CREATE TABLE url_verify.filter_set (
     CONSTRAINT uvfs_name_once_per_lib UNIQUE (name, owning_lib)
 );
  
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
index c63c2d9..2990382 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -44,6 +44,48 @@ CREATE TRIGGER ingest_url_tgr
     BEFORE INSERT ON url_verify.url
     FOR EACH ROW EXECUTE PROCEDURE url_verify.ingest_url(); 
 
+CREATE OR REPLACE FUNCTION url_verify.extract_urls ( session_id INT, item_id INT ) RETURNS INT AS $$
+DECLARE
+    current_tag TEXT;
+    current_sf TEXT;
+    current_url TEXT;
+    current_ord INT;
+    current_url_pos INT;
+    current_selector url_verify.url_selector%ROWTYPE;
+BEGIN
+    current_ord := 1;
+
+    FOR current_selector IN SELECT * FROM url_verify.url_selector s WHERE s.session = session_id LOOP
+        current_url_pos := 1;
+        LOOP
+            SELECT  (XPATH(current_selector.xpath || '/text()', b.marc))[current_url_pos]::TEXT INTO current_url
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+    
+            EXIT WHEN current_url IS NULL;
+    
+            SELECT  (XPATH(current_selector.xpath || '/../@tag', b.marc))[current_url_pos]::TEXT INTO current_tag
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+    
+            SELECT  (XPATH(current_selector.xpath || '/@subfield', b.marc))[current_url_pos]::TEXT INTO current_sf
+              FROM  biblio.record_entry b
+                    JOIN container.biblio_record_entry_bucket_item c ON (c.target_biblio_record_entry = b.id)
+              WHERE c.id = item_id;
+    
+            INSERT INTO url_verify.url (item, url_selector, tag, subfield, ord, full_url)
+              VALUES ( item_id, current_selector.id, current_tag, current_sf, current_ord, current_url);
+
+            current_url_pos := current_url_pos + 1;
+            current_ord := current_ord + 1;
+        END LOOP;
+    END LOOP;
+
+    RETURN current_ord - 1;
+END;
+$$ LANGUAGE PLPGSQL;
 
 COMMIT;
 

commit 073bb779d7c11c6e74dba9a1f0023536cccd9872
Author: Bill Erickson <berick at esilibrary.com>
Date:   Tue Aug 7 16:28:33 2012 -0400

    Link checker: middle layer work, actual parallelized machinery to check links
    
    Added is_event check to AppUtils
    URLVerify.pm getting started
    URLVerify.pm; firing batch url verify calls
    URLVerify.pm; docs / cleanup
    Constraints need unique names
    Liberalize res_code constraint
    URLVerify.pm; resume options; docs
    URLVerify.pm; initial testing tweaks
    Move Fieldmapper API call to Application.pm
    Move export of opensrf.open-ils.system.fieldmapper API call from
        Fieldmapper.pm into Application.pm with the rest of the shared API
        calls.  This allows us to remove the OpenSRF::Application base from
        Fieldmapper, which was causing some method name collisions.  For
        example, if a Fieldmapper object had a field called "session", which,
        incidentally, is also a method of OpenSRF::Application, the version
        from OpenSRF::Application would get called instead of the Fieldmapper
        version, since Fieldmapper methods are defined during AUTOLOAD.
        Hilarity was guaranteed to ensue.
    URLVerify.pm; more testing tweaks
    URLVerify.pm; redirects / error handling
    url_verify perm/org setting seed data
    url_verify perm/org setting seed data
    url_verify perm/org setting seed data
    url_verify seed data (trigger)
    url verify seed data / null constraint repairs
    URLVerify.pm; settings, tmp caching, cleanup / misc
    url verify seed data repairs
    url_verify seed data / sql manifest
    url_verify schema repair / res-code constraint
    Do the same thing I did to fix constrain names in upgr scripts to baseline
    URLVerify.pm; move to lwp to support ftp and simplify
    URLVerify.pm; apply timeout to lwp useragent; comments
    URLVerify.pm; avoid re-processing same url within the same attempt
    URLVerify.pm; avoid re-processing same url within the same attempt (thinko)
    URLVerify.pm; avoid re-processing same url repairs; honor delay=0
    URLVerify.pm; docs; url shuffling
    URLVerify.pm; docs; url shuffling
    URLVerify.pm; docs; url domain looping; cleanup
    URLVerify.pm; docs; tested redirect max/loops and repairs
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application.pm
index cd4dbbf..2888c93 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application.pm
@@ -2,6 +2,7 @@ package OpenILS::Application;
 use OpenSRF::Application;
 use UNIVERSAL::require;
 use base qw/OpenSRF::Application/;
+use OpenILS::Utils::Fieldmapper;
 
 sub ils_version {
     # version format is "x-y-z", for example "2-0-0" for Evergreen 2.0.0
@@ -25,6 +26,19 @@ sub get_idl_file {
     return OpenSRF::Utils::SettingsClient->new->config_value('IDL');
 }
 
+sub publish_fieldmapper {
+	my ($self,$client,$class) = @_;
+
+	return $Fieldmapper::fieldmap unless (defined $class);
+	return undef unless (exists($$Fieldmapper::fieldmap{$class}));
+	return {$class => $$Fieldmapper::fieldmap{$class}};
+}
+__PACKAGE__->register_method(
+	api_name	=> 'opensrf.open-ils.system.fieldmapper',
+	api_level	=> 1,
+	method		=> 'publish_fieldmapper',
+);
+
 sub register_method {
     my $class = shift;
     my %args = @_;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
index e4d1e7d..b8124f2 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
@@ -140,10 +140,22 @@ sub rollback_db_session {
 # returns the event code otherwise
 sub event_code {
 	my( $self, $evt ) = @_;
-	return $evt->{ilsevent} if( ref($evt) eq 'HASH' and defined($evt->{ilsevent})) ;
+	return $evt->{ilsevent} if $self->is_event($evt);
 	return undef;
 }
 
+# some events, in particular auto-generated events, don't have an 
+# ilsevent key.  treat hashes with a 'textcode' key as events.
+sub is_event {
+	my ($self, $evt) = @_;
+	return (
+		ref($evt) eq 'HASH' and (
+			defined $evt->{ilsevent} or
+			defined $evt->{textcode}
+		)
+	);
+}
+
 # ---------------------------------------------------------------------------
 # Checks to see if a user is logged in.  Returns the user record on success,
 # throws an exception on error.
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
new file mode 100644
index 0000000..4e86dcd
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
@@ -0,0 +1,587 @@
+package OpenILS::Application::URLVerify;
+use base qw/OpenILS::Application/;
+use strict; use warnings;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenSRF::MultiSession;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenILS::Application::AppUtils;
+use LWP::UserAgent;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+
+__PACKAGE__->register_method(
+    method => 'validate_session',
+    api_name => 'open-ils.url_verify.session.validate',
+    stream => 1,
+    signature => {
+        desc => q/
+            Performs verification on all (or a subset of the) URLs within the requested session.
+        /,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Session ID (url_verify.session.id)', type => 'number'},
+            {desc => 'URL ID list (optional).  An empty list will result in no URLs being processed', type => 'array'},
+            {
+                desc => q/
+                    Options (optional).
+                        report_all => bypass response throttling and return all URL sub-process
+                            responses to the caller.  Not recommened for remote (web, etc.) clients,
+                            because it can be a lot of data.
+                        resume_attempt => atttempt_id.  Resume verification after a failure.
+                        resume_with_new_attempt => If true, resume from resume_attempt, but
+                            create a new attempt to track the resumption.
+                    /,
+                type => 'hash'
+            }
+        ],
+        return => {desc => q/
+            Stream of objects containing the number of URLs to be processed (url_count),
+            the number processed thus far including redirects (total_processed),
+            and the current url_verification object (current_verification).
+
+            Note that total_processed may ultimately exceed url_count, since it
+            includes non-anticipate-able redirects.
+
+            The final response contains url_count, total_processed, and the
+            verification_attempt object (attempt).
+            /
+        }
+    }
+);
+
+sub validate_session {
+    my ($self, $client, $auth, $session_id, $url_ids, $options) = @_;
+    $options ||= {};
+
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+    return $e->die_event unless $e->allowed('VERIFY_URL');
+
+    my $session = $e->retrieve_url_verify_session($session_id)
+        or return $e->die_event;
+
+    my $attempt_id = $options->{resume_attempt};
+
+    if (!$url_ids) {
+
+        # No URLs provided, load all URLs for the requested session
+
+        my $query = {
+            select => {uvu => ['id']},
+            from => {
+                uvu => { # url
+                    cbrebi => { # bucket item
+                        join => { cbreb => { # bucket
+                            join => { uvs => { # session
+                                filter => {id => $session_id}
+                            }}
+                        }}
+                    }
+                }
+            }
+        };
+
+        if ($attempt_id) {
+
+            # when resuming an existing attempt (that presumably failed
+            # mid-processing), we only want to process URLs that either
+            # have no linked url_verification or have an un-completed
+            # url_verification.
+
+            $logger->info("url: resuming attempt $attempt_id");
+
+            $query->{from}->{uvu}->{uvuv} = {
+                type => 'left',
+                filter => {attempt => $attempt_id}
+            };
+
+            $query->{where} = {
+                '+uvuv' => {
+                    '-or' => [
+                        {id => undef}, # no verification started
+                        {res_code => undef} # verification started but did no complete
+                    ]
+                }
+            };
+
+        } else {
+
+            # this is a new attempt, so we only want to process URLs that
+            # originated from the source records and not from redirects.
+
+            $query->{where} = {
+                '+uvu' => {redirect_from => undef}
+            };
+        }
+
+        my $ids = $e->json_query($query);
+        $url_ids = [ map {$_->{id}} @$ids ];
+    }
+
+    my $url_count = scalar(@$url_ids);
+    $logger->info("url: processing $url_count URLs");
+
+    my $attempt;
+    if ($attempt_id and !$options->{resume_with_new_attempt}) {
+
+        $attempt = $e->retrieve_url_verification_attempt($attempt_id)
+            or return $e->die_event;
+
+        # no data was written
+        $e->rollback;
+
+    } else {
+
+        $attempt = Fieldmapper::url_verify::verification_attempt->new;
+        $attempt->session($session_id);
+        $attempt->usr($e->requestor->id);
+        $attempt->start_time('now');
+
+        $e->create_url_verify_verification_attempt($attempt)
+            or return $e->die_event;
+
+        $e->commit;
+    }
+
+    # END DB TRANSACTION
+
+    # Now cycle through the URLs in batches.
+
+    my $batch_size = $U->ou_ancestor_setting_value(
+        $session->owning_lib,
+        'url_verify.verification_batch_size', $e) || 5;
+
+    my $num_processed = 0; # total number processed, including redirects
+    my $resp_window = 1;
+
+    # before we start the real work, let the caller know
+    # the attempt (id) so recovery is possible.
+
+    $client->respond({
+        url_count => $url_count,
+        total_processed => $num_processed,
+        attempt => $attempt
+    });
+
+    my $multises = OpenSRF::MultiSession->new(
+
+        app => 'open-ils.url_verify', # hey, that's us!
+        cap => $batch_size,
+
+        success_handler => sub {
+            my ($self, $req) = @_;
+
+            # API call streams fleshed url_verification objects.  We wrap
+            # those up with some extra info and pass them on to the caller.
+
+            for my $resp (@{$req->{response}}) {
+                my $content = $resp->content;
+
+                if ($content) {
+
+                    $num_processed++;
+
+                    if ($options->{report_all} or ($num_processed % $resp_window == 0)) {
+                        $client->respond({
+                            url_count => $url_count,
+                            current_verification => $content,
+                            total_processed => $num_processed
+                        });
+                    }
+
+                    # start off responding quickly, then throttle
+                    # back to only relaying every 256 messages.
+                    $resp_window *= 2 unless $resp_window == 256;
+                }
+            }
+        },
+
+        failure_handler => sub {
+            my ($self, $req) = @_;
+
+            # {error} should be an Error w/ a toString
+            $logger->error("url: error processing URL: " . $req->{error});
+        }
+    );
+
+    sort_and_fire_domains($e, $auth, $attempt, $url_ids, $multises);
+
+    # Wait for all requests to be completed
+    $multises->session_wait(1);
+
+    # All done.  Let's wrap up the attempt.
+    $attempt->finish_time('now');
+
+    $e->xact_begin;
+    $e->update_url_verify_verification_attempt($attempt) or return $e->die_event;
+    $e->xact_commit;
+
+    return {
+        url_count => $url_count,
+        total_processed => $num_processed,
+        attempt => $attempt
+    };
+}
+
+# retrieves the URL domains and sorts them into buckets
+# Iterates over the buckets and fires the multi-session call
+# the main drawback to this domain sorting approach is that
+# any domain used a lot more than the others will be the
+# only domain standing after the others are exhausted, which
+# means it will take a beating at the end of the batch.
+sub sort_and_fire_domains {
+    my ($e, $auth, $attempt, $url_ids, $multises) = @_;
+
+    # there is potential here for data sets to be too large
+    # for delivery, but it's not likely, since we're only
+    # fetching ID and domain.
+    my $urls = $e->json_query(
+        {
+            select => {uvu => ['id', 'domain']},
+            from => 'uvu',
+            where => {id => $url_ids}
+        },
+        # {substream => 1} only if needed
+    );
+
+    # sort them into buckets based on domain name
+    my %domains;
+    for my $url (@$urls) {
+        $domains{$url->{domain}} = [] unless $domains{$url->{domain}};
+        push(@{$domains{$url->{domain}}}, $url->{id});
+    }
+
+    # loop through the domains and fire the verification call
+    while (keys %domains) {
+        for my $domain (keys %domains) {
+
+            my $url_id = pop(@{$domains{$domain}});
+            delete $domains{$domain} unless @{$domains{$domain}};
+
+            $multises->request(
+                'open-ils.url_verify.verify_url',
+                $auth, $attempt->id, $url_id);
+        }
+    }
+}
+
+
+__PACKAGE__->register_method(
+    method => 'verify_url',
+    api_name => 'open-ils.url_verify.verify_url',
+    stream => 1,
+    signature => {
+        desc => q/
+            Performs verification of a single URL.  When a redirect is detected,
+            a new URL is created to model the redirect and the redirected URL
+            is then tested, up to max-redirects or a loop is detected.
+        /,
+        params => [
+            {desc => 'Authentication token', type => 'string'},
+            {desc => 'Verification attempt ID (url_verify.verification_attempt.id)', type => 'number'},
+            {desc => 'URL id (url_verify.url.id)', type => 'number'},
+        ],
+        return => {desc => q/Stream of url_verification objects, one per URL tested/}
+    }
+);
+
+=head comment
+
+verification.res_code:
+
+999 bad hostname, etc. (IO::Socket::Inet errors)
+998 in-flight errors (e.g connection closed prematurely)
+997 timeout
+996 redirect loop
+995 max redirects
+
+verification.res_text:
+
+$@ or custom message "Redirect Loop"
+
+=cut
+
+sub verify_url {
+    my ($self, $client, $auth, $attempt_id, $url_id) = @_;
+    my %seen_urls;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+
+    my $url = $e->retrieve_url_verify_url($url_id) or return $e->event;
+
+    my ($attempt, $delay, $max_redirects, $timeout) =
+        collect_verify_attempt_and_settings($e, $attempt_id);
+
+    return $e->event unless $e->allowed(
+        'VERIFY_URL', $attempt->session->owning_lib);
+
+    my $cur_url = $url;
+    my $loop_detected = 0;
+    my $redir_count = 0;
+
+    while ($redir_count++ < $max_redirects) {
+
+        if ($seen_urls{$cur_url->full_url}) {
+            $loop_detected = 1;
+            last;
+        }
+
+        $seen_urls{$cur_url->full_url} = $cur_url;
+
+        my $url_resp = verify_one_url($e, $attempt, $cur_url, $timeout);
+
+        # something tragic happened
+        return $url_resp if $U->is_event($url_resp);
+
+        # flesh and respond to the caller
+        $url_resp->{verification}->url($cur_url);
+        $client->respond($url_resp->{verification});
+
+        $cur_url = $url_resp->{redirect_url} or last;
+    }
+
+    if ($loop_detected or $redir_count > $max_redirects) {
+
+        my $vcation = Fieldmapper::url_verify::url_verification->new;
+        $vcation->url($cur_url->id);
+        $vcation->attempt($attempt->id);
+        $vcation->req_time('now');
+
+        if ($loop_detected) {
+            $logger->info("url: redirect loop detected at " . $cur_url->full_url);
+            $vcation->res_code('996');
+            $vcation->res_text('Redirect Loop');
+
+        } else {
+            $logger->info("url: max redirects reached for source URL " . $url->full_url);
+            $vcation->res_code('995');
+            $vcation->res_text('Max Redirects');
+        }
+
+        $e->xact_begin;
+        $e->create_url_verify_url_verification($vcation) or return $e->die_event;
+        $e->xact_commit;
+    }
+
+    # The calling code is likely not multi-threaded, so a
+    # per-URL (i.e. per-thread) delay would not be possible.
+    # Applying the delay here allows the caller to process
+    # batches of URLs without having to worry about the delay.
+    sleep $delay;
+
+    return undef;
+}
+
+# temporarily cache some data to avoid a pile
+# of data lookups on every URL processed.
+my %cache;
+sub collect_verify_attempt_and_settings {
+    my ($e, $attempt_id) = @_;
+    my $attempt;
+
+    if (!(keys %cache) or $cache{age} > 20) { # configurable?
+        %cache = (
+            age => 0,
+            attempt => {},
+            delay => {},
+            redirects => {},
+            timeout => {},
+        );
+    }
+
+    if ( !($attempt = $cache{attempt}{$attempt_id}) ) {
+
+        # attempt may have just been created, so
+        # we need to guarantee a write-DB read.
+        $e->xact_begin;
+
+        $attempt =
+            $e->retrieve_url_verify_verification_attempt([
+                $attempt_id, {
+                    flesh => 1,
+                    flesh_fields => {uvva => ['session']}
+                }
+            ]) or return $e->die_event;
+
+        $e->rollback;
+
+        $cache{attempt}{$attempt_id} = $attempt;
+    }
+
+    my $org = $attempt->session->owning_lib;
+
+    if (!$cache{timeout}{$org}) {
+
+        $cache{delay}{$org} = $U->ou_ancestor_setting_value(
+            $org, 'url_verify.url_verification_delay', $e);
+
+        # 0 is a valid delay
+        $cache{delay}{$org} = 2 unless defined $cache{delay}{$org};
+
+        $cache{redirects}{$org} = $U->ou_ancestor_setting_value(
+            $org, 'url_verify.url_verification_max_redirects', $e) || 20;
+
+        $cache{timeout}{$org} = $U->ou_ancestor_setting_value(
+            $org, 'url_verify.url_verification_max_wait', $e) || 5;
+
+        $logger->info(
+            sprintf("url: loaded settings delay=%s; max_redirects=%s; timeout=%s",
+                $cache{delay}{$org}, $cache{redirects}{$org}, $cache{timeout}{$org}));
+    }
+
+    $cache{age}++;
+
+
+    return (
+        $cache{attempt}{$attempt_id},
+        $cache{delay}{$org},
+        $cache{redirects}{$org},
+        $cache{timeout}{$org}
+    );
+}
+
+
+# searches for a completed url_verfication for any url processed
+# within this verification attempt whose full_url matches the
+# full_url of the provided URL.
+sub find_matching_url_for_attempt {
+    my ($e, $attempt, $url) = @_;
+
+    my $match = $e->json_query({
+        select => {uvuv => ['id']},
+        from => {
+            uvuv => {
+                uvva => { # attempt
+                    filter => {id => $attempt->id}
+                },
+                uvu => {} # url
+            }
+        },
+        where => {
+            '+uvu' => {
+                id => {'!=' => $url->id},
+                full_url => $url->full_url
+            },
+
+            # There could be multiple verifications for matching URLs
+            # We only want a verification that completed.
+            # Note also that 2 identical URLs processed within the same
+            # sub-batch will have to each be fully processed in their own
+            # right, since neither knows how the other will ultimately fare.
+            '+uvuv' => {
+                res_time => {'!=' => undef}
+            }
+        }
+    })->[0];
+
+    return $e->retrieve_url_verify_url_verification($match->{id}) if $match;
+    return undef;
+}
+
+
+=head comment
+
+1. create the verification object and commit.
+2. test the URL
+3. update the verification object to capture the results of the test
+4. Return redirect_url object if this is a redirect, otherwise undef.
+
+=cut
+
+sub verify_one_url {
+    my ($e, $attempt, $url, $timeout) = @_;
+
+    my $url_text = $url->full_url;
+    my $redir_url;
+
+    # first, create the verification object so we can a) indicate that
+    # we're working on this URL and b) get the DB to set the req_time.
+
+    my $vcation = Fieldmapper::url_verify::url_verification->new;
+    $vcation->url($url->id);
+    $vcation->attempt($attempt->id);
+    $vcation->req_time('now');
+
+    # begin phase-I DB communication
+
+    $e->xact_begin;
+
+    my $match_vcation = find_matching_url_for_attempt($e, $attempt, $url);
+
+    if ($match_vcation) {
+        $logger->info("url: found matching URL in verification attempt [$url_text]");
+        $vcation->res_code($match_vcation->res_code);
+        $vcation->res_text($match_vcation->res_text);
+        $vcation->redirect_to($match_vcation->redirect_to);
+    }
+
+    $e->create_url_verify_url_verification($vcation) or return $e->die_event;
+    $e->xact_commit;
+
+    # found a matching URL, no need to re-process
+    return {verification => $vcation} if $match_vcation;
+
+    # End phase-I DB communication
+    # No active DB xact means no cstore timeout concerns.
+
+    # Now test the URL.
+
+    $ENV{FTP_PASSIVE} = 1; # TODO: setting?
+
+    my $ua = LWP::UserAgent->new(ssl_opts => {verify_hostname => 0}); # TODO: verify_hostname setting?
+    $ua->timeout($timeout);
+
+    my $req = HTTP::Request->new(HEAD => $url->full_url);
+
+    # simple_request avoids LWP's auto-redirect magic
+    my $res = $ua->simple_request($req);
+
+    $logger->info(sprintf(
+        "url: received HTTP '%s' / '%s' [%s]",
+        $res->code,
+        $res->message,
+        $url_text
+    ));
+
+    $vcation->res_code($res->code);
+    $vcation->res_text($res->message);
+
+    # is this a redirect?
+    if ($res->code =~ /^3/) {
+
+        if (my $loc = $res->headers->{location}) {
+            $redir_url = Fieldmapper::url_verify::url->new;
+            $redir_url->redirect_from($url->id);
+            $redir_url->full_url($loc);
+
+            $logger->info("url: redirect found $url_text => $loc");
+
+        } else {
+            $logger->info("url: server returned 3XX but no 'Location' header for url $url_text");
+        }
+    }
+
+    # Begin phase-II DB communication
+
+    $e->xact_begin;
+
+    if ($redir_url) {
+        $redir_url = $e->create_url_verify_url($redir_url) or return $e->die_event;
+        $vcation->redirect_to($redir_url->id);
+    }
+
+    $vcation->res_time('now');
+    $e->update_url_verify_url_verification($vcation) or return $e->die_event;
+    $e->commit;
+
+    return {
+        verification => $vcation,
+        redirect_url => $redir_url
+    };
+}
+
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
index 33050fd..709ad5c 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
@@ -1,7 +1,6 @@
 package Fieldmapper;
 use OpenSRF::Utils::JSON;
 use Data::Dumper;
-use base 'OpenSRF::Application';
 use OpenSRF::Utils::Logger;
 use OpenSRF::Utils::SettingsClient;
 use OpenSRF::System;
@@ -12,19 +11,6 @@ my $log = 'OpenSRF::Utils::Logger';
 
 use vars qw/$fieldmap $VERSION/;
 
-sub publish_fieldmapper {
-	my ($self,$client,$class) = @_;
-
-	return $fieldmap unless (defined $class);
-	return undef unless (exists($$fieldmap{$class}));
-	return {$class => $$fieldmap{$class}};
-}
-__PACKAGE__->register_method(
-	api_name	=> 'opensrf.open-ils.system.fieldmapper',
-	api_level	=> 1,
-	method		=> 'publish_fieldmapper',
-);
-
 #
 # To dump the Javascript version of the fieldmapper struct use the command:
 #
diff --git a/Open-ILS/src/sql/Pg/075.schema.url_verify.sql b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
index 753c769..db42861 100644
--- a/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
@@ -28,7 +28,7 @@ CREATE TABLE url_verify.session (
     container   INT                         NOT NULL REFERENCES container.biblio_record_entry_bucket (id) DEFERRABLE INITIALLY DEFERRED,
     create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     search      TEXT                        NOT NULL,
-    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+    CONSTRAINT uvs_name_once_per_lib UNIQUE (name, owning_lib)
 );
 
 CREATE TABLE url_verify.url_selector (
@@ -41,11 +41,11 @@ CREATE TABLE url_verify.url_selector (
 CREATE TABLE url_verify.url (
     id              SERIAL  PRIMARY KEY,
     redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
-    item            INT     NOT NULL REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
-    url_selector    INT     NOT NULL REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
-    tag             TEXT    NOT NULL,
-    subfield        TEXT    NOT NULL,
-    ord             INT     NOT NULL, -- ordinal position of this url within the record as found by url_selector, for later update
+    item            INT     REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
+    url_selector    INT     REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
+    tag             TEXT,    
+    subfield        TEXT,    
+    ord             INT,    -- ordinal position of this url within the record as found by url_selector, for later update
     full_url        TEXT    NOT NULL,
     scheme          TEXT,
     username        TEXT,
@@ -83,7 +83,7 @@ CREATE TABLE url_verify.url_verification (
     attempt     INT                         NOT NULL REFERENCES url_verify.verification_attempt (id) DEFERRABLE INITIALLY DEFERRED,
     req_time    TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     res_time    TIMESTAMP WITH TIME ZONE, 
-    res_code    INT                         CHECK (res_code BETWEEN 100 AND 599),
+    res_code    INT                         CHECK (res_code BETWEEN 100 AND 999), -- we know > 599 will never be valid HTTP code, but we use 9XX for other stuff
     res_text    TEXT, 
     redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
 );
@@ -95,7 +95,7 @@ CREATE TABLE url_verify.filter_set (
     creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
     create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     filter      TEXT                        NOT NULL,
-    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+    CONSTRAINT uvfs_name_once_per_lib UNIQUE (name, owning_lib)
 );
  
 COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 8805181..fe4952c 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -1573,7 +1573,11 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 541, 'ADMIN_TOOLBAR_FOR_WORKSTATION', oils_i18n_gettext( 541,
         'Allows a user to create, edit, and delete custom toolbars for workstations', 'ppl', 'description')),
  ( 542, 'ADMIN_TOOLBAR_FOR_USER', oils_i18n_gettext( 542,
-        'Allows a user to create, edit, and delete custom toolbars for users', 'ppl', 'description'))
+        'Allows a user to create, edit, and delete custom toolbars for users', 'ppl', 'description')),
+ ( 543, 'URL_VERIFY', oils_i18n_gettext( 543, 
+    'Allows a user to process and verify ULSs', 'ppl', 'description')),
+ ( 544, 'URL_VERIFY_UPDATE_SETTINGS', oils_i18n_gettext( 544, 
+    'Allows a user to configure URL verification org unit settings', 'ppl', 'description'))
 ;
 
 
@@ -12004,3 +12008,95 @@ INSERT INTO config.org_unit_setting_type
         ),
         'integer'
     );
+
+INSERT INTO config.settings_group (name, label)
+    VALUES (
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify',
+            'URL Verify',
+            'csg',
+            'label'
+        )
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_delay',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_delay',
+            'Number of seconds to wait between URL test attempts.',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.url_verification_delay',
+            'Throttling mechanism for batch URL verification runs.  Each running process will wait this number of seconds after a URL test before performing the next.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_max_redirects',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_redirects',
+            'Maximum redirect lookups',
+            'url_verify.url_verification_max_redirects',
+            'For URLs returning 3XX redirects, this is the maximum number of redirects we will follow before giving up.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_max_wait',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_wait',
+            'Maximum wait time (in seconds) for a URL to lookup',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_wait',
+            'If we exceed the wait time, the URL is marked as a "timeout" and the system moves on to the next URL',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.verification_batch_size',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.verification_batch_size',
+            'Number of URLs to test in parallel',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.verification_batch_size',
+            'URLs are tested in batches.  This number defines the size of each batch and it directly relates to the number of back-end processes performing URL verification.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
diff --git a/Open-ILS/src/sql/Pg/sql_file_manifest b/Open-ILS/src/sql/Pg/sql_file_manifest
index 70d7dcd..459b604 100644
--- a/Open-ILS/src/sql/Pg/sql_file_manifest
+++ b/Open-ILS/src/sql/Pg/sql_file_manifest
@@ -24,6 +24,8 @@ FTS_CONFIG_FILE
 030.schema.metabib.sql
 040.schema.asset.sql
 070.schema.container.sql
+075.schema.url_verify.sql
+076.functions.url_verify.sql
 080.schema.money.sql
 090.schema.action.sql
 095.schema.booking.sql
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
index 7850b49..e001f9f 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
@@ -10,7 +10,7 @@ CREATE TABLE url_verify.session (
     container   INT                         NOT NULL REFERENCES container.biblio_record_entry_bucket (id) DEFERRABLE INITIALLY DEFERRED,
     create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     search      TEXT                        NOT NULL,
-    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+    CONSTRAINT uvs_name_once_per_lib UNIQUE (name, owning_lib)
 );
 
 CREATE TABLE url_verify.url_selector (
@@ -23,11 +23,11 @@ CREATE TABLE url_verify.url_selector (
 CREATE TABLE url_verify.url (
     id              SERIAL  PRIMARY KEY,
     redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
-    item            INT     NOT NULL REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
-    url_selector    INT     NOT NULL REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
-    tag             TEXT    NOT NULL,
-    subfield        TEXT    NOT NULL,
-    ord             INT     NOT NULL, -- ordinal position of this url within the record as found by url_selector, for later update
+    item            INT     REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
+    url_selector    INT     REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
+    tag             TEXT,
+    subfield        TEXT,
+    ord             INT,
     full_url        TEXT    NOT NULL,
     scheme          TEXT,
     username        TEXT,
@@ -65,7 +65,7 @@ CREATE TABLE url_verify.url_verification (
     attempt     INT                         NOT NULL REFERENCES url_verify.verification_attempt (id) DEFERRABLE INITIALLY DEFERRED,
     req_time    TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     res_time    TIMESTAMP WITH TIME ZONE, 
-    res_code    INT                         CHECK (res_code BETWEEN 100 AND 599),
+    res_code    INT                         CHECK (res_code BETWEEN 100 AND 999), -- we know > 599 will never be valid HTTP code, but we use 9XX for other stuff
     res_text    TEXT, 
     redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
 );
@@ -77,6 +77,6 @@ CREATE TABLE url_verify.filter_set (
     creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
     create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
     filter      TEXT                        NOT NULL,
-    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+    CONSTRAINT uvfs_name_once_per_lib UNIQUE (name, owning_lib)
 );
  
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
index 2087794..c63c2d9 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -18,5 +18,32 @@ return \%parts;
 
 $$ LANGUAGE PLPERLU;
 
+CREATE OR REPLACE FUNCTION url_verify.ingest_url () RETURNS TRIGGER AS $$
+DECLARE
+    tmp_row url_verify.url%ROWTYPE;
+BEGIN
+    SELECT * INTO tmp_row FROM url_verify.parse_url(NEW.full_url);
+
+    NEW.scheme          := tmp_row.scheme;
+    NEW.username        := tmp_row.username;
+    NEW.password        := tmp_row.password;
+    NEW.host            := tmp_row.host;
+    NEW.domain          := tmp_row.domain;
+    NEW.tld             := tmp_row.tld;
+    NEW.port            := tmp_row.port;
+    NEW.path            := tmp_row.path;
+    NEW.page            := tmp_row.page;
+    NEW.query           := tmp_row.query;
+    NEW.fragment        := tmp_row.fragment;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER ingest_url_tgr
+    BEFORE INSERT ON url_verify.url
+    FOR EACH ROW EXECUTE PROCEDURE url_verify.ingest_url(); 
+
+
 COMMIT;
 
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
new file mode 100644
index 0000000..f0f9846
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.url_verify.sql
@@ -0,0 +1,131 @@
+
+-- NOTE: beware the use of bare perm IDs in the update_perm's below and in 
+-- the 950 seed data file.  Update before merge to match current perm IDs! XXX
+
+BEGIN;
+
+INSERT INTO permission.perm_list (id, code, description) 
+    VALUES ( 
+        543, 
+        'URL_VERIFY',
+        oils_i18n_gettext(
+            543, 
+            'Allows a user to process and verify ULSs', 
+            'ppl', 
+            'description'
+        )
+    );
+
+
+INSERT INTO permission.perm_list (id, code, description) 
+    VALUES ( 
+        544, 
+        544,
+        oils_i18n_gettext(
+            544, 
+            'Allows a user to configure URL verification org unit settings',
+            'ppl', 
+            'description'
+        )
+    );
+
+
+INSERT INTO config.settings_group (name, label)
+    VALUES (
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify',
+            'URL Verify',
+            'csg',
+            'label'
+        )
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_delay',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_delay',
+            'Number of seconds to wait between URL test attempts.',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.url_verification_delay',
+            'Throttling mechanism for batch URL verification runs.  Each running process will wait this number of seconds after a URL test before performing the next.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_max_redirects',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_redirects',
+            'Maximum redirect lookups',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_redirects',
+            'For URLs returning 3XX redirects, this is the maximum number of redirects we will follow before giving up.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.url_verification_max_wait',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_wait',
+            'Maximum wait time (in seconds) for a URL to lookup',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.url_verification_max_wait',
+            'If we exceed the wait time, the URL is marked as a "timeout" and the system moves on to the next URL',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype, update_perm)
+    VALUES (
+        'url_verify.verification_batch_size',
+        'url_verify',
+        oils_i18n_gettext(
+            'url_verify.verification_batch_size',
+            'Number of URLs to test in parallel',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'url_verify.verification_batch_size',
+            'URLs are tested in batches.  This number defines the size of each batch and it directly relates to the number of back-end processes performing URL verification.',
+            'coust',
+            'description'
+        ),
+        'integer',
+        544
+    );
+
+
+COMMIT;
+

commit 23101c610ccd606764e4f531ee2a9994c935f81f
Author: Mike Rylander <mrylander at gmail.com>
Date:   Mon Aug 6 15:51:04 2012 -0400

    Link checker: DB layer and similar changes for URL Verification
    
    Schema and IDL changes for URL Verification functionality
    Start building URL-Validation related funcitons
    Add Rose::URI to the CPAN_MODULES list
    Add ON INSERT trigger to parse the URL as it is added
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index a7a765d..c774122 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -19,6 +19,56 @@ You should have received a copy of the GNU General Public License
 along with this program; if not, write to the Free Software
 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 
+############################
+          TEMPLATE
+############################
+
+    <class
+        id=""
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper=""
+        oils_persist:tablename=""
+        reporter:label=""
+        oils_persist:field_safe=""
+        oils_persist:virtual=""
+        oils_persist:readonly=""
+        reporter:core=""
+    >
+
+        <oils_persist:source_definition><![CDATA[
+            SELECT * FROM foo
+        ]]></oils_persist:source_definition>
+
+        <fields oils_persist:primary="" oils_persist:sequence="">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="" name="" reporter:datatype="org_unit"/>
+            <field reporter:label="" name="" reporter:datatype="text"/>
+            <field reporter:label="" name="" reporter:datatype="link"/>
+            <field reporter:label="" name="" reporter:datatype="timestamp"/>
+            <field reporter:label="" name="" reporter:datatype="money"/>
+            <field reporter:label="" name="" reporter:datatype="bool"/>
+            <field reporter:label="" name="" reporter:datatype="int" oils_obj:required="true"/>
+            <field reporter:label="" name="" reporter:datatype="int" oils_persist:virtual="true"/>
+        </fields>
+
+        <links>
+            <link field="" reltype="has_a" key="" map="" class=""/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="" gloabl_required="true"/>
+                <retrieve/>
+                <update permission="" context_field=""/>
+                <delete permission="">
+                    <context link="" field=""/>
+                </delete>
+            </actions>
+        </permacrud>
+
+    </class>
+
+
 -->
 
 <IDL xmlns="http://opensrf.org/spec/IDL/base/v1" xmlns:idl="http://opensrf.org/spec/IDL/base/v1" xmlns:oils_persist="http://open-ils.org/spec/opensrf/IDL/persistence/v1" xmlns:oils_obj="http://open-ils.org/spec/opensrf/IDL/objects/v1" xmlns:reporter="http://open-ils.org/spec/opensrf/IDL/reporter/v1" xmlns:permacrud="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
@@ -9287,6 +9337,247 @@ SELECT  usr,
 		</permacrud>
 	</class>
 
+    <class
+        id="uvs"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::session"
+        oils_persist:tablename="url_verify.session"
+        reporter:label="URL Verification Session"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.session_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+			<field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit" oils_obj:required="true"/>
+            <field reporter:label="Creator" name="creator" reporter:datatype="link" oils_obj:required="true"/>
+            <field reporter:label="Record Container" name="container" reporter:datatype="link" oils_obj:required="true"/>
+            <field reporter:label="Create Time" name="create_time" reporter:datatype="timestamp"/>
+            <field reporter:label="Search Constraints" name="search" reporter:datatype="text" oils_obj:required="true"/>
+            <field reporter:label="URL Selectors" name="selectors" reporter:datatype="link" oils_persist:virtual="true"/>
+            <field reporter:label="Verification Attempts" name="attempts" reporter:datatype="link" oils_persist:virtual="true"/>
+        </fields>
+
+        <links>
+            <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
+            <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+            <link field="container" reltype="has_a" key="id" map="" class="cbreb"/>
+            <link field="selectors" reltype="has_many" key="session" map="" class="uvus"/>
+            <link field="attempts" reltype="has_many" key="session" map="" class="uvva"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <retrieve permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <update permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <delete permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+            </actions>
+        </permacrud>
+
+    </class>
+
+    <class
+        id="uvus"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::url_selector"
+        oils_persist:tablename="url_verify.url_selector"
+        reporter:label="URL Verification URL Selector"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_selector_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="XPath" name="xpath" reporter:datatype="text" oils_obj:required="true"/>
+			<field reporter:label="Session" name="session" reporter:datatype="link" oils_obj:required="true"/>
+            <field reporter:label="URLs" name="urls" reporter:datatype="link" oils_persist:virtual="true"/>
+        </fields>
+
+        <links>
+            <link field="session" reltype="has_a" key="id" map="" class="uvs"/>
+            <link field="urls" reltype="has_many" key="id" map="" class="uvu"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </create>
+                <retrieve permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </retrieve>
+                <update permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </update>
+                <delete permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </delete>
+            </actions>
+        </permacrud>
+
+    </class>
+
+    <class
+        id="uvu"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::url"
+        oils_persist:tablename="url_verify.url"
+        reporter:label="URL Verification URL"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="Redirected From" name="redirect_from" reporter:datatype="link"/>
+			<field reporter:label="Container Item" name="item" reporter:datatype="link" oils_obj:required="true"/>
+			<field reporter:label="URL Selector" name="url_selector" reporter:datatype="link"/>
+            <field reporter:label="Tag" name="tag" reporter:datatype="text"/>
+            <field reporter:label="Subfield" name="subfield" reporter:datatype="text"/>
+            <field reporter:label="Ordinal Position" name="ord" reporter:datatype="int"/>
+            <field reporter:label="URL" name="full_url" reporter:datatype="text"/>
+            <field reporter:label="Scheme" name="scheme" reporter:datatype="text"/>
+            <field reporter:label="Host" name="host" reporter:datatype="text"/>
+            <field reporter:label="Domain" name="domain" reporter:datatype="text"/>
+            <field reporter:label="TLD" name="tld" reporter:datatype="text"/>
+            <field reporter:label="Path" name="path" reporter:datatype="text"/>
+            <field reporter:label="Page" name="page" reporter:datatype="text"/>
+            <field reporter:label="Query" name="query" reporter:datatype="text"/>
+            <field reporter:label="Fragment" name="fragment" reporter:datatype="text"/>
+        </fields>
+
+        <links>
+            <link field="redirect_from" reltype="has_a" key="id" map="" class="uvu"/>
+            <link field="item" reltype="has_a" key="id" map="" class="cbrebi"/>
+            <link field="url_selector" reltype="has_a" key="id" map="" class="uvus"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY">
+                    <context link="url_selector session" field="owning_lib"/>
+                </create>
+                <retrieve permission="ADMIN_URL_VERIFY">
+                    <context link="url_selector session" field="owning_lib"/>
+                </retrieve>
+                <update permission="ADMIN_URL_VERIFY">
+                    <context link="url_selector session" field="owning_lib"/>
+                </update>
+                <delete permission="ADMIN_URL_VERIFY">
+                    <context link="url_selector session" field="owning_lib"/>
+                </delete>
+            </actions>
+        </permacrud>
+
+    </class>
+
+    <class
+        id="uvva"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::verification_attempt"
+        oils_persist:tablename="url_verify.verification_attempt"
+        reporter:label="URL Verification Attempt"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.verification_attempt_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="User" name="usr" reporter:datatype="link"/>
+			<field reporter:label="Session" name="session" reporter:datatype="link"/>
+            <field reporter:label="Start Time" name="start_time" reporter:datatype="timestamp"/>
+            <field reporter:label="Finish Time" name="finish_time" reporter:datatype="timestamp"/>
+        </fields>
+
+        <links>
+            <link field="session" reltype="has_a" key="id" map="" class="uvs"/>
+            <link field="usr" reltype="has_a" key="id" map="" class="au"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </create>
+                <retrieve permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </retrieve>
+                <update permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </update>
+                <delete permission="ADMIN_URL_VERIFY">
+                    <context link="session" field="owning_lib"/>
+                </delete>
+            </actions>
+        </permacrud>
+
+    </class>
+
+    <class
+        id="uvuv"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::url_verification"
+        oils_persist:tablename="url_verify.url_verification"
+        reporter:label="URL Verification"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_verification_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+			<field reporter:label="URL" name="url" reporter:datatype="link"/>
+			<field reporter:label="Attempt" name="attempt" reporter:datatype="link"/>
+            <field reporter:label="Request Time" name="req_time" reporter:datatype="timestamp"/>
+            <field reporter:label="Result Time" name="res_time" reporter:datatype="timestamp"/>
+            <field reporter:label="Result Code" name="res_code" reporter:datatype="int"/>
+            <field reporter:label="Result Text" name="res_text" reporter:datatype="text"/>
+			<field reporter:label="Redirected To" name="redirect_to" reporter:datatype="link"/>
+        </fields>
+
+        <links>
+            <link field="url" reltype="has_a" key="id" map="" class="uvu"/>
+            <link field="attempt" reltype="has_a" key="id" map="" class="uvva"/>
+            <link field="redirect_to" reltype="has_a" key="id" map="" class="uvu"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY">
+                    <context link="attempt session" field="owning_lib"/>
+                </create>
+                <retrieve permission="ADMIN_URL_VERIFY">
+                    <context link="attempt session" field="owning_lib"/>
+                </retrieve>
+                <update permission="ADMIN_URL_VERIFY">
+                    <context link="attempt session" field="owning_lib"/>
+                </update>
+                <delete permission="ADMIN_URL_VERIFY">
+                    <context link="attempt session" field="owning_lib"/>
+                </delete>
+            </actions>
+        </permacrud>
+
+    </class>
+
+    <class
+        id="uvfs"
+        controller="open-ils.cstore open-ils.pcrud"
+        oils_obj:fieldmapper="url_verify::filter_set"
+        oils_persist:tablename="url_verify.filter_set"
+        reporter:label="URL Verification Filter Set"
+    >
+        <fields oils_persist:primary="id" oils_persist:sequence="url_verify.url_verification_id_seq">
+            <field reporter:label="ID" name="id" reporter:datatype="id"/>
+            <field reporter:label="Name" name="name" reporter:datatype="text"/>
+			<field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit"/>
+			<field reporter:label="Creator" name="creator" reporter:datatype="link"/>
+            <field reporter:label="Create Time" name="create_time" reporter:datatype="timestamp"/>
+			<field reporter:label="Filter" name="filter" reporter:datatype="text"/>
+        </fields>
+
+        <links>
+            <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
+            <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+        </links>
+
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <retrieve permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <update permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+                <delete permission="ADMIN_URL_VERIFY" context_field="owning_lib"/>
+            </actions>
+        </permacrud>
+
+    </class>
+
 	<class id="cmrtm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::marc21_rec_type_map" oils_persist:tablename="config.marc21_rec_type_map" reporter:label="MARC21 Record Type Map" oils_persist:field_safe="true">
 		<fields oils_persist:primary="code">
 			<field reporter:label="Code" name="code" reporter:datatype="id"/>
diff --git a/Open-ILS/src/extras/Makefile.install b/Open-ILS/src/extras/Makefile.install
index 60c8d66..344d417 100644
--- a/Open-ILS/src/extras/Makefile.install
+++ b/Open-ILS/src/extras/Makefile.install
@@ -219,7 +219,8 @@ CPAN_MODULES = \
 	Library::CallNumber::LC \
 	Net::Z3950::Simple2ZOOM \
 	Template::Plugin::POSIX \
-	SRU
+	SRU \
+	Rose::URI
 
 # More chronically unpackaged CPAN modules (available in Squeeze though)
 CPAN_MODULES_MORE = \
diff --git a/Open-ILS/src/sql/Pg/075.schema.url_verify.sql b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
new file mode 100644
index 0000000..753c769
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/075.schema.url_verify.sql
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2012  Equinox Software, Inc.
+ * Mike Rylander <miker at esilibrary.com>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ */
+
+BEGIN;
+
+DROP SCHEMA IF EXISTS url_verify CASCADE;
+
+CREATE SCHEMA url_verify;
+
+CREATE TABLE url_verify.session (
+    id          SERIAL                      PRIMARY KEY,
+    name        TEXT                        NOT NULL,
+    owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+    creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    container   INT                         NOT NULL REFERENCES container.biblio_record_entry_bucket (id) DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    search      TEXT                        NOT NULL,
+    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+);
+
+CREATE TABLE url_verify.url_selector (
+    id      SERIAL  PRIMARY KEY,
+    xpath   TEXT    NOT NULL,
+    session INT     NOT NULL REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT tag_once_per_sess UNIQUE (xpath, session)
+);
+
+CREATE TABLE url_verify.url (
+    id              SERIAL  PRIMARY KEY,
+    redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
+    item            INT     NOT NULL REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
+    url_selector    INT     NOT NULL REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
+    tag             TEXT    NOT NULL,
+    subfield        TEXT    NOT NULL,
+    ord             INT     NOT NULL, -- ordinal position of this url within the record as found by url_selector, for later update
+    full_url        TEXT    NOT NULL,
+    scheme          TEXT,
+    username        TEXT,
+    password        TEXT,
+    host            TEXT,
+    domain          TEXT,
+    tld             TEXT,
+    port            TEXT,
+    path            TEXT,
+    page            TEXT,
+    query           TEXT,
+    fragment        TEXT,
+    CONSTRAINT redirect_or_from_item CHECK (
+        redirect_from IS NOT NULL OR (
+            item         IS NOT NULL AND
+            url_selector IS NOT NULL AND
+            tag          IS NOT NULL AND
+            subfield     IS NOT NULL AND
+            ord          IS NOT NULL
+        )
+    )
+);
+
+CREATE TABLE url_verify.verification_attempt (
+    id          SERIAL                      PRIMARY KEY,
+    usr         INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    session     INT                         NOT NULL REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
+    start_time  TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    finish_time TIMESTAMP WITH TIME ZONE
+);
+ 
+CREATE TABLE url_verify.url_verification (
+    id          SERIAL                      PRIMARY KEY,
+    url         INT                         NOT NULL REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED,
+    attempt     INT                         NOT NULL REFERENCES url_verify.verification_attempt (id) DEFERRABLE INITIALLY DEFERRED,
+    req_time    TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    res_time    TIMESTAMP WITH TIME ZONE, 
+    res_code    INT                         CHECK (res_code BETWEEN 100 AND 599),
+    res_text    TEXT, 
+    redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
+);
+
+CREATE TABLE url_verify.filter_set (
+    id          SERIAL                      PRIMARY KEY,
+    name        TEXT                        NOT NULL,
+    owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+    creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    filter      TEXT                        NOT NULL,
+    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+);
+ 
+COMMIT;
+
diff --git a/Open-ILS/src/sql/Pg/076.functions.url_verify.sql b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
new file mode 100644
index 0000000..23dedab
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/076.functions.url_verify.sql
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2012  Equinox Software, Inc.
+ * Mike Rylander <miker at esilibrary.com>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ */
+
+BEGIN;
+
+CREATE OR REPLACE FUNCTION url_verify.parse_url (url_in TEXT) RETURNS url_verify.url AS $$
+
+use Rose::URI;
+
+my $url_in = shift;
+my $url = Rose::URI->new($url_in);
+
+my %parts = map { $_ => $url->$_ } qw/scheme username password host port path query fragment/;
+
+$parts{full_url} = $url_in;
+($parts{domain} = $parts{host}) =~ s/^[^.]+\.//;
+($parts{tld} = $parts{domain}) =~ s/(?:[^.]+\.)+//;
+($parts{page} = $parts{path}) =~ s#(?:[^/]*/)+##;
+
+return \%parts;
+
+$$ LANGUAGE PLPERLU;
+
+CREATE OR REPLACE FUNCTION url_verify.ingest_url () RETURNS TRIGGER AS $$
+DECLARE
+    tmp_row url_verify.url%ROWTYPE;
+BEGIN
+    SELECT * INTO tmp_row FROM url_verify.parse_url(NEW.full_url);
+
+    NEW.scheme          := tmp_row.scheme;
+    NEW.username        := tmp_row.username;
+    NEW.password        := tmp_row.password;
+    NEW.host            := tmp_row.host;
+    NEW.domain          := tmp_row.domain;
+    NEW.tld             := tmp_row.tld;
+    NEW.port            := tmp_row.port;
+    NEW.path            := tmp_row.path;
+    NEW.page            := tmp_row.page;
+    NEW.query           := tmp_row.query;
+    NEW.fragment        := tmp_row.fragment;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER ingest_url_tgr
+    BEFORE INSERT ON url_verify.url
+    FOR EACH ROW EXECUTE PROCEDURE url_verify.ingest_url(); 
+
+COMMIT;
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
new file mode 100644
index 0000000..7850b49
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.url_verify.sql
@@ -0,0 +1,82 @@
+DROP SCHEMA IF EXISTS url_verify CASCADE;
+
+CREATE SCHEMA url_verify;
+
+CREATE TABLE url_verify.session (
+    id          SERIAL                      PRIMARY KEY,
+    name        TEXT                        NOT NULL,
+    owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+    creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    container   INT                         NOT NULL REFERENCES container.biblio_record_entry_bucket (id) DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    search      TEXT                        NOT NULL,
+    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+);
+
+CREATE TABLE url_verify.url_selector (
+    id      SERIAL  PRIMARY KEY,
+    xpath   TEXT    NOT NULL,
+    session INT     NOT NULL REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
+    CONSTRAINT tag_once_per_sess UNIQUE (xpath, session)
+);
+
+CREATE TABLE url_verify.url (
+    id              SERIAL  PRIMARY KEY,
+    redirect_from   INT     REFERENCES url_verify.url(id) DEFERRABLE INITIALLY DEFERRED,
+    item            INT     NOT NULL REFERENCES container.biblio_record_entry_bucket_item (id) DEFERRABLE INITIALLY DEFERRED,
+    url_selector    INT     NOT NULL REFERENCES url_verify.url_selector (id) DEFERRABLE INITIALLY DEFERRED,
+    tag             TEXT    NOT NULL,
+    subfield        TEXT    NOT NULL,
+    ord             INT     NOT NULL, -- ordinal position of this url within the record as found by url_selector, for later update
+    full_url        TEXT    NOT NULL,
+    scheme          TEXT,
+    username        TEXT,
+    password        TEXT,
+    host            TEXT,
+    domain          TEXT,
+    tld             TEXT,
+    port            TEXT,
+    path            TEXT,
+    page            TEXT,
+    query           TEXT,
+    fragment        TEXT,
+    CONSTRAINT redirect_or_from_item CHECK (
+        redirect_from IS NOT NULL OR (
+            item         IS NOT NULL AND
+            url_selector IS NOT NULL AND
+            tag          IS NOT NULL AND
+            subfield     IS NOT NULL AND
+            ord          IS NOT NULL
+        )
+    )
+);
+
+CREATE TABLE url_verify.verification_attempt (
+    id          SERIAL                      PRIMARY KEY,
+    usr         INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    session     INT                         NOT NULL REFERENCES url_verify.session (id) DEFERRABLE INITIALLY DEFERRED,
+    start_time  TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    finish_time TIMESTAMP WITH TIME ZONE
+);
+ 
+CREATE TABLE url_verify.url_verification (
+    id          SERIAL                      PRIMARY KEY,
+    url         INT                         NOT NULL REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED,
+    attempt     INT                         NOT NULL REFERENCES url_verify.verification_attempt (id) DEFERRABLE INITIALLY DEFERRED,
+    req_time    TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    res_time    TIMESTAMP WITH TIME ZONE, 
+    res_code    INT                         CHECK (res_code BETWEEN 100 AND 599),
+    res_text    TEXT, 
+    redirect_to INT                         REFERENCES url_verify.url (id) DEFERRABLE INITIALLY DEFERRED -- if redirected
+);
+
+CREATE TABLE url_verify.filter_set (
+    id          SERIAL                      PRIMARY KEY,
+    name        TEXT                        NOT NULL,
+    owning_lib  INT                         NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
+    creator     INT                         NOT NULL REFERENCES actor.usr (id) DEFERRABLE INITIALLY DEFERRED,
+    create_time TIMESTAMP WITH TIME ZONE    NOT NULL DEFAULT NOW(),
+    filter      TEXT                        NOT NULL,
+    CONSTRAINT name_once_per_lib UNIQUE (name, owning_lib)
+);
+ 
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
new file mode 100644
index 0000000..2087794
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.functions.url_verify.sql
@@ -0,0 +1,22 @@
+BEGIN;
+
+CREATE OR REPLACE FUNCTION url_verify.parse_url (url_in TEXT) RETURNS url_verify.url AS $$
+
+use Rose::URI;
+
+my $url_in = shift;
+my $url = Rose::URI->new($url_in);
+
+my %parts = map { $_ => $url->$_ } qw/scheme username password host port path query fragment/;
+
+$parts{full_url} = $url_in;
+($parts{domain} = $parts{host}) =~ s/^[^.]+\.//;
+($parts{tld} = $parts{domain}) =~ s/(?:[^.]+\.)+//;
+($parts{page} = $parts{path}) =~ s#(?:[^/]*/)+##;
+
+return \%parts;
+
+$$ LANGUAGE PLPERLU;
+
+COMMIT;
+

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/fm_IDL.xml                       |  342 ++++++++
 Open-ILS/examples/opensrf.xml.example              |   22 +
 Open-ILS/examples/opensrf_core.xml.example         |    1 +
 Open-ILS/src/extras/Makefile.install               |    3 +-
 Open-ILS/src/extras/ils_events.xml                 |   10 +
 Open-ILS/src/perlmods/lib/OpenILS/Application.pm   |   14 +
 .../perlmods/lib/OpenILS/Application/AppUtils.pm   |   14 +-
 .../perlmods/lib/OpenILS/Application/Flattener.pm  |    2 +-
 .../perlmods/lib/OpenILS/Application/Trigger.pm    |    6 +-
 .../lib/OpenILS/Application/Trigger/Event.pm       |   12 +-
 .../perlmods/lib/OpenILS/Application/URLVerify.pm  |  872 ++++++++++++++++++++
 .../src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm  |   14 -
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   19 +-
 Open-ILS/src/sql/Pg/075.schema.url_verify.sql      |   93 +++
 Open-ILS/src/sql/Pg/076.functions.url_verify.sql   |  114 +++
 Open-ILS/src/sql/Pg/800.fkeys.sql                  |   13 +
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  154 ++++-
 Open-ILS/src/sql/Pg/sql_file_manifest              |    2 +
 .../src/sql/Pg/upgrade/0752.schema.url_verify.sql  |   96 +++
 .../sql/Pg/upgrade/0753.functions.url_verify.sql   |  100 +++
 .../src/sql/Pg/upgrade/0754.data.url_verify.sql    |  195 +++++
 Open-ILS/src/templates/base.tt2                    |    4 +
 .../src/templates/url_verify/create_session.tt2    |  136 +++
 .../src/templates/url_verify/review_attempt.tt2    |   81 ++
 Open-ILS/src/templates/url_verify/select_urls.tt2  |   76 ++
 Open-ILS/src/templates/url_verify/sessions.tt2     |   51 ++
 Open-ILS/web/js/dojo/openils/FlattenerStore.js     |   92 +--
 .../web/js/dojo/openils/URLVerify/CreateSession.js |  441 ++++++++++
 .../web/js/dojo/openils/URLVerify/ReviewAttempt.js |  107 +++
 .../web/js/dojo/openils/URLVerify/SelectURLs.js    |  102 +++
 Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js |   74 ++
 Open-ILS/web/js/dojo/openils/URLVerify/Verify.js   |   59 ++
 .../web/js/dojo/openils/URLVerify/nls/URLVerify.js |   25 +
 Open-ILS/web/js/dojo/openils/XUL.js                |   12 +-
 .../js/dojo/openils/widget/FilteringTreeSelect.js  |    1 +
 .../dojo/openils/widget/FlattenerFilterDialog.js   |   20 +-
 .../web/js/dojo/openils/widget/FlattenerGrid.js    |  159 +++-
 .../web/js/dojo/openils/widget/PCrudFilterPane.js  |  487 ++++++++++-
 .../js/dojo/openils/widget/nls/FlattenerGrid.js    |    6 +
 .../js/dojo/openils/widget/nls/PCrudFilterPane.js  |   10 +-
 Open-ILS/web/opac/locale/en-US/lang.dtd            |    2 +
 .../xul/staff_client/chrome/content/main/menu.js   |   28 +-
 .../chrome/content/main/menu_frame_menus.xul       |    3 +
 .../chrome/locale/en-US/offline.properties         |    1 +
 docs/TechRef/LinkChecker.txt                       |  113 +++
 45 files changed, 4021 insertions(+), 167 deletions(-)
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/URLVerify.pm
 create mode 100644 Open-ILS/src/sql/Pg/075.schema.url_verify.sql
 create mode 100644 Open-ILS/src/sql/Pg/076.functions.url_verify.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/0752.schema.url_verify.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/0753.functions.url_verify.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/0754.data.url_verify.sql
 create mode 100644 Open-ILS/src/templates/url_verify/create_session.tt2
 create mode 100644 Open-ILS/src/templates/url_verify/review_attempt.tt2
 create mode 100644 Open-ILS/src/templates/url_verify/select_urls.tt2
 create mode 100644 Open-ILS/src/templates/url_verify/sessions.tt2
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/CreateSession.js
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/ReviewAttempt.js
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/SelectURLs.js
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/Sessions.js
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/Verify.js
 create mode 100644 Open-ILS/web/js/dojo/openils/URLVerify/nls/URLVerify.js
 create mode 100644 Open-ILS/web/js/dojo/openils/widget/nls/FlattenerGrid.js
 create mode 100644 docs/TechRef/LinkChecker.txt


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list