webstaff: Offline mode
authorMike Rylander <mrylander@gmail.com>
Mon, 20 Mar 2017 20:38:15 +0000 (16:38 -0400)
committerMike Rylander <mrylander@gmail.com>
Tue, 30 May 2017 16:30:01 +0000 (12:30 -0400)
Here is implemented an offline mode interface for the web staff client.

It is made available during both network and server outages by using the
UpUp[1] service worker wrapper.

We leverage Lovefield[2] for local storage of library settings, configuration
data, offline transactions, and the standalone offline block list.

In order to make use of the offline interface, users should first log into
the web staff client and navigate to the "Search -> Search for Patrons"
interface, perform a search, select a user from the results, and open the
Patron Editor interface.  This will allow the offline interface to collect
all the relevant configuration information for the workstation.  In addition,
the offline interface available from the Circulation menu provides a "Download
block list" button when accessed while logged in.

[1]https://www.talater.com/upup/
[2]https://google.github.io/lovefield/

Signed-off-by: Mike Rylander <mrylander@gmail.com>
26 files changed:
Open-ILS/src/offline/offline.pl
Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2
Open-ILS/src/templates/staff/base_js.tt2
Open-ILS/src/templates/staff/circ/patron/reg_actions.tt2
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/src/templates/staff/offline-interface.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_offline_checkin.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_offline_checkout.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_offline_in_house_use.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_offline_renew.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_datetime.tt2
Open-ILS/web/LICENSE.UpUp [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
Open-ILS/web/js/ui/default/staff/offline.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/env.js
Open-ILS/web/js/ui/default/staff/services/file.js
Open-ILS/web/js/ui/default/staff/services/hatch.js
Open-ILS/web/js/ui/default/staff/services/idl.js
Open-ILS/web/js/ui/default/staff/services/lovefield.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/org.js
Open-ILS/web/js/ui/default/staff/services/print.js
Open-ILS/web/js/ui/default/staff/services/startup.js
Open-ILS/web/js/ui/default/staff/services/ui.js
Open-ILS/web/upup.min.js [new file with mode: 0644]
Open-ILS/web/upup.sw.min.js [new file with mode: 0644]

index f7719d5..4bc9e51 100755 (executable)
@@ -40,6 +40,7 @@ do '##CONFIG##/offline-config.pl';
 my $cgi                        = new CGI;
 my $basedir            = $config{base_dir} || die "Offline config error: no base_dir defined\n";
 my $bootstrap  = $config{bootstrap} || die "Offline config error: no bootstrap defined\n";
+my $webclient  = $cgi->param('wc');
 my $wsname             = $cgi->param('ws');
 my $org                        = $cgi->param('org');
 my $authtoken  = $cgi->param('ses') || "";
@@ -877,6 +878,7 @@ sub ol_handle_register {
 
        my $barcode = $command->{user}->{card}->{barcode};
        delete $command->{user}->{card}; 
+       delete $command->{user}->{cards} if $command->{user}->{cards}; 
 
        $logger->info("offline: creating new user with barcode $barcode");
 
@@ -914,8 +916,10 @@ sub ol_handle_register {
        delete $command->{user}->{survey_responses};
        $actor->survey_responses(\@sresp) if @sresp;
 
+    my $bid = undef;
        # extract the billing address
        if( my $addr = $command->{user}->{billing_address} ) {
+        $bid = $command->{user}->{billing_address}->{id};
                $billing_address = Fieldmapper::actor::user_address->new;
                $billing_address->$_($addr->{$_}) for keys %$addr;
                $billing_address->isnew(1);
@@ -925,15 +929,26 @@ sub ol_handle_register {
                $logger->debug("offline: read billing address ".$billing_address->street1);
        }
 
+    my $mid = undef;
        # extract the mailing address
        if( my $addr = $command->{user}->{mailing_address} ) {
-               $mailing_address = Fieldmapper::actor::user_address->new;
-               $mailing_address->$_($addr->{$_}) for keys %$addr;
-               $mailing_address->isnew(1);
-               $mailing_address->id(-2);
-               $mailing_address->usr(-1);
+        $mid = $command->{user}->{mailing_address}->{id};
+        if ($webclient && $mid != $bid) {
+                   $mailing_address = Fieldmapper::actor::user_address->new;
+                   $mailing_address->$_($addr->{$_}) for keys %$addr;
+                   $mailing_address->isnew(1);
+                   $mailing_address->id(-2);
+                   $mailing_address->usr(-1);
+                   $logger->debug("offline: read mailing address ".$mailing_address->street1);
+        } elsif (!$webclient) {
+                   $mailing_address = Fieldmapper::actor::user_address->new;
+                   $mailing_address->$_($addr->{$_}) for keys %$addr;
+                   $mailing_address->isnew(1);
+                   $mailing_address->id(-2);
+                   $mailing_address->usr(-1);
+                   $logger->debug("offline: read mailing address ".$mailing_address->street1);
+        }
                delete $command->{user}->{mailing_address};
-               $logger->debug("offline: read mailing address ".$mailing_address->street1);
        }
 
        # make sure we have values for both
@@ -946,9 +961,23 @@ sub ol_handle_register {
 
        push( @{$actor->addresses}, $billing_address ) 
                unless $billing_address->id eq $mailing_address->id;
+
+    my $aid = -3;
+    for my $a ( @{$command->{user}->{addresses}} ) {
+        next if ($a->{id} == $bid || $a->{id} == $mid);
+       # extract all other addresses
+        my $addr = Fieldmapper::actor::user_address->new;
+           $addr->$_($a->{$_}) for keys %$a;
+               $addr->isnew(1);
+       $addr->id($aid);
+           $addr->usr(-1);
+       $logger->debug("offline: read other address ".$addr->street1);
+        $aid--;
+        push( @{$actor->addresses}, $addr );
+    }
        
        # pull all of the rest of the data from the command blob
-       $actor->$_( $command->{user}->{$_} ) for keys %{$command->{user}};
+       $actor->$_( $command->{user}->{$_} ) for grep { $_ ne 'addresses' } keys %{$command->{user}};
 
     # calculate the expire date for the patron based on the profile group
     my ($grp) = grep {$_->id == $actor->profile} @$user_groups;
index b049c56..2fa6802 100644 (file)
           <option value="renew">[% l('Renew') %]</option>
           <option value="transit_list">[% l('Transit List') %]</option>
           <option value="transit_slip">[% l('Transit Slip') %]</option>
+          <option value="offline_checkout">[% l('Offline Checkout') %]</option>
+          <option value="offline_renew">[% l('Offline Renew') %]</option>
+          <option value="offline_checkin">[% l('Offline Checkin') %]</option>
+          <option value="offline_in_house_use">[% l('Offline In-house Use') %]</option>
         </select>
         <label for="print_context">[% l('Force Printer Context') %]</label>
         <select class="form-control" ng-model="print.template_context">
index 82b662e..351a246 100644 (file)
@@ -1,3 +1,74 @@
+<script src="/upup.min.js"></script>
+<script>
+UpUp.start({
+  'content-url': '[% ctx.base_path %]/staff/offline-interface',
+  'cache-version': '[% USE date(format = '%Y-%m-%d'); date.format; %]',
+  'service-worker-url': '/upup.sw.min.js',
+  'assets': [
+    '/IDL2js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/bootstrap.min.css',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/hotkeys.min.css',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/ngToast.min.css',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/ngToast-animations.min.css',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/tree-control.css',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/css/tree-control-attribute.css',
+    '[% ctx.base_path %]/staff/css/print.css',
+    '[% ctx.base_path %]/staff/css/cat.css',
+    '[% ctx.base_path %]/staff/css/style.css',
+    '[% ctx.base_path %]/staff/css/circ.css',
+    '[% ctx.media_prefix %]/js/dojo/opensrf/md5.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/jquery.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-route.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ui-bootstrap-tpls.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/hotkeys.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-file-saver.bundle.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-location-update.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-animate.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-sanitize.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-cookies.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ngToast.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-tree-control.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/iframeResizer.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ng-order-object-by.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/js/lovefield.min.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/build/fonts/glyphicons-halflings-regular.woff',
+    '[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js',
+    '[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js',
+    '[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_ws.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/core.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/strings.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/event.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/net.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/env.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/org.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/hatch.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/print.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/audio.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/coresvc.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/user.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/statusbar.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/date.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/op_change.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js',
+    '[% ctx.media_prefix %]/js/ui/default/staff/offline.js',
+    '[% ctx.base_path %]/staff/share/t_alert_dialog',
+    '[% ctx.base_path %]/staff/share/t_datetime',
+    '[% ctx.base_path %]/staff/share/print_templates/t_offline_in_house_use',
+    '[% ctx.base_path %]/staff/share/print_templates/t_offline_checkout',
+    '[% ctx.base_path %]/staff/share/print_templates/t_offline_checkin',
+    '[% ctx.base_path %]/staff/share/print_templates/t_offline_renew',
+    '/images/question-mark.png'
+  ]
+});
+</script>
+
 <script src="/IDL2js"></script>
 <script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
 
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/date.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/op_change.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js"></script>
 
 [% ELSE %]
 
     s.OP_CHANGE_PERM_MESSAGE = "[% l('Another staff member with the above permission may authorize this specific action.  Please notify your library administrator if you need this permission.  If you feel you have received this exception in error, please inform your friendly Evergreen developers or helpdesk staff of the above permission.') %]";
     s.PERM_OP_CHANGE_SUCCESS = "[% l('Permission Override Login Succeeded') %]";
     s.PERM_OP_CHANGE_FAILURE = "[% l('Permission Override Login Failed') %]";
+    s.OFFLINE_SESSION_DESC = "[% l('Offline session description') %]";
+    s.OFFLINE_SESSION_CREATE_FAILED = "[% l('Offline session creation failed') %]";
+    s.OFFLINE_SESSION_PROCESSING_FAILED = "[% l('Offline session processing failed') %]";
+    s.OFFLINE_SESSION_UPLOAD_FAILED = "[% l('Offline transaction upload failed') %]";
+    s.PATRON_NOT_FOUND = "[% l('Patron not found') %]";
+    s.PATRON_BLOCKED = "[% l('Patron blocked') %]";
+    s.BAD_BARCODE = "[% l('Bad item barcode') %]";
+    s.ITEM_NOT_FOUND = "[% l('Item not found') %]";
   }]);
 </script>
 
index 1508018..c5aabcb 100644 (file)
@@ -27,7 +27,7 @@
       ng-disabled="edit_passthru.hide_save_actions()"
       ng-click="edit_passthru.save()">[% l('Save') %]</button>
   </span>
-  <span class="pad-all-min">
+  <span ng-if="!offline" class="pad-all-min">
     <button type="button" class="btn btn-default"
       ng-disabled="edit_passthru.hide_save_actions()"
       ng_click="edit_passthru.save({clone:true})">[% l('Save & Clone') %]</button>
index 4f4cc6e..5e873d1 100644 (file)
@@ -1,7 +1,7 @@
 [% DOC_IMG = '/images/question-mark.png' %]
 
 <!-- register banner -->
-<div ng-if="!patron_id" class='patron-reg-fixed-bar'>
+<div ng-if="!patron_id" ng-class='{"patron-reg-fixed-bar":!offline}'>
 
   <div class="container-fluid" style="text-align:center">
     <div class="alert alert-info alert-less-pad strong-text-2">
@@ -161,7 +161,7 @@ within the "form" by name for validation.
   <div class="col-md-6 patron-reg-example">
       <button class="btn btn-default" ng-show="!patron.isnew"
         ng-click="replace_card()">[% l('Replace Barcode') %]</button>
-      <button class="btn btn-default" 
+      <button class="btn btn-default" ng-if="!patron.isnew" 
         ng-click="cards_dialog()">[% l('See All') %]</button>
       <div ng-show="dupe_barcode" class="patron-reg-validation-alert">
         <span>[% l('Barcode is already in use') %]</span>
@@ -443,7 +443,7 @@ within the "form" by name for validation.
     </div>
   </div>
   <div class="col-md-3">
-    <button class="btn btn-default" ng-disabled="!perms.CREATE_USER_GROUP_LINK"
+    <button class="btn btn-default" ng-if="!offline" ng-disabled="!perms.CREATE_USER_GROUP_LINK"
       ng-click="secondary_groups_dialog()">[% l('Secondary Groups') %]</button>
   </div> 
 </div>
@@ -559,6 +559,8 @@ within the "form" by name for validation.
   </div>
 </div>
 
+<div ng-if="!offline">
+
 <div class="alert alert-success row" role="alert">
   <div class="col-md-6">[% l('User Settings') %]</div>
 </div>
@@ -686,6 +688,8 @@ within the "form" by name for validation.
   </div>
 </div>
 
+</div> <!-- end offline test -->
+
 <!-- addresses -->
 
 <div ng-repeat="addr in patron.addresses">
index 12cbcb2..e443d3a 100644 (file)
               <span>[% l('Reprint Last Receipt') %]</span>
             </a>
           </li>
+          <li class="divider"></li>
+          <li>
+            <a href="./offline-interface/session" target="_self">
+              <span class="glyphicon glyphicon-alert"></span>
+              <span>[% l('Offline Circulation') %]</span>
+            </a>
+          </li>
         </ul>
       </li><!-- circ -->
 
diff --git a/Open-ILS/src/templates/staff/offline-interface.tt2 b/Open-ILS/src/templates/staff/offline-interface.tt2
new file mode 100644 (file)
index 0000000..357ead2
--- /dev/null
@@ -0,0 +1,571 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Offline"); 
+  ctx.page_app = "egOffline";
+%]
+
+
+<script type="text/ng-template" id="offline-template">
+
+<div class="row">
+  <div class="col-md-12">
+    <div class="input-group">
+      <div class="input-group-addon"><b>[% l('Workstation') %]</b></div>
+      <div class="input-group-addon">
+        <select class="form-control" required
+          ng-model="workstation"
+          ng-options="ws.id as ws.name for ws in workstations"></select>
+      </div>
+      <div class="input-group-addon"><b>[% l('Working location') %]</b></div>
+      <div class="input-group-addon">
+        <eg-org-selector sticky-setting="eg.org.offline_location" selected="org"></eg-org-selector>
+      </div>
+      <div class="input-group-addon">
+        <button
+          class="btn btn-primary"
+          ng-disabled="all_xact.length == 0 || active_tab == 'register'"
+          ng-click="save()">
+            [% l('Save Transactions') %]
+        </button>
+        <button
+          class="btn btn-default"
+          ng-if="logged_in"
+          ng-click="downloadBlockList()">
+            [% l('Download block list') %]
+        </button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="row col-md-12 pad-vert">
+  <div class="col-md-12">
+    <uib-tabset active="active_tab">
+      <!-- note that non-numeric index values must be enclosed in single-quotes,
+           otherwise selecting the active table won't work cleanly -->
+      <uib-tab index="'checkout'" heading="[% l('Checkout') %]">
+
+        <div class="row">
+
+          <!-- left-hand side -->
+          <div class="col-md-6" style="border-right:solid 1px;">
+            <div class="row">
+              <div class="col-md-1"></div>
+              <div class="col-md-4">
+                [% l('Patron barcode:') %]
+              </div>
+              <div class="col-md-7">
+                <input class="form-control" type="text" ng-model="checkout.patron_barcode" next-on-enter="co_barcode"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1">
+                <input type="radio" ng-model="barcode_type" value="barcode"/>
+              </div>
+              <div class="col-md-4">
+                [% l('Item Barcode:') %]
+              </div>
+              <div class="col-md-7">
+                <input id="co_barcode"
+                    class="form-control"
+                    ng-init="barcode_type = 'barcode'"
+                    ng-disabled="barcode_type != 'barcode'"
+                    type="text"
+                    ng-model="checkout.barcode"
+                    eg-enter="!notEnough('checkout') && add('checkout')"
+                />
+              </div>
+            </div>
+
+            <div class="row">
+              <div class="col-md-1">
+                <input type="radio" ng-model="barcode_type" value="noncat"/>
+              </div>
+              <div class="col-md-4">
+                [% l('Non-cataloged Type:') %]
+              </div>
+              <div class="col-md-5">
+                <select
+                    class="form-control"
+                    ng-disabled="barcode_type != 'noncat'"
+                    ng-options="nct.id() as nct.name() for nct in noncats"
+                    ng-model="checkout.noncat_type"
+                >
+                  <option value="">[% l('Select Non-cataloged Type') %]</option>
+                </select>
+              </div>
+              <div class="col-md-2">
+                <input
+                    class="form-control"
+                    ng-disabled="barcode_type != 'noncat'"
+                    type="number"
+                    min="1"
+                    max="100"
+                    ng-model="checkout.noncat_count"
+                />
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1"></div>
+              <div class="col-md-4">
+                [% l('Due Date:') %]
+              </div>
+              <div class="col-md-4">
+                <eg-date-input id="co_duedate" ng-model="shared.due_date"></eg-date-input>
+              </div>
+              <div class="col-md-3">
+                <select class="form-control" ng-model="shared.due_date_offset" ng-change="resetDueDate()">
+                  <option value="">[% l('No Offset') %]</option>
+                  <option value="3">[% l('3 days') %]</option>
+                  <option value="7">[% l('7 days') %]</option>
+                  <option value="14">[% l('14 days') %]</option>
+                  <option value="30">[% l('30 days') %]</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-6">
+                <button class="btn btn-warning" ng-click="clear('checkout')">[% l('Clear') %]</button>
+              </div>
+              <div class="col-md-6">
+                <input id="do_print_co" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+                <label for="do_print_co">[% l('Print receipt') %]</label>
+                <button class="btn btn-primary pull-right" ng-disabled="notEnough('checkout')" ng-click="add('checkout')">[% l('Checkout') %]</button>
+              </div>
+            </div>
+
+          </div>
+
+          <!-- right-hand side -->
+          <div class="col-md-6 container">
+            <table class="table">
+              <thead>
+                <tr>
+                  <th>[% l('Patron barcode') %]</th>
+                  <th>[% l('Item barcode') %]</th>
+                  <th>[% l('Due date') %]</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr ng-repeat="xact in xact_page.checkout track by $index">
+                  <td>{{xact.patron_barcode}}</td>
+                  <td>{{xact.barcode}}</td>
+                  <td>{{xact.due_date | date:'shortDate'}}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+        </div>
+
+      </uib-tab>
+      <uib-tab index="'renew'" heading="[% l('Renew') %]">
+
+        <div class="row">
+
+          <!-- left-hand side -->
+          <div class="col-md-6" style="border-right:solid 1px;">
+            <div class="row">
+              <div class="col-md-1"></div>
+              <div class="col-md-4">
+                [% l('Patron barcode:') %]
+              </div>
+              <div class="col-md-7">
+                <input class="form-control" type="text" ng-model="renew.patron_barcode" next-on-enter="re_barcode"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1"></div>
+              <div class="col-md-4">
+                [% l('Item Barcode:') %]
+              </div>
+              <div class="col-md-7">
+                <input class="form-control" type="text" ng-model="renew.barcode" id="re_barcode" eg-enter="!notEnough('renew') && add('renew')"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1"></div>
+              <div class="col-md-4">
+                [% l('Due Date:') %]
+              </div>
+              <div class="col-md-4">
+                <eg-date-input ng-model="shared.due_date"></eg-date-input>
+              </div>
+              <div class="col-md-3">
+                <select class="form-control" ng-model="shared.due_date_offset" ng-change="resetDueDate()">
+                  <option value="">[% l('No Offset') %]</option>
+                  <option value="3">[% l('3 days') %]</option>
+                  <option value="7">[% l('7 days') %]</option>
+                  <option value="14">[% l('14 days') %]</option>
+                  <option value="30">[% l('30 days') %]</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-6">
+                <button class="btn btn-warning" ng-click="clear('renew')">[% l('Clear') %]</button>
+              </div>
+              <div class="col-md-6">
+                <input id="do_print_r" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+                <label for="do_print_r">[% l('Print receipt') %]</label>
+                <button class="btn btn-primary pull-right" ng-disabled="notEnough('renew')" ng-click="add('renew')">[% l('Renew') %]</button>
+              </div>
+            </div>
+
+          </div>
+
+          <!-- right-hand side -->
+          <div class="col-md-6 container">
+            <table class="table">
+              <thead>
+                <tr>
+                  <th>[% l('Patron barcode') %]</th>
+                  <th>[% l('Item barcode') %]</th>
+                  <th>[% l('Due date') %]</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr ng-repeat="xact in xact_page.renew track by $index">
+                  <td>{{xact.patron_barcode}}</td>
+                  <td>{{xact.barcode}}</td>
+                  <td>{{xact.due_date | date:'shortDate'}}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+        </div>
+
+      </uib-tab>
+      <uib-tab index="'in_house_use'" heading="[% l('In-house Use') %]">
+
+        <div class="row">
+
+          <!-- left-hand side -->
+          <div class="col-md-6 container" style="border-right:solid 1px;">
+            <div class="row">
+              <div class="col-md-1"></div>
+              <div class="col-md-5">
+                [% l('Item Barcode:') %]
+              </div>
+              <div class="col-md-6">
+                <input class="form-control" type="text" ng-model="in_house_use.barcode" next-on-enter="ihu_count"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1"></div>
+              <div class="col-md-5">
+                [% l('Use count:') %]
+              </div>
+              <div class="col-md-6">
+                <input class="form-control" type="number" min="1" max="100" ng-model="in_house_use.count" id="ihu_count"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-6">
+                <button class="btn btn-warning" ng-click="clear('in_house_use')">[% l('Clear') %]</button>
+              </div>
+              <div class="col-md-6">
+                <input id="do_print_ihu" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+                <label for="do_print_ihu">[% l('Print receipt') %]</label>
+                <button class="btn btn-primary pull-right" ng-disabled="notEnough('in_house_use')" ng-click="add('in_house_use')">[% l('Record Use') %]</button>
+              </div>
+            </div>
+
+          </div>
+
+          <!-- right-hand side -->
+          <div class="col-md-6 container">
+            <table class="table">
+              <thead>
+                <tr>
+                  <th>[% l('Item barcode') %]</th>
+                  <th>[% l('Use count') %]</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr ng-repeat="xact in xact_page.in_house_use track by $index">
+                  <td>{{xact.barcode}}</td>
+                  <td>{{xact.count}}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+        </div>
+
+      </uib-tab>
+      <uib-tab index="'checkin'" heading="[% l('Checkin') %]">
+
+        <div class="row">
+
+          <!-- left-hand side -->
+          <div class="col-md-6" style="border-right:solid 1px;">
+
+            <div class="row">
+              <div class="col-md-1"></div>
+              <div class="col-md-5">
+                [% l('Item Barcode:') %]
+              </div>
+              <div class="col-md-6">
+                <input class="form-control" type="text" ng-model="checkin.barcode" eg-enter="!notEnough('checkin') && add('checkin')"/>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-1"></div>
+              <div class="col-md-5">
+                [% l('Checkin Date:') %]
+              </div>
+              <div class="col-md-6">
+                <eg-date-input ng-model="checkin.backdate"></eg-date-input>
+              </div>
+            </div>
+
+            <div class="row pad-vert">
+              <div class="col-md-6">
+                <button class="btn btn-warning" ng-click="clear('checkin')">[% l('Clear') %]</button>
+              </div>
+              <div class="col-md-6">
+                <input id="do_print_ci" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+                <label for="do_print_ci">[% l('Print receipt') %]</label>
+                <button class="btn btn-primary pull-right" ng-disabled="notEnough('checkin')" ng-click="add('checkin')">[% l('Checkin') %]</button>
+                    
+              </div>
+            </div>
+
+          </div>
+
+          <!-- right-hand side -->
+          <div class="col-md-6 container">
+            <table class="table">
+              <thead>
+                <tr>
+                  <th>[% l('Item barcode') %]</th>
+                  <th>[% l('Effective Checkin date') %]</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr ng-repeat="xact in xact_page.checkin track by $index">
+                  <td>{{xact.barcode}}</td>
+                  <td>{{xact.backdate | date:'shortDate'}}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+        </div>
+
+      </uib-tab>
+      <uib-tab index="'register'" heading="[% l('Register Patron') %]">
+        <div ng-controller="PatronRegCtrl">
+          <div>[% INCLUDE 'staff/circ/patron/t_edit.tt2' %]</div>
+        </div>
+      </uib-tab>
+      <uib-tab ng-if="logged_in" index="'session'" heading="[% l('Session Management') %]">
+        <div class="col-md-12" ng-controller="OfflineSessionCtrl">
+          <uib-tabset active="active_session_tab">
+            <uib-tab index="'pending'" heading="[% l('Pending Transactions') %]">
+              <div class="row">
+                <div class="col-md-12 container">
+                  <button
+                    class="btn btn-default"
+                    ng-disabled="pending_xacts.length == 0"
+                    eg-line-exporter
+                    default-file-name="pending.xacts"
+                    json-array="pending_xacts"
+                  >[% l('Export Transactions') %]</button>
+                  <div class="btn-group">
+                    <span class="btn btn-default btn-file">
+                      [% l('Import Transactions') %]
+                      <input type="file" eg-file-reader container="imported_pending_xacts.data">
+                    </span>
+                  </div>
+                <button class="btn btn-warning pull-right" ng-click="clear_pending()">[% l('Clear Transactions') %]</button>
+                </div>
+              </div>
+              <div class="row">
+                <div class="col-md-12 container">
+                  <table class="table">
+                    <thead>
+                      <tr>
+                        <th>[% l('Type') %]</th>
+                        <th>[% l('Timestamp') %]</th>
+                        <th>[% l('Patron Barcode') %]</th>
+                        <th>[% l('Item Barcode') %]</th>
+                        <th>[% l('Non-cataloged Type') %]</th>
+                        <th>[% l('Checkout Date') %]</th>
+                        <th>[% l('Due Date') %]</th>
+                        <th>[% l('Checkin Date') %]</th>
+                        <th>[% l('First Name') %]</th>
+                        <th>[% l('Last Name') %]</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr ng-repeat="xact in pending_xacts track by $index">
+                        <td>{{xact.type}}</td>
+                        <td>{{createDate(xact.timestamp, true) | date:'short'}}</td>
+                        <td>{{xact.patron_barcode || xact.user.card.barcode}}</td>
+                        <td>{{xact.barcode}}</td>
+                        <td>{{lookupNoncatTypeName(xact.noncat_type)}}</td>
+                        <td>{{createDate(xact.checkout_time) | date:'short'}}</td>
+                        <td>{{createDate(xact.due_date) | date:'shortDate'}}</td>
+                        <td>{{createDate(xact.backdate) | date:'shortDate'}}</td>
+                        <td>{{xact.user.first_given_name}}</td>
+                        <td>{{xact.user.family_name}}</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </uib-tab>
+            <uib-tab index="'offline_sessions'" heading="[% l('Offline Sessions') %]">
+              <div class="row">
+                <div class="col-md-12">
+                  <button
+                    class="btn btn-primary"
+                    ng-disabled="!logged_in"
+                    ng-click="createSession()">[% l('Create Session') %]</button>
+                  <button
+                    class="btn btn-default pull-right"
+                    ng-disabled="!logged_in"
+                    ng-click="refreshSessions()">[% l('Refresh') %]</button>
+                </div>
+              </div>
+              <div class="row">
+                <div class="col-md-12"><h2>[% l('Session List') %]</h2></div>
+              </div>
+              <div class="row">
+                <div class="col-md-12">
+                  <table class="table">
+                    <thead>
+                      <tr>
+                        <th>[% l('Description') %]</th>
+                        <th>[% l('Date Created') %]</th>
+                        <th>[% l('Upload Count') %]</th>
+                        <th>[% l('Transactions Processed') %]</th>
+                        <th>[% l('Date Completed') %]</th>
+                        <th></th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr
+                        ng-repeat="ses in sessions track by $index"
+                        ng-click="setSession(ses, $index)"
+                        ng-class="{'bg-info':current_session_index==$index}"
+                      >
+                        <td>{{ses.description}}</td>
+                        <td>{{createDate(ses.create_time, true) | date:'short'}}</td>
+                        <td>{{ses.total}}</td>
+                        <td>{{ses.num_complete}}</td>
+                        <td>{{createDate(ses.end_time, true) | date:'short'}}</td>
+                        <td>
+                          <button
+                            class="btn btn-info btn-xs"
+                            ng-disabled="!logged_in || pending_xacts.length == 0"
+                            ng-click="uploadPending(ses, $index)"
+                          >[% l('Upload') %]</button>
+                          <button
+                            class="btn btn-warning btn-xs"
+                            ng-disabled="!logged_in || ses.total == 0 || ses.end_time"
+                            ng-click="processSession(ses, $index)"
+                          >[% l('Process') %]</button>
+                        </td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+              <div class="row">
+                    <div class="col-md-12"><hr/></div>
+              </div>
+              <div class="row">
+                    <div class="col-md-12"><h2>[% l('Exception List') %]</h2></div>
+              </div>
+              <div class="row">
+                <div class="col-md-12">
+                  <table class="table">
+                    <thead>
+                      <tr>
+                        <th>[% l('Workstation') %]</th>
+                        <th>[% l('Type') %]</th>
+                        <th>[% l('Timestamp') %]</th>
+                        <th>[% l('Event Name') %]</th>
+                        <th>[% l('Patron Barcode') %]</th>
+                        <th>[% l('Item Barcode') %]</th>
+                        <th>[% l('Non-cataloged Type') %]</th>
+                        <th>[% l('Checkout Date') %]</th>
+                        <th>[% l('Due Date') %]</th>
+                        <th>[% l('Checkin Date') %]</th>
+                        <th></th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr ng-repeat="xact in current_session.exceptions track by $index">
+                        <td>{{xact.command._workstation}}</td>
+                        <td>{{xact.command.type}}</td>
+                        <td>{{createDate(xact.command.timestamp, true) | date:'short'}}</td>
+                        <td>{{xact.event.textcode}}</td>
+                        <td>{{xact.command.patron_barcode || xact.command.user.card.barcode}}</td>
+                        <td>{{xact.command.barcode}}</td>
+                        <td>{{lookupNoncatTypeName(xact.command.noncat_type)}}</td>
+                        <td>{{createDate(xact.command.checkout_time) | date:'short'}}</td>
+                        <td>{{createDate(xact.command.due_date) | date:'shortDate'}}</td>
+                        <td>{{createDate(xact.command.backdate) | date:'shortDate'}}</td>
+                        <td>
+                          <button
+                            class="btn btn-info btn-xs"
+                            ng-disabled="!logged_in || !xact.command.barcode"
+                            ng-click="retrieveItem(xact.command.barcode)">[% l('Item') %]</button>
+                          <button
+                            class="btn btn-info btn-xs"
+                            ng-disabled="!logged_in || (!xact.command.patron_barcode && xact.command.user.card.barcode)"
+                            ng-click="retrievePatron(xact.command.patron_barcode)">[% l('Patron') %]</button>
+                          <button
+                            class="btn btn-info btn-xs"
+                            ng-disabled="!logged_in"
+                            ng-click="retrieveDetails(xact)">[% l('Details') %]</button>
+                        </td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </uib-tab>
+          </uib-tabset>
+        </div>
+      </uib-tab>
+    </uib-tabset>
+  </div>
+</div>
+
+</script>
+
+[% BLOCK APP_JS %]
+<!-- offline page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/offline.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.REG_ADDR_TYPE = "[% l('Mailing') %]";
+  s.REG_INVALID_FIELDS =
+    "[% l('Please enter valid values for all required fields.') %]"
+  s.REG_ADDR_REQUIRED =
+    "[% l('An address is required during registration.') %]"
+}]);
+</script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div> 
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_offline_checkin.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_offline_checkin.tt2
new file mode 100644 (file)
index 0000000..7c0acdd
--- /dev/null
@@ -0,0 +1,25 @@
+<!--
+Template for printing offline checkout receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+  includes:
+
+  * barcode
+  * backdate
+  * timestamp
+
+-->
+<div>
+  <div>[% l('You checked in the following [_1] items:', '{{transactions.length}}') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkin in transactions">
+      <div>[% l('Barcode: [_1] Checkin date: [_2]', 
+        '{{checkin.barcode}}',
+        '{{checkin.backdate | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{today | date:'short'}}</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_offline_checkout.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_offline_checkout.tt2
new file mode 100644 (file)
index 0000000..292e7b4
--- /dev/null
@@ -0,0 +1,26 @@
+<!--
+Template for printing offline checkout receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+  includes:
+
+  * barcode
+  * patron_barcode
+  * due_date
+
+-->
+<div>
+  <div>[% l('Patron [_1]', '{{transactions[0].patron_barcode}}') %]</div>
+  <div>[% l('You checked out the following [_1] items:', '{{transactions.length}}') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in transactions">
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.barcode}}',
+        '{{checkout.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{today | date:'short'}}</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_offline_in_house_use.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_offline_in_house_use.tt2
new file mode 100644 (file)
index 0000000..1f177ce
--- /dev/null
@@ -0,0 +1,24 @@
+<!--
+Template for printing offline in-house use receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+  includes:
+
+  * barcode
+  * count
+
+-->
+<div>
+  <div>[% l('You recorded use for the following [_1] items:', '{{transactions.length}}') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in transactions">
+      <div>[% l('Barcode [_1] used [_2] times', 
+        '{{checkout.barcode}}',
+        '{{checkout.count}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{today | date:'short'}}</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_offline_renew.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_offline_renew.tt2
new file mode 100644 (file)
index 0000000..7bd8242
--- /dev/null
@@ -0,0 +1,24 @@
+<!--
+Template for printing offline renew receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+  includes:
+
+  * barcode
+  * due_date
+
+-->
+<div>
+  <div>[% l('You renewed the following [_1] items:', '{{transactions.length}}') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in transactions">
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.barcode}}',
+        '{{checkout.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{today | date:'short'}}</div>
+<br/>
+
index cc0afbf..8926e0d 100644 (file)
@@ -3,6 +3,7 @@
     <!-- Date Picker -->
     <div class="input-group">
       <input type="text"
+        id="{{id}}"
         class="form-control"
         ng-show="!hideDatePicker"
         uib-datepicker-popup="{{date_format}}"
diff --git a/Open-ILS/web/LICENSE.UpUp b/Open-ILS/web/LICENSE.UpUp
new file mode 100644 (file)
index 0000000..c7fa58d
--- /dev/null
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Tal Ater
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
index 02e9902..3d2caf5 100644 (file)
@@ -2,7 +2,7 @@
 angular.module('egCoreMod')
 // toss tihs onto egCoreMod since the page app may vary
 
-.factory('patronRegSvc', ['$q', 'egCore', function($q, egCore) {
+.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) {
 
     var service = {
         field_doc : {},            // config.idl_field_doc
@@ -251,6 +251,11 @@ angular.module('egCoreMod')
                     });
                 });
             });
+
+            egLovefield.setListInOfflineCache('asv', service.surveys)
+            egLovefield.setListInOfflineCache('asvq', service.survey_questions)
+            egLovefield.setListInOfflineCache('asva', service.survey_answers)
+
         });
     }
 
@@ -270,6 +275,7 @@ angular.module('egCoreMod')
                 );
             });
             service.stat_cats = cats;
+            return egLovefield.setStatCatsCache(cats);
         });
     };
 
@@ -364,7 +370,7 @@ angular.module('egCoreMod')
     // some org settings require the retrieval of additional data
     service.process_org_settings = function(settings) {
 
-        var promises = [];
+        var promises = [egLovefield.setSettingsCache(settings)];
 
         if (settings['sms.enable']) {
             // fetch SMS carriers
@@ -434,14 +440,23 @@ angular.module('egCoreMod')
     }
 
     service.get_field_doc = function() {
+        var to_cache = [];
         return egCore.pcrud.search('fdoc', {
             fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
-        .then(null, null, function(doc) {
-            if (!service.field_doc[doc.fm_class()]) {
-                service.field_doc[doc.fm_class()] = {};
+        .then(
+            function () {
+                return egLovefield.setListInOfflineCache('fdoc', to_cache)
+            },
+            null,
+            function(doc) {
+                if (!service.field_doc[doc.fm_class()]) {
+                    service.field_doc[doc.fm_class()] = {};
+                }
+                service.field_doc[doc.fm_class()][doc.field()] = doc;
+                to_cache.push(doc);
             }
-            service.field_doc[doc.fm_class()][doc.field()] = doc;
-        });
+        );
+
     };
 
     service.get_user_settings = function() {
@@ -471,6 +486,8 @@ angular.module('egCoreMod')
             ]
         }, {}, {atomic : true}).then(function(setting_types) {
 
+            egCore.env.absorbList(setting_types, 'cust'); // why not...
+
             angular.forEach(setting_types, function(stype) {
                 service.user_setting_types[stype.name()] = stype;
                 if (static_types.indexOf(stype.name()) == -1) {
diff --git a/Open-ILS/web/js/ui/default/staff/offline.js b/Open-ILS/web/js/ui/default/staff/offline.js
new file mode 100644 (file)
index 0000000..1a375bd
--- /dev/null
@@ -0,0 +1,1962 @@
+/**
+ * App to drive the offline UI
+ */
+
+lf.isOffline = true;
+
+angular.module('egOffline', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'ngToast'])
+
+.config(
+       ['$routeProvider','$locationProvider','$compileProvider',
+function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/);
+
+    /**
+     * Route resolvers allow us to run async commands
+     * before the page controller is instantiated.
+     */
+    var resolver = {delay : ['egCore', 
+        function(egCore) {
+            return egCore.startup.go();
+        }
+    ]};
+
+    $routeProvider.when('/offline-interface/:tab', {
+        templateUrl: 'offline-template',
+        controller: 'OfflineCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : 'offline-template',
+        controller : 'OfflineCtrl',
+        resolve : resolver
+    });
+}])
+
+.controller('OfflineSessionCtrl', 
+           ['$scope','$location','$window','egCore','$routeParams','$http','$q','$timeout','egPromptDialog','ngToast',
+    function($scope , $location , $window , egCore , $routeParams , $http , $q , $timeout , egPromptDialog , ngToast) {
+        $scope.active_session_tab = 'pending';
+
+        $scope.lookupNoncatTypeName = function (type) {
+            var nc =  $scope.noncats.filter(function(n){ return n.id() == type })[0];
+            if (nc) return nc.name();
+            return '';
+        }
+
+        $scope.createDate = function (ts, epoch) {
+            if (!ts) return '';
+            if (epoch) ts = ts * 1000;
+            return new Date(ts);
+        }
+
+        $scope.setSession = function (s, ind) {
+            $scope.current_session = s;
+            $scope.current_session_index = ind;
+
+            return $scope.refreshExceptions(s);
+        }
+
+        $scope.createSession = function () {
+
+            return egPromptDialog.open(
+                egCore.strings.OFFLINE_SESSION_DESC, '',
+                {ok : function(value) {
+                    if (value) {
+
+                        return $http.get(formURL({action:'create',desc:value})).then(function(res) {
+                            if (res.data.ilsevent == "0") return $q.when(res.data.payload);
+                            return $q.reject();
+                        }).then(function (seskey) {
+                            return $scope.refreshSessions().then(function() {
+                                if (seskey) {
+                                    var s = $scope.sessions.filter(function(s){ s.key == seskey })[0];
+                                    var ind = $scope.sessions.length - 1; // sorted by create time, so new one is last
+                                    return $scope.setSession(s, ind);
+                                }
+                            });
+                        }, function() {
+                            ngToast.warning(egCore.strings.OFFLINE_SESSION_CREATE_FAILED);
+                        });
+                    }
+                }}
+            );
+        }
+
+        $scope.processSession = function (s, ind) {
+            return $scope.setSession(s, ind).then(function() {
+
+                return $http.get(
+                    formURL({action:'execute',seskey:$scope.current_session.key})
+                ).then(function(res) {
+                    if (res.data.ilsevent == "0") return $q.when(res.data.payload);
+                    return $q.reject();
+                }).then(function () {
+                    return $scope.refreshSessions();
+                },function () {
+                    return $scope.refreshSessions().then(function() {
+                        ngToast.warning(egCore.strings.OFFLINE_SESSION_PROCESSING_FAILED);
+                    });
+                });
+            });
+        }
+
+        $scope.refreshExceptions = function (s) {
+            return $http.get(
+                formURL({
+                    action      : 'status',
+                    status_type : 'exceptions',
+                    seskey      : s.key
+                })
+            ).then(function(res) {
+                if (res.data.ilsevent) {
+                    $scope.current_session.exceptions = [];
+                } else {
+                    $scope.current_session.exceptions = res.data;
+                }
+                return $q.when();
+            });
+        }
+
+        $scope.refreshSessions = function () {
+
+            return $http.get(formURL({action:'status',status_type:'sessions'})).then(function(res) {
+                if (res.data) {
+                    $scope.sessions = res.data;
+                    return $q.when();
+                }
+                return $q.reject();
+            }).then(function() {
+                angular.forEach($scope.sessions, function (s) {
+                    s.total = 0;
+                    angular.forEach(s.scripts, function(sc) {
+                        s.total += sc.count;
+                    });
+                });
+            });
+        }
+
+        $scope.uploadPending = function (s, ind) {
+            return $scope.setSession(s, ind).then(function() {
+
+                return $scope.createOfflineXactBlob().then(function(blob) {
+
+                    var form = new FormData();
+                    form.append("ses", egCore.auth.token());
+                    form.append("org", $scope.org.id());
+                    form.append("ws", $scope.current_workstation_name());
+                    form.append("wc", 1);
+                    form.append("action", "load");
+                    form.append("seskey", $scope.current_session.key);
+                    form.append("file", blob, "file");
+
+                    return $http.post(
+                        '/cgi-bin/offline/offline.pl?' + new Date().getTime(),
+                        form,
+                        {
+                            transformRequest: angular.identity,
+                            headers: {'Content-Type': undefined}
+                        }
+                    ).then(function(res) {
+                        if (res.data.ilsevent == "0") {
+                            return $scope.clear_pending().then(function() {
+                                return $scope.refreshSessions();
+                            });
+                        } else {
+                            ngToast.warning(egCore.strings.OFFLINE_SESSION_UPLOAD_FAILED);
+                            return $scope.refreshSessions();
+                        }
+                    });
+                });
+            });
+        }
+
+        $scope.retrieveDetails = function (x) {
+            alert(JSON.stringify(x, null, 2)); // egAlertDialog kills pretty printing
+        }
+
+        $scope.retrieveItem = function (bc) {
+            return egCore.pcrud.search('acp',{deleted: 'f', barcode: bc}).then(function(copy) {
+                if (copy) {
+                    return $window.open(
+                        egCore.env.basePath +
+                        '/cat/item/' + copy.id(),
+                        '_blank'
+                    ).focus();
+                }
+
+                ngToast.warning(egCore.strings.ITEM_NOT_FOUND);
+            });
+        }
+
+        $scope.retrievePatron = function (bc) {
+            return egCore.pcrud.search('ac',{barcode: bc}).then(function(card) {
+                if (card) {
+                    return $window.open(
+                        egCore.env.basePath +
+                        '/circ/patron/' + card.usr() + '/checkout',
+                        '_blank'
+                    ).focus();
+                }
+
+                ngToast.warning(egCore.strings.PATRON_NOT_FOUND);
+            });
+        }
+
+        function formURL (params) {
+            var url = '/cgi-bin/offline/offline.pl?' + new Date().getTime();
+
+            var defaults = {
+                org : $scope.org ? $scope.org.id() : null,
+                ws  : $scope.current_workstation_name(),
+                wc  : 1,
+                ses : egCore.auth.token()
+            }
+
+            angular.extend(params, defaults)
+
+            var first = true;
+            for (var k in params) {
+                url += '&' + k + '=' + window.encodeURIComponent(params[k]);
+            }
+            return url;
+        }
+
+        $scope.$watch('org',function(n){if (n) $scope.refreshSessions()});
+
+    }
+])
+
+.controller('OfflineCtrl', 
+           ['$q','$scope','$location','$window','egCore','egLovefield','$routeParams','$timeout','$http','ngToast',
+    function($q , $scope , $location , $window , egCore , egLovefield , $routeParams , $timeout , $http , ngToast) {
+        $scope.active_tab = $routeParams.tab || 'checkout';
+
+        $scope.blocked_patron = null;
+        $scope.bad_barcode = null;
+        $scope.focusMe = true;
+        $scope.shared = { due_date : null, due_date_offset : '' };
+        $scope.workstation = '';
+        $scope.workstation_owner = '';
+        $scope.workstations = [];
+        $scope.org = null;
+        $scope.do_print = Boolean($scope.active_tab == 'checkout');
+        $scope.do_print_changed = false;
+
+        $scope.imported_pending_xacts = { data : '' };
+
+        $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
+        $scope.all_xact = [];
+        $scope.noncats = [];
+
+        $scope.checkout = { noncat_type : '' };
+        $scope.renew = { noncat_type : '' };
+        $scope.in_house_use = {};
+        $scope.checkin = { backdate : new Date() };
+
+        $scope.current_workstation_name = function () {
+            return $scope.workstations.filter(function(w) {
+                return $scope.workstation == w.id
+            })[0].name;
+        }
+
+        $scope.changePrint = function () {
+            $scope.do_print = !$scope.do_print;
+            $scope.do_print_changed = true;
+        }
+
+        $scope.logged_in = egCore.auth.token() ? true : false;
+
+        if (!$scope.logged_in && $routeParams.tab == 'session')
+            $scope.active_tab = 'checkout';
+        
+        egCore.hatch.getItem('eg.workstation.all')
+        .then(function(all) {
+            if (all && all.length) {
+                $scope.workstations = all;
+
+                if (ws = $location.search().ws) {
+                    // user requested a workstation via URL
+                    var match = all.filter(
+                        function(w) {return ws == w.name} )[0];
+
+                    if (match) {
+                        // requested WS registered on this client
+                        $scope.workstation = match.id;
+                    } else {
+                        // the requested WS is not registered on this client
+                        $scope.wsNotRegistered = true;
+                    }
+                } else {
+                    // no workstation requested; use the default
+                    egCore.hatch.getItem('eg.workstation.default')
+                    .then(function(ws) {
+                        var ws_obj = all.filter(function(w) {
+                            return ws == w.name
+                        })[0];
+
+                        $scope.workstation = ws_obj.id;
+                        $scope.workstation_owner = ws_obj.owning_lib;
+
+                        return egLovefield.reconstituteList('cnct').then(function () {
+                            $scope.noncats = egCore.env.cnct.list;
+                        });
+                    });
+                }
+            } 
+        });
+
+        $scope.downloadBlockList = function () {
+            var url = '/standalone/list.txt?ses='
+                + egCore.auth.token()
+                + '&' + new Date().getTime();
+            return $http.get(url).then(
+                function (res) {
+                    if (res.data) {
+                        var lines = res.data.split('\n');
+                        egLovefield.destroyOfflineBlocks().then(function(){
+                            angular.forEach(lines, function (l) {
+                                var parts = l.split(' ');
+                                egLovefield.addOfflineBlock(parts[0], parts[1]);
+                            });
+                        });
+                    }
+                }
+            );
+        }
+
+        $scope.createOfflineXactBlob = function () {
+            return egLovefield.retrievePendingOfflineXacts().then(function(list) {
+                var flat_list = [];
+                angular.forEach(list, function (i) {
+                    flat_list.push(JSON.stringify(i) + '\n');
+                });
+
+                var blob = new Blob(flat_list, {type: 'text/plain'});
+
+                return $q.when(blob)
+            });
+        }
+
+        $scope.pending_xacts = [];
+        $scope.retrieve_pending = function () {
+            return egLovefield.retrievePendingOfflineXacts().then(function(list) {
+                $scope.pending_xacts = list;
+                return $q.when(list);
+            });
+        }
+
+        $scope.save = function () {
+            angular.forEach($scope.all_xact, function (x) {
+                egLovefield.addOfflineXact(x);
+            });
+
+            var prints = [$q.when()];
+            if ($scope.do_print) {
+                angular.forEach(['checkin','checkout','renew','in_house_use'], function(xtype) {
+                    if ($scope.xact_page[xtype].length > 0) {
+                        prints.push(egCore.print.print({
+                            context : 'offline', 
+                            template : 'offline_'+xtype,
+                            scope : {
+                                transactions    : $scope.xact_page[xtype]
+                            }
+                        }));
+                    }
+                });
+            }
+
+            return $q.all(prints).finally(function() {
+                $scope.all_xact = [];
+                $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
+                angular.forEach(['checkout','renew'], function (xtype) {
+                    $scope[xtype].patron_barcode = '';
+                });
+                $scope.retrieve_pending();
+            });
+        }
+
+        $scope.clear_pending = function () {
+            return egLovefield.destroyPendingOfflineXacts().then(function () {
+                return $scope.retrieve_pending();
+            });
+        }
+
+        $scope.retrieve_pending();
+        $scope.$watch('active_tab', function (n,o) {
+            if (n != o && !$scope.do_print_changed && n != 'checkout') $scope.do_print = false;
+            if (n != o && !$scope.do_print_changed && n == 'checkout') $scope.do_print = true;
+            if (n != o && n == 'session') $scope.retrieve_pending();
+        });
+
+        $scope.$watch('imported_pending_xacts.data', function (n, o) {
+            if (n != 0) {
+                var lines = n.split('\n');
+                var promises = [];
+
+                angular.forEach(lines, function (l) {
+                    if (!l) return;
+
+                    try {
+                        promises.push(
+                            egLovefield.addOfflineXact(JSON.parse(l))
+                        );
+                    } catch (err) {
+                        ngToast.warning(err);
+                    }
+                });
+
+                $q.all(promises).then(function () { $scope.retrieve_pending() });
+            }
+        });
+
+        $scope.resetDueDate = function (xtype) {
+            $scope.shared.due_date = new Date();
+        }
+
+        $scope.notEnough = function (xtype) {
+            if (xtype == 'checkout') {
+                if (
+                    $scope.checkout.patron_barcode &&
+                    ($scope.shared.due_date || $scope.shared.due_date_offset) &&
+                    ($scope.checkout.barcode || ($scope.checkout.noncat_type && $scope.checkout.noncat_count))
+                ) return false;
+                return true;
+            }
+
+            if (xtype == 'renew') {
+                if (
+                    $scope.renew.patron_barcode && $scope.renew.barcode &&
+                    ($scope.shared.due_date || $scope.shared.due_date_offset)
+                ) return false;
+                return true;
+            }
+
+            if (xtype == 'in_house_use') {
+                if (
+                    $scope.in_house_use.barcode && $scope.in_house_use.count
+                ) return false;
+                return true;
+            }
+
+            if (xtype == 'checkin') {
+                if (
+                    $scope.checkin.barcode && $scope.checkin.backdate
+                ) return false;
+                return true;
+            }
+        }
+
+        $scope.clear = function (xtype) {
+            $scope[xtype] = {};
+        }
+
+        $scope.add = function (xtype) {
+
+            var pbarcode = $scope[xtype].patron_barcode;
+
+            if (pbarcode) {
+                egLovefield.testOfflineBlock(pbarcode).then(function (blocked) {
+                    if (blocked) {
+                        $scope.blocked_patron = xtype;
+                        ngToast.warning(egCore.strings.PATRON_BLOCKED);
+                        egCore.audio.play('warning.offline.blocked_patron');
+                        return;
+                    }
+                    $scope.blocked_patron = null;
+                    _add_impl(xtype,true)
+                });
+            } else {
+                _add_impl(xtype);
+            }
+        }
+
+        function _add_impl (xtype,digest) {
+            var pbarcode = $scope[xtype].patron_barcode;
+            var backdate = $scope[xtype].backdate;
+
+            if ($scope.strict_barcode && $scope[xtype].barcode) {
+                if (!check_barcode($scope[xtype].barcode)) {
+                    $scope.bad_barcode = xtype;
+                    ngToast.warning(egCore.strings.BAD_BARCODE);
+                    egCore.audio.play('warning.offline.bad_barcode');
+                    return;
+                }
+            }
+
+            $scope.bad_barcode = null;
+
+            var now = new Date().getTime();
+            now = now / 1000;
+
+            if ($scope.shared.due_date_offset) {
+                var due = parseInt(now * 1000);
+                due += parseInt($scope.shared.due_date_offset) * 86400000;
+                $scope.shared.due_date = new Date(due);
+            }
+
+            if ($scope[xtype].noncat_type) $scope[xtype].noncat = 1;
+
+            if ($scope.shared.due_date && (xtype == 'checkout' || xtype == 'renew')) {
+                $scope[xtype].due_date = $scope.shared.due_date.toISOString();
+                $scope[xtype].checkout_time = new Date().toISOString();
+            }
+
+            var xact = { timestamp : parseInt(now), type : xtype, delta : 0 };
+
+            $scope.xact_page[xtype].push(
+                angular.extend(xact, $scope[xtype])
+            );
+
+            $scope.all_xact.push(xact)
+
+            $scope[xtype] = {};
+
+            if (pbarcode) $scope[xtype].patron_barcode = pbarcode;
+            if (backdate) $scope[xtype].backdate = backdate;
+
+            if (digest) $scope.$apply();
+        }
+
+        check_barcode = function(bc) {
+            if (bc != Number(bc)) return false;
+            bc = bc.toString();
+            // "16.00" == Number("16.00"), but the . is bad.
+            // Throw out any barcode that isn't just digits
+            if (bc.search(/\D/) != -1) return false;
+            var last_digit = bc.substr(bc.length-1);
+            var stripped_barcode = bc.substr(0,bc.length-1);
+            return barcode_checkdigit(stripped_barcode).toString() == last_digit;
+        }
+    
+        barcode_checkdigit = function(bc) {
+            var reverse_barcode = bc.toString().split('').reverse();
+            var check_sum = 0; var multiplier = 2;
+            for (var i = 0; i < reverse_barcode.length; i++) {
+                var digit = reverse_barcode[i];
+                var product = digit * multiplier; product = product.toString();
+                var temp_sum = 0;
+                for (var j = 0; j < product.length; j++) {
+                    temp_sum += Number( product[j] );
+                }
+                check_sum += Number( temp_sum );
+                multiplier = ( multiplier == 2 ? 1 : 2 );
+            }
+            check_sum = check_sum.toString();
+            var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10;
+            var check_digit = next_multiple_of_10 - Number(check_sum);
+            if (check_digit == 10) check_digit = 0;
+            return check_digit;
+        }
+
+    }
+])
+
+// dummy service so standalone patron editor can reference it
+.factory('patronSvc', function() { return { /* dummy */ } })
+
+.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) {
+
+    egLovefield.isOffline = true;
+
+    var service = {
+        org : null,                // will come from workstation org 
+        field_doc : {},            // config.idl_field_doc
+        profiles : [],             // permission groups
+        edit_profiles : [],        // perm groups we can modify
+        sms_carriers : [],
+        user_settings : {},        // applied user settings
+        user_setting_types : {},   // config.usr_setting_type
+        opt_in_setting_types : {}, // config.usr_setting_type for event-def opt-in
+        surveys : [],
+        survey_questions : {},
+        survey_answers : {},
+        survey_responses : {},     // survey.responses for loaded patron in progress
+        stat_cats : [],
+        stat_cat_entry_maps : {},   // cat.id to selected value
+        virt_id : -1,               // virtual ID for new objects
+        init_done : false           // have we loaded our initialization data?
+    };
+
+    service.offlineMode = function () {
+        return lf.isOffline;
+    }
+
+    // launch a series of parallel data retrieval calls
+    service.init = function(scope) {
+
+        // Data loaded here only needs to be retrieved the first time this
+        // tab becomes active within the current instance of the patron app.
+        // In other words, navigating between patron tabs will not cause
+        // all of this data to be reloaded.  Navigating to a separate app
+        // and returning will cause the data to be reloaded.
+        if (service.init_done) return $q.when();
+        service.init_done = true;
+
+        return $q.all([
+            service.get_field_doc(),
+            service.get_perm_groups(),
+            service.get_ident_types(),
+            service.get_user_settings(),
+            service.get_org_settings(),
+            service.get_stat_cats(),
+            service.get_surveys(),
+            service.get_net_access_levels()
+        ]);
+    };
+
+    service.get_linked_addr_users = function(addrs) {
+        return $q.when();
+    }
+
+    service.apply_secondary_groups = function(user_id, group_ids) {
+        return $q.when(true);
+    }
+
+    // See note above about not loading egUser.
+    // TODO: i18n
+    service.format_name = function(last, first, middle) {
+        return last + ', ' + first + (middle ? ' ' + middle : '');
+    }
+
+    service.check_dupe_username = function(usrname) {
+        return $q.when(false);
+    }
+
+    // determine which user groups our user is not allowed to modify
+    service.set_edit_profiles = function() {
+        service.edit_profiles = egCore.env.pgt.list.filter(
+            function (p) { return p.application_perm() == 'group_application.user.patron' }
+        );
+        return $q.when;
+    }
+
+    // resolves to a hash of perm-name => boolean value indicating
+    // wether the user has the permission at org_id.
+    service.has_perms_for_org = function(org_id) {
+
+        var perms_needed = [
+            'UPDATE_USER',
+            'CREATE_USER',
+            'CREATE_USER_GROUP_LINK', 
+            'UPDATE_PATRON_COLLECTIONS_EXEMPT',
+            'UPDATE_PATRON_CLAIM_RETURN_COUNT',
+            'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
+            'UPDATE_PATRON_ACTIVE_CARD',
+            'UPDATE_PATRON_PRIMARY_CARD'
+        ];
+
+        var hash = {};
+        angular.forEach(perms_needed, function (p) {
+            hash[p] = true;
+        });
+
+        return $q.when(hash);
+    }
+
+    service.get_surveys = function() {
+        return egLovefield.reconstituteList('asv').then(function(offline) {
+            return egLovefield.reconstituteList('asvq')
+                    .then(function(){
+                        return egLovefield.reconstituteList('asva');
+                    }).then(function() {
+                        angular.forEach(egCore.env.asv.list, function (s) {
+                            s.questions( egCore.env.asva.list.filter( function (a) {
+                                return q.survey().id == s.id();
+                            }));
+                        });
+
+                        angular.forEach(egCore.env.asvq.list, function (q) {
+                            q.survey( egCore.env.asv.map[ q.survey().id ] );
+                            q.answers( egCore.env.asva.list.filter( function (a) {
+                                return q.id() == a.question();
+                            }));
+                        });
+
+                        angular.forEach(egCore.env.asva.list, function (a) {
+                            a.question( egCore.env.asvq.map[ a.question().id ] );
+                        });
+
+                        service.surveys = egCore.env.asv.list;
+                        service.survey_questions = egCore.env.asvq.list;
+                        service.survey_answers = egCore.env.asva.list;
+
+                        return $q.when();
+                    });
+        });
+    }
+
+    service.get_stat_cats = function() {
+        return egLovefield.getStatCatsCache().then(
+            function(cats) {
+                service.stat_cats = cats;
+                return $q.when();
+            }
+        );
+    };
+
+    service.get_org_settings = function() {
+        return egLovefield.getSettingsCache().then(
+            function (list) {
+                var hash = {};
+                angular.forEach(list, function (s) {
+                    hash[s.name] = s.value;
+                });
+                service.org_settings = hash;
+                if (egCore && egCore.env && !egCore.env.aous) {
+                    egCore.env.aous = hash;
+                    console.log('setting egCore.env.aous');
+                }
+                return $q.when();
+            }
+        );
+    };
+
+    service.get_ident_types = function() {
+        return egLovefield.reconstituteList('cit').then(function() {
+            service.ident_types = egCore.env.cit.list;
+            return $q.when();
+        });
+    };
+
+    service.get_net_access_levels = function() {
+        return egLovefield.reconstituteList('cnal').then(function() {
+            service.net_access_levels = egCore.env.cnal.list;
+            return $q.when();
+        });
+    }
+
+    service.get_perm_groups = function() {
+        if (egCore.env.pgt) {
+            service.profiles = egCore.env.pgt.list;
+            return service.set_edit_profiles();
+        } else {
+            return egLovefield.reconstituteTree('pgt').then(function(offline) {
+                service.profiles = egCore.env.pgt.list;
+                return service.set_edit_profiles();
+            });
+        }
+    }
+
+    service.get_field_doc = function() {
+        return egLovefield.getListFromOfflineCache('fdoc').then(function (list) {
+            angular.forEach(list, function(doc) {
+                service.field_doc[doc.fm_class()][doc.field()] = doc;
+            });
+            return $q.when();
+        });
+    };
+
+    service.get_user_settings = function() {
+        var static_types = [
+            'circ.holds_behind_desk', 
+            'circ.collections.exempt', 
+            'opac.hold_notify', 
+            'opac.default_phone', 
+            'opac.default_pickup_location', 
+            'opac.default_sms_carrier', 
+            'opac.default_sms_notify'];
+
+        angular.forEach(static_types, function (t) {
+            service.user_settings[t] = null;
+        });
+
+        return egLovefield.getListFromOfflineCache('cust').then(function (list) {
+            angular.forEach(list, function(stype) {
+                service.user_setting_types[stype.name()] = stype;
+                if (static_types.indexOf(stype.name()) == -1) {
+                    service.opt_in_setting_types[stype.name()] = stype;
+                }
+                if (stype.reg_default() != undefined) {
+                    service.user_settings[setting.name()] = 
+                        setting.reg_default();
+                }
+            });
+            return $q.when();
+        });
+    }
+
+    service.invalidate_field = function(patron, field) {
+        return;
+    }
+
+    service.dupe_patron_search = function(patron, type, value) {
+        return $q.when({ search : search, count : 0 });
+    }
+
+    service.init_patron = function(current) {
+
+        if (!current)
+            return service.init_new_patron();
+
+        service.patron = current;
+        return service.init_existing_patron(current)
+    }
+
+    service.ingest_address = function(patron, addr) {
+        addr.valid = addr.valid == 't';
+        addr.within_city_limits = addr.within_city_limits == 't';
+        addr._is_mailing = (patron.mailing_address && 
+            addr.id == patron.mailing_address.id);
+        addr._is_billing = (patron.billing_address && 
+            addr.id == patron.billing_address.id);
+    }
+
+    /*
+     * Existing patron objects reqire some data munging before insertion
+     * into the scope.
+     *
+     * 1. Turn everything into a hash
+     * 2. ... Except certain fields (selectors) whose widgets require objects
+     * 3. Bools must be Boolean, not t/f.
+     */
+    service.init_existing_patron = function(current) {
+
+        service.existing_patron = current;
+
+        var patron = egCore.idl.toHash(current);
+
+        patron.home_ou = egCore.org.get(patron.home_ou.id);
+        patron.expire_date = new Date(Date.parse(patron.expire_date));
+        patron.dob = service.parse_dob(patron.dob);
+        patron.profile = current.profile(); // pre-hash version
+        patron.net_access_level = current.net_access_level();
+        patron.ident_type = current.ident_type();
+        patron.groups = current.groups(); // pre-hash
+
+        angular.forEach(
+            ['juvenile', 'barred', 'active', 'master_account'],
+            function(field) { patron[field] = patron[field] == 't'; }
+        );
+
+        angular.forEach(patron.cards, function(card) {
+            card.active = card.active == 't';
+            if (card.id == patron.card.id) {
+                patron.card = card;
+                card._primary = 'on';
+            }
+        });
+
+        angular.forEach(patron.addresses, 
+            function(addr) { service.ingest_address(patron, addr) });
+
+        service.get_linked_addr_users(patron.addresses);
+
+        // Remove stat cat entries that link to out-of-scope stat
+        // cats.  With this, we avoid unnecessarily updating (or worse,
+        // modifying) stat cat values that are not ours to modify.
+        patron.stat_cat_entries = patron.stat_cat_entries.filter(
+            function(map) {
+                return Boolean(
+                    // service.stat_cats only contains in-scope stat cats.
+                    service.stat_cats.filter(function(cat) { 
+                        return (cat.id() == map.stat_cat.id) })[0]
+                );
+            }
+        );
+
+        // toss entries for existing stat cat maps into our living 
+        // stat cat entry map, which is modified within the template.
+        angular.forEach(patron.stat_cat_entries, function(map) {
+            service.stat_cat_entry_maps[map.stat_cat.id] = map.stat_cat_entry;
+        });
+
+        return patron;
+    }
+
+    service.init_new_patron = function() {
+        var addr = {
+            id : service.virt_id--,
+            isnew : true,
+            valid : true,
+            address_type : egCore.strings.REG_ADDR_TYPE,
+            _is_mailing : true,
+            _is_billing : true,
+            within_city_limits : false,
+            country : service.org_settings['ui.patron.default_country'],
+        };
+
+        var card = {
+            id : service.virt_id--,
+            isnew : true,
+            active : true,
+            _primary : 'on'
+        };
+
+        var home_ou = egCore.org.get(service.org);
+
+        var user = {
+            isnew : true,
+            active : true,
+            card : card,
+            cards : [card],
+            home_ou : home_ou,
+            stat_cat_entries : [],
+            groups : [],
+            addresses : [addr]
+        };
+
+        if (service.clone_user)
+            service.copy_clone_data(user);
+
+        if (service.stage_user)
+            service.copy_stage_data(user);
+
+        return user;
+    }
+
+    // dob is always YYYY-MM-DD
+    // Dates of birth do not contain timezone info, which can lead to
+    // inconcistent timezone handling, potentially representing
+    // different points in time, depending on the implementation.
+    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
+    // See "Differences in assumed time zone"
+    // TODO: move this into egDate ?
+    service.parse_dob = function(dob) {
+        if (!dob) return null;
+        var parts = dob.split('-');
+        var d = new Date(); // always local time zone, yay.
+        d.setFullYear(parts[0]);
+        d.setMonth(parts[1] - 1);
+        d.setDate(parts[2]);
+        return d;
+    }
+
+    service.copy_stage_data = function(user) {
+        var cuser = service.stage_user;
+
+        // copy the data into our new user object
+
+        for (var key in egCore.idl.classes.stgu.field_map) {
+            if (egCore.idl.classes.au.field_map[key] &&
+                !egCore.idl.classes.stgu.field_map[key].virtual) {
+                if (cuser.user[key]() !== null)
+                    user[key] = cuser.user[key]();
+            }
+        }
+
+        if (user.home_ou) user.home_ou = egCore.org.get(user.home_ou);
+        if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
+        if (user.ident_type) 
+            user.ident_type = egCore.env.cit.map[user.ident_type];
+        user.dob = service.parse_dob(user.dob);
+
+        // Clear the usrname if it looks like a UUID
+        if (user.usrname.replace(/-/g,'').match(/[0-9a-f]{32}/)) 
+            user.usrname = '';
+
+        // Don't use stub address if we have one from the staged user.
+        if (cuser.mailing_addresses.length || cuser.billing_addresses.length)
+            user.addresses = [];
+
+        // is_mailing=false implies is_billing
+        function addr_from_stage(stage_addr) {
+            if (!stage_addr) return;
+            var cls = stage_addr.classname;
+
+            var addr = {
+                id : service.virt_id--,
+                usr : user.id,
+                isnew : true,
+                valid : true,
+                _is_mailing : cls == 'stgma',
+                _is_billing : cls == 'stgba'
+            };
+
+            user.mailing_address = addr;
+            user.addresses.push(addr);
+
+            for (var key in egCore.idl.classes[cls].field_map) {
+                if (egCore.idl.classes.aua.field_map[key] &&
+                    !egCore.idl.classes[cls].field_map[key].virtual) {
+                    if (stage_addr[key]() !== null)
+                        addr[key] = stage_addr[key]();
+                }
+            }
+        }
+
+        addr_from_stage(cuser.mailing_addresses[0]);
+        addr_from_stage(cuser.billing_addresses[0]);
+
+        if (user.addresses.length == 1) {
+            // If there is only one address, 
+            // use it as both mailing and billing.
+            var addr = user.addresses[0];
+            addr._is_mailing = addr._is_billing = true;
+            user.mailing_address = user.billing_address = addr;
+        }
+
+        if (cuser.cards.length) {
+            user.card = {
+                id : service.virt_id--,
+                barcode : cuser.cards[0].barcode(),
+                isnew : true,
+                active : true,
+                _primary : 'on'
+            };
+
+            user.cards.push(user.card);
+            if (user.usrname == '') 
+                user.usrname = card.barcode;
+        }
+
+        angular.forEach(cuser.settings, function(setting) {
+            service.user_settings[setting.setting()] = Boolean(setting.value());
+        });
+    }
+
+    // copy select values from the cloned user to the new user.
+    // user is a hash
+    service.copy_clone_data = function(user) {
+        var clone_user = service.clone_user;
+
+        // flesh the home org locally
+        user.home_ou = egCore.org.get(clone_user.home_ou());
+        if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
+
+        if (!clone_user.billing_address() &&
+            !clone_user.mailing_address())
+            return; // no addresses to copy or link
+
+        // if the cloned user has any addresses, we don't need 
+        // the stub address created in init_new_patron.
+        user.addresses = [];
+
+        var copy_addresses = 
+            service.org_settings['circ.patron_edit.clone.copy_address'];
+
+        var clone_fields = [
+            'day_phone',
+            'evening_phone',
+            'other_phone',
+            'usrgroup'
+        ]; 
+
+        angular.forEach(clone_fields, function(field) {
+            user[field] = clone_user[field]();
+        });
+
+        if (copy_addresses) {
+            var bill_addr, mail_addr;
+
+            // copy the billing and mailing addresses into new addresses
+            function clone_addr(addr) {
+                var new_addr = egCore.idl.toHash(addr);
+                new_addr.id = service.virt_id--;
+                new_addr.usr = user.id;
+                new_addr.isnew = true;
+                new_addr.valid = true;
+                user.addresses.push(new_addr);
+                return new_addr;
+            }
+
+            if (bill_addr = clone_user.billing_address()) {
+                var addr = clone_addr(bill_addr);
+                addr._is_billing = true;
+                user.billing_address = addr;
+            }
+
+            if (mail_addr = clone_user.mailing_address()) {
+
+                if (bill_addr && bill_addr.id() == mail_addr.id()) {
+                    user.mailing_address = user.billing_address;
+                    user.mailing_address._is_mailing = true;
+                } else {
+                    var addr = clone_addr(mail_addr);
+                    addr._is_mailing = true;
+                    user.mailing_address = addr;
+                }
+
+                if (!bill_addr) {
+                    // if there is no billing addr, use the mailing addr
+                    user.billing_address = user.mailing_address;
+                    user.billing_address._is_billing = true;
+                }
+            }
+
+
+        } else {
+
+            // link the billing and mailing addresses
+            var addr;
+            if (addr = clone_user.billing_address()) {
+                user.billing_address = egCore.idl.toHash(addr);
+                user.billing_address._is_billing = true;
+                user.addresses.push(user.billing_address);
+                user.billing_address._linked_owner_id = clone_user.id();
+                user.billing_address._linked_owner = service.format_name(
+                    clone_user.family_name(),
+                    clone_user.first_given_name(),
+                    clone_user.second_given_name()
+                );
+            }
+
+            if (addr = clone_user.mailing_address()) {
+                if (user.billing_address && 
+                    addr.id() == user.billing_address.id) {
+                    // mailing matches billing
+                    user.mailing_address = user.billing_address;
+                    user.mailing_address._is_mailing = true;
+                } else {
+                    user.mailing_address = egCore.idl.toHash(addr);
+                    user.mailing_address._is_mailing = true;
+                    user.addresses.push(user.mailing_address);
+                    user.mailing_address._linked_owner_id = clone_user.id();
+                    user.mailing_address._linked_owner = service.format_name(
+                        clone_user.family_name(),
+                        clone_user.first_given_name(),
+                        clone_user.second_given_name()
+                    );
+                }
+            }
+        }
+    }
+
+    // translate the patron back into IDL form
+    service.save_user = function(phash) {
+
+        var patron = egCore.idl.fromHash('au', phash);
+
+        patron.home_ou(patron.home_ou().id());
+        patron.expire_date(patron.expire_date().toISOString());
+        patron.profile(patron.profile().id());
+        if (patron.dob()) 
+            patron.dob(patron.dob().toISOString().replace(/T.*/,''));
+        if (patron.ident_type()) 
+            patron.ident_type(patron.ident_type().id());
+        if (patron.net_access_level())
+            patron.net_access_level(patron.net_access_level().id());
+
+        angular.forEach(
+            ['juvenile', 'barred', 'active', 'master_account'],
+            function(field) { patron[field](phash[field] ? 't' : 'f'); }
+        );
+
+        var card_hashes = patron.cards();
+        patron.cards([]);
+        angular.forEach(card_hashes, function(chash) {
+            var card = egCore.idl.fromHash('ac', chash)
+            card.usr(patron.id());
+            card.active(chash.active ? 't' : 'f');
+            patron.cards().push(card);
+            if (chash._primary) {
+                patron.card(card);
+            }
+        });
+
+        var addr_hashes = patron.addresses();
+        patron.addresses([]);
+        angular.forEach(addr_hashes, function(addr_hash) {
+            if (!addr_hash.isnew && !addr_hash.isdeleted) 
+                addr_hash.ischanged = true;
+            var addr = egCore.idl.fromHash('aua', addr_hash);
+            patron.addresses().push(addr);
+            addr.valid(addr.valid() ? 't' : 'f');
+            addr.within_city_limits(addr.within_city_limits() ? 't' : 'f');
+            if (addr_hash._is_mailing) patron.mailing_address(addr);
+            if (addr_hash._is_billing) patron.billing_address(addr);
+        });
+
+        patron.survey_responses([]);
+        angular.forEach(service.survey_responses, function(answer) {
+            var question = service.survey_questions[answer.question()];
+            var resp = new egCore.idl.asvr();
+            resp.isnew(true);
+            resp.survey(question.survey());
+            resp.question(question.id());
+            resp.answer(answer.id());
+            resp.usr(patron.id());
+            resp.answer_date('now');
+            patron.survey_responses().push(resp);
+        });
+        
+        // re-object-ify the patron stat cat entry maps
+        var maps = [];
+        angular.forEach(patron.stat_cat_entries(), function(entry) {
+            var e = egCore.idl.fromHash('actscecm', entry);
+            e.stat_cat(e.stat_cat().id);
+            maps.push(e);
+        });
+        patron.stat_cat_entries(maps);
+
+        // service.stat_cat_entry_maps maps stats to values
+        // patron.stat_cat_entries is an array of stat_cat_entry_usr_map's
+        angular.forEach(
+            service.stat_cat_entry_maps, function(value, cat_id) {
+
+            // see if we already have a mapping for this entry
+            var existing = patron.stat_cat_entries().filter(
+                function(e) { return e.stat_cat() == cat_id })[0];
+
+            if (existing) { // we have a mapping
+                // if the existing mapping matches the new one,
+                // there' nothing left to do
+                if (existing.stat_cat_entry() == value) return;
+
+                // mappings differ.  delete the old one and create
+                // a new one below.
+                existing.isdeleted(true);
+            }
+
+            var newmap = new egCore.idl.actscecm();
+            newmap.target_usr(patron.id());
+            newmap.isnew(true);
+            newmap.stat_cat(cat_id);
+            newmap.stat_cat_entry(value);
+            patron.stat_cat_entries().push(newmap);
+        });
+
+        if (!patron.isnew()) patron.ischanged(true);
+
+        return egLovefield.addOfflineXact({
+            user        : egCore.idl.toHash(patron),
+            timestamp   : parseInt(new Date().getTime() / 1000),
+            type        : 'register',
+            delta       : 0
+        }).then(function (success) {
+            if (success) return patron;
+        });
+    }
+
+    service.remove_staged_user = function() {
+        if (!service.stage_user) return $q.when();
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.stage.delete',
+            egCore.auth.token(),
+            service.stage_user.user.row_id()
+        );
+    }
+
+    service.save_user_settings = function(new_user, user_settings) {
+        return;
+    }
+
+    // Applies field-specific validation regex's from org settings 
+    // to form fields.  Be careful not remove any pattern data we
+    // are not explicitly over-writing in the provided patterns obj.
+    service.set_field_patterns = function(patterns) {
+        if (service.org_settings['opac.username_regex']) {
+            patterns.au.usrname = 
+                new RegExp(service.org_settings['opac.username_regex']);
+        }
+
+        if (service.org_settings['opac.barcode_regex']) {
+            patterns.ac.barcode = 
+                new RegExp(service.org_settings['opac.barcode_regex']);
+        }
+
+        if (service.org_settings['global.password_regex']) {
+            patterns.au.passwd = 
+                new RegExp(service.org_settings['global.password_regex']);
+        }
+
+        var phone_reg = service.org_settings['ui.patron.edit.phone.regex'];
+        if (phone_reg) {
+            // apply generic phone regex first, replace below as needed.
+            patterns.au.day_phone = new RegExp(phone_reg);
+            patterns.au.evening_phone = new RegExp(phone_reg);
+            patterns.au.other_phone = new RegExp(phone_reg);
+        }
+
+        // the remaining patterns fit a well-known key name pattern
+
+        angular.forEach(service.org_settings, function(val, key) {
+            if (!val) return;
+            var parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
+            if (!parts) return;
+            var cls = parts[1];
+            var name = parts[2];
+            patterns[cls][name] = new RegExp(val);
+        });
+    }
+
+    return service;
+}])
+
+.controller('PatronRegCtrl',
+       ['$scope','$routeParams','$q','$uibModal','$window','egCore',
+        'patronSvc','patronRegSvc','egUnloadPrompt','egAlertDialog',
+        'egWorkLog','$timeout','egLovefield',
+function($scope , $routeParams , $q , $uibModal , $window , egCore ,
+         patronSvc , patronRegSvc , egUnloadPrompt, egAlertDialog ,
+         egWorkLog , $timeout , egLovefield) {
+
+    patronRegSvc.org = $scope.workstation.owning_lib;
+    $scope.offline = true;
+
+    $scope.page_data_loaded = false;
+    $scope.clone_id = patronRegSvc.clone_id = $routeParams.clone_id;
+    $scope.stage_username = 
+        patronRegSvc.stage_username = $routeParams.stage_username;
+    $scope.patron_id = 
+        patronRegSvc.patron_id = $routeParams.edit_id || $routeParams.id;
+
+    // for existing patrons, disable barcode input by default
+    $scope.disable_bc = $scope.focus_usrname = Boolean($scope.patron_id);
+    $scope.focus_bc = !Boolean($scope.patron_id);
+    $scope.address_alerts = [];
+    $scope.dupe_counts = {};
+
+    // map of perm name to true/false for perms the logged in user
+    // has at the currently selected patron home org unit.
+    $scope.perms = {};
+
+    $scope.edit_passthru = {};
+
+    // 0=all, 1=suggested, 2=all
+    $scope.edit_passthru.vis_level = 2; 
+
+    // Apply default values for new patrons during initial registration
+    // prs is shorthand for patronSvc
+    function set_new_patron_defaults(prs) {
+        if (!$scope.patron.passwd) {
+            // passsword may originate from staged user.
+            $scope.generate_password();
+        }
+        $scope.hold_notify_phone = true;
+        $scope.hold_notify_email = true;
+
+        // staged users may be loaded w/ a profile.
+        $scope.set_expire_date();
+
+        if (prs.org_settings['ui.patron.default_ident_type']) {
+            // $scope.patron needs this field to be an object
+            var id = prs.org_settings['ui.patron.default_ident_type'];
+            var ident_type = $scope.ident_types.filter(
+                function(type) { return type.id() == id })[0];
+            $scope.patron.ident_type = ident_type;
+        }
+        if (prs.org_settings['ui.patron.default_inet_access_level']) {
+            // $scope.patron needs this field to be an object
+            var id = prs.org_settings['ui.patron.default_inet_access_level'];
+            var level = $scope.net_access_levels.filter(
+                function(lvl) { return lvl.id() == id })[0];
+            $scope.patron.net_access_level = level;
+        }
+        if (prs.org_settings['ui.patron.default_country']) {
+            $scope.patron.addresses[0].country = 
+                prs.org_settings['ui.patron.default_country'];
+        }
+    }
+
+    // A null or undefined pattern leads to exceptions.  Before the
+    // patterns are loaded from the server, default all patterns
+    // to an innocuous regex.  To avoid re-creating numerous
+    // RegExp objects, cache the stub RegExp after initial creation.
+    // note: angular docs say ng-pattern accepts a regexp or string,
+    // but as of writing, it only works with a regexp object.
+    // (Likely an angular 1.2 vs. 1.4 issue).
+    var field_patterns = {au : {}, ac : {}, aua : {}};
+    $scope.field_pattern = function(cls, field) { 
+        if (!field_patterns[cls][field])
+            field_patterns[cls][field] = new RegExp('.*');
+        return field_patterns[cls][field];
+    }
+
+    patronRegSvc.offlineMode($scope.offline); // force offline if ng-init'd to do so
+    patronRegSvc.init().then(function() {
+        // called after initTab and patronRegSvc.init have completed
+    
+        var prs = patronRegSvc; // brevity
+        // in standalone mode, we have no patronSvc
+        $scope.patron = prs.init_patron(patronSvc ? patronSvc.current : null);
+        $scope.field_doc = prs.field_doc;
+        $scope.edit_profiles = prs.edit_profiles;
+        $scope.ident_types = prs.ident_types;
+        $scope.net_access_levels = prs.net_access_levels;
+        $scope.user_setting_types = prs.user_setting_types;
+        $scope.opt_in_setting_types = prs.opt_in_setting_types;
+        $scope.org_settings = prs.org_settings;
+        $scope.sms_carriers = prs.sms_carriers;
+        $scope.stat_cats = prs.stat_cats;
+        $scope.surveys = prs.surveys;
+        $scope.survey_responses = prs.survey_responses;
+        $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps;
+        $scope.stage_user = prs.stage_user;
+        $scope.stage_user_requestor = prs.stage_user_requestor;
+    
+        $scope.user_settings = prs.user_settings;
+        // clone the user settings back into the patronRegSvc so
+        // we have a copy of the original state of the settings.
+        prs.user_settings = {};
+        angular.forEach($scope.user_settings, function(val, key) {
+            prs.user_settings[key] = val;
+        });
+    
+        extract_hold_notify();
+        $scope.handle_home_org_changed();
+    
+        if ($scope.org_settings['ui.patron.edit.default_suggested'])
+            $scope.edit_passthru.vis_level = 1;
+    
+        if ($scope.patron.isnew) 
+            set_new_patron_defaults(prs);
+    
+        $scope.page_data_loaded = true;
+    
+        prs.set_field_patterns(field_patterns);
+        apply_username_regex();
+    });
+    
+
+
+    // update the currently displayed field documentation
+    $scope.set_selected_field_doc = function(cls, field) {
+        $scope.selected_field_doc = $scope.field_doc[cls][field];
+    }
+
+    // returns the tree depth of the selected profile group tree node.
+    $scope.pgt_depth = function(grp) {
+        var d = 0;
+        while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+        return d;
+    }
+
+    // IDL fields used for labels in the UI.
+    $scope.idl_fields = {
+        au  : egCore.idl.classes.au.field_map,
+        ac  : egCore.idl.classes.ac.field_map,
+        aua : egCore.idl.classes.aua.field_map
+    };
+
+    // field visibility cache.  Some fields are universally required.
+    // 3 == value universally required
+    // 2 == field is visible by default
+    // 1 == field is suggested by default
+    var field_visibility = {};
+    var default_field_visibility = {
+        'ac.barcode' : 3,
+        'au.usrname' : 3,
+        'au.passwd' :  3,
+        'au.first_given_name' : 3,
+        'au.family_name' : 3,
+        'au.ident_type' : 3,
+        'au.home_ou' : 3,
+        'au.profile' : 3,
+        'au.expire_date' : 3,
+        'au.net_access_level' : 3,
+        'aua.address_type' : 3,
+        'aua.post_code' : 3,
+        'aua.street1' : 3,
+        'aua.street2' : 2,
+        'aua.city' : 3,
+        'aua.county' : 2,
+        'aua.state' : 2,
+        'aua.country' : 3,
+        'aua.valid' : 2,
+        'aua.within_city_limits' : 2,
+        'stat_cats' : 1,
+        'surveys' : 1
+    }; 
+
+    // Returns true if the selected field should be visible
+    // given the current required/suggested/all setting.
+    // The visibility flag applied to each field as a result of calling
+    // this function also sets (via the same flag) the requiredness state.
+    $scope.show_field = function(field_key) {
+        // org settings have not been received yet.
+        if (!$scope.org_settings) return false;
+
+        if (field_visibility[field_key] == undefined) {
+            // compile and cache the visibility for the selected field
+
+            var req_set = 'ui.patron.edit.' + field_key + '.require';
+            var sho_set = 'ui.patron.edit.' + field_key + '.show';
+            var sug_set = 'ui.patron.edit.' + field_key + '.suggest';
+
+            if ($scope.org_settings[req_set]) {
+                field_visibility[field_key] = 3;
+
+            } else if ($scope.org_settings[sho_set]) {
+                field_visibility[field_key] = 2;
+
+            } else if ($scope.org_settings[sug_set]) {
+                field_visibility[field_key] = 1;
+            }
+        }
+
+        if (field_visibility[field_key] == undefined) {
+            // No org settings were applied above.  Use the default
+            // settings if present or assume the field has no
+            // visibility flags applied.
+            field_visibility[field_key] = 
+                default_field_visibility[field_key] || 0;
+        }
+
+        return field_visibility[field_key] >= $scope.edit_passthru.vis_level;
+    }
+
+    // See $scope.show_field().
+    // A field with visbility level 3 means it's required.
+    $scope.field_required = function(cls, field) {
+
+        // Value in the password field is not required
+        // for existing patrons.
+        if (field == 'passwd' && $scope.patron && !$scope.patron.isnew) 
+          return false;
+
+        return (field_visibility[cls + '.' + field] == 3 || default_field_visibility[cls + '.' + field] == 3);
+    }
+
+    // generates a random 4-digit password
+    $scope.generate_password = function() {
+        $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000;
+    }
+
+    $scope.set_expire_date = function() {
+        if (!$scope.patron.profile) return;
+        var seconds = egCore.date.intervalToSeconds(
+            $scope.patron.profile.perm_interval());
+        var now_epoch = new Date().getTime();
+        $scope.patron.expire_date = new Date(
+            now_epoch + (seconds * 1000 /* milliseconds */))
+    }
+
+    // grp is the pgt object
+    $scope.set_profile = function(grp) {
+        $scope.patron.profile = grp;
+        $scope.set_expire_date();
+        $scope.field_modified();
+    }
+
+    $scope.invalid_profile = function() {
+        return !(
+            $scope.patron && 
+            $scope.patron.profile && 
+            $scope.patron.profile.usergroup() == 't'
+        );
+    }
+
+    $scope.new_address = function() {
+        var addr = egCore.idl.toHash(new egCore.idl.aua());
+        patronRegSvc.ingest_address($scope.patron, addr);
+        addr.id = patronRegSvc.virt_id--;
+        addr.isnew = true;
+        addr.valid = true;
+        addr.within_city_limits = true;
+        addr.country = $scope.org_settings['ui.patron.default_country'];
+        $scope.patron.addresses.push(addr);
+    }
+
+    // keep deleted addresses out of the patron object so
+    // they won't appear in the UI.  They'll be re-inserted
+    // when the patron is updated.
+    deleted_addresses = [];
+    $scope.delete_address = function(id) {
+
+        if ($scope.patron.isnew &&
+            $scope.patron.addresses.length == 1 &&
+            $scope.org_settings['ui.patron.registration.require_address']) {
+            egAlertDialog.open(egCore.strings.REG_ADDR_REQUIRED);
+            return;
+        }
+
+        var addresses = [];
+        angular.forEach($scope.patron.addresses, function(addr) {
+            if (addr.id == id) {
+                if (id > 0) {
+                    addr.isdeleted = true;
+                    deleted_addresses.push(addr);
+                }
+            } else {
+                addresses.push(addr);
+            }
+        });
+        $scope.patron.addresses = addresses;
+    } 
+
+    $scope.post_code_changed = function(addr) { 
+        if ($scope.offline) return;
+        egCore.net.request(
+            'open-ils.search', 'open-ils.search.zip', addr.post_code)
+        .then(function(resp) {
+            if (!resp) return;
+            if (resp.city) addr.city = resp.city;
+            if (resp.state) addr.state = resp.state;
+            if (resp.county) addr.county = resp.county;
+            if (resp.alert) alert(resp.alert);
+        });
+    }
+
+    $scope.replace_card = function() {
+        $scope.patron.card.active = false;
+        $scope.patron.card.ischanged = true;
+        $scope.disable_bc = false;
+
+        var new_card = egCore.idl.toHash(new egCore.idl.ac());
+        new_card.id = patronRegSvc.virt_id--;
+        new_card.isnew = true;
+        new_card.active = true;
+        new_card._primary = 'on';
+        $scope.patron.card = new_card;
+        $scope.patron.cards.push(new_card);
+    }
+
+    $scope.day_phone_changed = function(phone) {
+        if (phone && $scope.patron.isnew && 
+            $scope.org_settings['patron.password.use_phone']) {
+            $scope.patron.passwd = phone.substr(-4);
+        }
+    }
+
+    $scope.barcode_changed = function(bc) {
+        if ($scope.offline) return;
+        if (!bc) return;
+        $scope.dupe_barcode = false;
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.barcode.exists',
+            egCore.auth.token(), bc
+        ).then(function(resp) {
+            if (resp == '1') { // duplicate card
+                $scope.dupe_barcode = true;
+                console.log('duplicate barcode detected: ' + bc);
+            } else {
+                if (!$scope.patron.usrname)
+                    $scope.patron.usrname = bc;
+                // No dupe -- A-OK
+            }
+        });
+    }
+
+    $scope.cards_dialog = function() {
+        $uibModal.open({
+            templateUrl: './circ/patron/t_patron_cards_dialog',
+            controller: 
+                   ['$scope','$uibModalInstance','cards','perms',
+            function($scope , $uibModalInstance , cards , perms) {
+                // scope here is the modal-level scope
+                $scope.args = {cards : cards};
+                $scope.perms = perms;
+                $scope.ok = function() { $uibModalInstance.close($scope.args) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }],
+            resolve : {
+                cards : function() {
+                    // scope here is the controller-level scope
+                    return $scope.patron.cards;
+                },
+                perms : function() {
+                    return $scope.perms;
+                }
+            }
+        }).result.then(
+            function(args) {
+                angular.forEach(args.cards, function(card) {
+                    card.ischanged = true; // assume cards need updating, OK?
+                    if (card._primary == 'on' && 
+                        card.id != $scope.patron.card.id) {
+                        $scope.patron.card = card;
+                    }
+                });
+            }
+        );
+    }
+
+    $scope.set_addr_type = function(addr, type) {
+        var addrs = $scope.patron.addresses;
+        if (addr['_is_'+type]) {
+            angular.forEach(addrs, function(a) {
+                if (a.id != addr.id) a['_is_'+type] = false;
+            });
+        } else {
+            // unchecking mailing/billing means we have to randomly
+            // select another address to fill that role.  Select the
+            // first address in the list (that does not match the
+            // modifed address)
+            for (var i = 0; i < addrs.length; i++) {
+                if (addrs[i].id != addr.id) {
+                    addrs[i]['_is_' + type] = true;
+                    break;
+                }
+            }
+        }
+    }
+
+
+    // Translate hold notify preferences from the form/scope back into a 
+    // single user setting value for opac.hold_notify.
+    function compress_hold_notify() {
+        var hold_notify = '';
+        var splitter = '';
+        if ($scope.hold_notify_phone) {
+            hold_notify = 'phone';
+            splitter = ':';
+        }
+        if ($scope.hold_notify_email) {
+            hold_notify = splitter + 'email';
+            splitter = ':';
+        }
+        if ($scope.hold_notify_sms) {
+            hold_notify = splitter + 'sms';
+            splitter = ':';
+        }
+        $scope.user_settings['opac.hold_notify'] = hold_notify;
+    }
+
+    // dialog for selecting additional permission groups
+    $scope.secondary_groups_dialog = function() {
+        $uibModal.open({
+            templateUrl: './circ/patron/t_patron_groups_dialog',
+            controller: 
+                   ['$scope','$uibModalInstance','linked_groups','pgt_depth',
+            function($scope , $uibModalInstance , linked_groups , pgt_depth) {
+
+                $scope.pgt_depth = pgt_depth;
+                $scope.args = {
+                    linked_groups : linked_groups,
+                    edit_profiles : patronRegSvc.edit_profiles,
+                    new_profile   : patronRegSvc.edit_profiles[0]
+                };
+
+                // add a new group to the linked groups list
+                $scope.link_group = function($event, grp) {
+                    var found = false; // avoid duplicates
+                    angular.forEach($scope.args.linked_groups, 
+                        function(g) {if (g.id() == grp.id()) found = true});
+                    if (!found) $scope.args.linked_groups.push(grp);
+                    $event.preventDefault(); // avoid close
+                }
+
+                // remove a group from the linked groups list
+                $scope.unlink_group = function($event, grp) {
+                    $scope.args.linked_groups = 
+                        $scope.args.linked_groups.filter(function(g) {
+                        return g.id() != grp.id()
+                    });
+                    $event.preventDefault(); // avoid close
+                }
+
+                $scope.ok = function() { $uibModalInstance.close($scope.args) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }],
+            resolve : {
+                linked_groups : function() { return $scope.patron.groups },
+                pgt_depth : function() { return $scope.pgt_depth }
+            }
+        }).result.then(
+            function(args) {
+
+                if ($scope.patron.isnew) {
+                    // groups must be linked for new patrons after the
+                    // patron is created.
+                    $scope.patron.groups = args.linked_groups;
+                    return;
+                }
+
+                // update links groups for existing users in real time.
+                var ids = args.linked_groups.map(function(g) {return g.id()});
+                patronRegSvc.apply_secondary_groups($scope.patron.id, ids)
+                .then(function(success) {
+                    if (success)
+                        $scope.patron.groups = args.linked_groups;
+                });
+            }
+        );
+    }
+
+    function extract_hold_notify() {
+        notify = $scope.user_settings['opac.hold_notify'];
+        if (!notify) return;
+        $scope.hold_notify_phone = Boolean(notify.match(/phone/));
+        $scope.hold_notify_email = Boolean(notify.match(/email/));
+        $scope.hold_notify_sms = Boolean(notify.match(/sms/));
+    }
+
+    $scope.invalidate_field = function(field) {
+        patronRegSvc.invalidate_field($scope.patron, field);
+    }
+
+    address_alert = function(addr) {
+        if ($scope.offline) return;
+        var args = {
+            street1: addr.street1,
+            street2: addr.street2,
+            city: addr.city,
+            state: addr.state,
+            county: addr.county,
+            country: addr.country,
+            post_code: addr.post_code,
+            mailing_address: addr._is_mailing,
+            billing_address: addr._is_billing
+        }
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.address_alert.test',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), args
+            ).then(function(res) {
+                $scope.address_alerts = res;
+        });
+    }
+
+    $scope.dupe_value_changed = function(type, value) {
+        $scope.dupe_counts[type] = 0;
+        patronRegSvc.dupe_patron_search($scope.patron, type, value)
+        .then(function(res) {
+            $scope.dupe_counts[type] = res.count;
+            if (res.count) {
+                $scope.dupe_search_encoded = 
+                    encodeURIComponent(js2JSON(res.search));
+            } else {
+                $scope.dupe_search_encoded = '';
+            }
+        });
+    }
+
+    // Dummy function in offline mode
+    $scope.handle_home_org_changed = function() {}
+
+    // This is called with every character typed in a form field,
+    // since that's the only way to gaurantee something has changed.
+    // See handle_field_changed for ng-change vs. ng-blur.
+    $scope.field_modified = function() {
+        // Call attach with every field change, regardless of whether
+        // it's been called before.  This will allow for re-attach after
+        // the user clicks through the unload warning. egUnloadPrompt
+        // will ensure we only attach once.
+        egUnloadPrompt.attach($scope);
+    }
+
+    // also monitor when form is changed *by the user*, as using
+    // an ng-change handler doesn't work with eg-date-input
+    $scope.$watch('reg_form.$pristine', function(newVal, oldVal) {
+        if (!newVal) egUnloadPrompt.attach($scope);
+    });
+
+    // username regex (if present) must be removed any time
+    // the username matches the barcode to avoid firing the
+    // invalid field handlers.
+    function apply_username_regex() {
+        var regex = $scope.org_settings['opac.username_regex'];
+        if (regex) {
+            if ($scope.patron.card.barcode) {
+                // username must match the regex or the barcode
+                field_patterns.au.usrname = 
+                    new RegExp(
+                        regex + '|^' + $scope.patron.card.barcode + '$');
+            } else {
+                // username must match the regex
+                field_patterns.au.usrname = new RegExp(regex);
+            }
+        } else {
+            // username can be any format.
+            field_patterns.au.usrname = new RegExp('.*');
+        }
+    }
+
+    // obj could be the patron, an address, etc.
+    // This is called any time a form field achieves then loses focus.
+    // It does not necessarily mean the field has changed.
+    // The alternative is ng-change, but it's called with each character
+    // typed, which would be overkill for many of the actions called here.
+    $scope.handle_field_changed = function(obj, field_name) {
+        if (!obj) return;
+
+        var cls = obj.classname; // set by egIdl
+        var value = obj[field_name];
+
+        // Hush!
+        //console.log('changing field ' + field_name + ' to ' + value);
+
+        switch (field_name) {
+            case 'day_phone' : 
+                if ($scope.patron.day_phone && 
+                    $scope.patron.isnew && 
+                    $scope.org_settings['patron.password.use_phone']) {
+                    $scope.patron.passwd = phone.substr(-4);
+                }
+                break;
+
+            case 'barcode':
+                apply_username_regex();
+                break;
+
+            case 'dob':
+                maintain_juvenile_flag();
+                break;
+
+            default:
+                break;
+        }
+    }
+
+    // patron.juvenile is set to true if the user was born after
+    function maintain_juvenile_flag() {
+        if ( !($scope.patron && $scope.patron.dob) ) return;
+
+        var juv_interval = 
+            $scope.org_settings['global.juvenile_age_threshold'] 
+            || '18 years';
+
+        var base = new Date();
+
+        base.setTime(base.getTime() - 
+            Number(egCore.date.intervalToSeconds(juv_interval) + '000'));
+
+        $scope.patron.juvenile = ($scope.patron.dob > base);
+    }
+
+    // returns true (disable) for orgs that cannot have users.
+    $scope.disable_home_org = function(org_id) {
+        if (!org_id) return;
+        var org = egCore.org.get(org_id);
+        return (
+            org &&
+            org.ou_type() &&
+            org.ou_type().can_have_users() == 'f'
+        );
+    }
+
+    // Returns true if the Save and Save & Clone buttons should be disabled.
+    $scope.edit_passthru.hide_save_actions = function() {
+        return false;
+    }
+
+    // Returns true if any input elements are tagged as invalid
+    // via Angular patterns or required attributes.
+    function form_has_invalid_fields() {
+        return $('#patron-reg-container .ng-invalid').length > 0;
+    }
+
+    function form_is_incomplete() {
+        return (
+            $scope.dupe_username ||
+            $scope.dupe_barcode ||
+            form_has_invalid_fields()
+        );
+
+    }
+
+    $scope.edit_passthru.save = function(save_args) {
+        if (!save_args) save_args = {};
+
+        if (form_is_incomplete()) {
+            // User has not provided valid values for all required fields.
+            return egAlertDialog.open(egCore.strings.REG_INVALID_FIELDS);
+        }
+
+        // remove page unload warning prompt
+        egUnloadPrompt.clear();
+
+        // toss the deleted addresses back into the patron's list of
+        // addresses so it's included in the update
+        $scope.patron.addresses = 
+            $scope.patron.addresses.concat(deleted_addresses);
+        
+        compress_hold_notify();
+
+        var updated_user;
+
+        patronRegSvc.save_user($scope.patron)
+        .then(function(new_user) { 
+            // reload the current page
+            $window.location.href = location.href;
+        });
+    }
+}])
index ad41fc1..413c44d 100644 (file)
@@ -46,8 +46,8 @@ angular.module('egCoreMod')
 
 // env fetcher
 .factory('egEnv', 
-       ['$q','$window','egAuth','egPCRUD','egIDL',
-function($q,  $window , egAuth,  egPCRUD,  egIDL) { 
+       ['$q','$window','$injector','egAuth','egPCRUD','egIDL',
+function($q,  $window , $injector , egAuth,  egPCRUD,  egIDL) { 
 
     var service = {
         // collection of custom loader functions
@@ -87,19 +87,25 @@ function($q,  $window , egAuth,  egPCRUD,  egIDL) {
     /** given a tree-shaped collection, captures the tree and
      *  flattens the tree for absorption.
      */
-    service.absorbTree = function(tree, class_) {
+    service.absorbTree = function(tree, class_, noOffline) {
+        if (service[class_] && service[class_].loaded) return;
+
         var list = [];
         function squash(node) {
             list.push(node);
             angular.forEach(node.children(), squash);
         }
         squash(tree);
-        var blob = service.absorbList(list, class_);
+        var blob = service.absorbList(list, class_, noOffline);
         blob.tree = tree;
     };
 
+    var egLovefield; // we'll inject it manually
+
     /** caches the object list both as the list and an id => object map */
-    service.absorbList = function(list, class_) {
+    service.absorbList = function(list, class_, noOffline) {
+        if (service[class_] && service[class_].loaded) return service[class_];
+
         var blob;
         var pkey = egIDL.classes[class_].pkey;
 
@@ -116,8 +122,18 @@ function($q,  $window , egAuth,  egPCRUD,  egIDL) {
             blob = {list : list, map : {}};
         }
 
+        if (!noOffline) {
+            if (!egLovefield) {
+                egLovefield = $injector.get('egLovefield');
+            }
+            egLovefield.isCacheGood(class_).then(function(good) {
+                if (!good) egLovefield.setListInOfflineCache(class_, blob.list);
+            });
+        }
+
         angular.forEach(list, function(item) {blob.map[item[pkey]()] = item});
         service[class_] = blob;
+        service[class_].loaded = true;
         return blob;
     };
 
@@ -135,35 +151,45 @@ function($q,  $window , egAuth,  egPCRUD,  egIDL) {
     service.classLoaders = {
         aou : function() {
 
-            // EXPERIMENT: cache the org tree in session storage.
-            // This means that if the org tree changes, users will have to
-            // open the client in a new browser tab to clear the cached tree.
-            var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree');
-            if (treeJSON) {
-                console.debug('serving org tree from cache');
-                var tree = JSON2js(treeJSON);
-                service.absorbTree(tree, 'aou')
-                return $q.when(tree);
-            }
-
-            // sort orgs at each level by shortname
-            function sort_aou(node) {
-                node.children(node.children().sort(function(a, b) {
-                    return a.shortname() < b.shortname() ? -1 : 1;
-                }));
-                angular.forEach(node.children(), sort_aou);
+            if (!egLovefield) {
+                egLovefield = $injector.get('egLovefield');
             }
 
-            return egPCRUD.search('aou', {parent_ou : null}, 
-                {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
-            ).then(
-                function(tree) {
-                    sort_aou(tree);
-                    $window.sessionStorage.setItem(
-                        'eg.env.aou.tree', js2JSON(tree));
+            return egLovefield.reconstituteTree('aou').then(function(offline) {
+                if (offline) return $q.when();
+                if (service.aou && service.aou.loaded) return $q.when();
+    
+                // EXPERIMENT: cache the org tree in session storage.
+                // This means that if the org tree changes, users will have to
+                // open the client in a new browser tab to clear the cached tree.
+                var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree');
+                if (treeJSON) {
+                    console.debug('serving org tree from cache');
+                    var tree = JSON2js(treeJSON);
                     service.absorbTree(tree, 'aou')
+                    return $q.when(tree);
                 }
-            );
+    
+                // sort orgs at each level by shortname
+                function sort_aou(node) {
+                    node.children(node.children().sort(function(a, b) {
+                        return a.shortname() < b.shortname() ? -1 : 1;
+                    }));
+                    angular.forEach(node.children(), sort_aou);
+                }
+    
+                return egPCRUD.search('aou', {parent_ou : null}, 
+                    {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+                ).then(
+                    function(tree) {
+                        sort_aou(tree);
+                        $window.sessionStorage.setItem(
+                            'eg.env.aou.tree', js2JSON(tree));
+                        service.absorbTree(tree, 'aou');
+                        return $q.when();
+                    }
+                );
+            });
         },
     };
 
index 83e0d29..829d4ca 100644 (file)
@@ -35,18 +35,72 @@ angular.module('egCoreMod')
             defaultFileName: '='
         },
         link: function (scope, element, attributes) {
+            var name = scope.defaultFileName || 'evergreen-json-export';
             element.bind('click', function (clickEvent) {
                 if (scope.generator) {
                     scope.generator().then(function(value) {
                         var data = new Blob([JSON.stringify(value)], {type : 'application/json'});
-                        FileSaver.saveAs(data, scope.defaultFileName);
+                        FileSaver.saveAs(data, name);
                     });
                 } else {
                     var data = new Blob([JSON.stringify(scope.container)], {type : 'application/json'});
-                    FileSaver.saveAs(data, scope.defaultFileName);
+                    FileSaver.saveAs(data, name);
                 }
             });
         }
     }
 }])
+
+// The following directives use a attr instead of binding to get the default file name!
+.directive('egStringExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) {
+    return {
+        scope: {
+            contentType: '=',
+            string: '=',
+            generator: '=',
+            defaultFileName: '@'
+        },
+        link: function (scope, element, attributes) {
+            var type = scope.contentType || 'text/plain';
+            var name = scope.defaultFileName || 'evergreen-string-export';
+            element.bind('click', function (clickEvent) {
+                if (scope.generator) {
+                    scope.generator().then(function(value) {
+                        var data = new Blob([value], {type : type});
+                        FileSaver.saveAs(data, name);
+                    });
+                } else {
+                    var data = new Blob([scope.string], {type : type});
+                    FileSaver.saveAs(data, name);
+                }
+            });
+        }
+    }
+}])
+
+.directive('egLineExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) {
+    return {
+        scope: {
+            contentType: '=',
+            jsonArray: '=',
+            defaultFileName: '@'
+        },
+        link: function (scope, element, attributes) {
+            element.bind('click', function (clickEvent) {
+                var type = scope.contentType || 'text/plain';
+                var fname = scope.defaultFileName || 'evergreen-string-export';
+                FileSaver.saveAs(
+                    new Blob(
+                        scope.jsonArray.map(function (line) {
+                            return JSON.stringify(line) + '\n';
+                        }),
+                        {type : type}
+                    ),
+                    fname
+                );
+            });
+        }
+    }
+}])
+
 ;
index 4fee7cb..0a27b15 100644 (file)
@@ -310,6 +310,42 @@ angular.module('egCoreMod')
         $window.localStorage.setItem(key, jsonified);
     }
 
+    service.appendItem = function(key, value) {
+        if (!service.useSettings())
+            return $q.when(service.appendLocalItem(key, value));
+
+        if (service.hatchAvailable)
+            return service.appendRemoteItem(key, value);
+
+        if (service.keyIsOnCall(key)) {
+            console.warn("Unable to appendItem in Hatch: " + 
+                key + ". Setting in local storage instead");
+
+            return $q.when(service.appendLocalItem(key, value));
+        }
+
+        console.error("Unable to appendItem in Hatch: " + key);
+        return $q.reject();
+    }
+
+    // append the value to a stored or new item
+    service.appendRemoteItem = function(key, value) {
+        service.keyCache[key] = value;
+        return service.attemptHatchDelivery({
+            key : key, 
+            content : value, 
+            action : 'append',
+        });
+    }
+
+    service.appendLocalItem = function(key, value, jsonified) {
+        if (jsonified === undefined ) 
+            jsonified = JSON.stringify(value);
+
+        var old_value = $window.localStorage.getItem(key) || '';
+        $window.localStorage.setItem( key, old_value + jsonified );
+    }
+
     // Set the value for the given key.  
     // "LoginSession" items are removed when the user logs out or the 
     // browser is closed.
index 90a62be..440827b 100644 (file)
@@ -51,8 +51,6 @@ angular.module('egCoreMod')
                                 obj.a[i][j] = angular.copy(thing[j]);
                             }
                         }
-                    } else {
-                        obj.a[i] = angular.copy(thing);
                     }
                 }
             }
diff --git a/Open-ILS/web/js/ui/default/staff/services/lovefield.js b/Open-ILS/web/js/ui/default/staff/services/lovefield.js
new file mode 100644 (file)
index 0000000..7e13a18
--- /dev/null
@@ -0,0 +1,392 @@
+var osb = lf.schema.create('offline', 2);
+
+osb.createTable('Object').
+    addColumn('type', lf.Type.STRING).          // class hint
+    addColumn('id', lf.Type.STRING).           // obj id
+    addColumn('object', lf.Type.OBJECT).
+    addPrimaryKey(['type','id']);
+
+osb.createTable('CacheDate').
+    addColumn('type', lf.Type.STRING).          // class hint
+    addColumn('cachedate', lf.Type.DATE_TIME).  // when was it last updated
+    addPrimaryKey(['type']);
+
+osb.createTable('Setting').
+    addColumn('name', lf.Type.STRING).
+    addColumn('value', lf.Type.STRING).
+    addPrimaryKey(['name']);
+
+osb.createTable('StatCat').
+    addColumn('id', lf.Type.INTEGER).
+    addColumn('value', lf.Type.OBJECT).
+    addPrimaryKey(['id']);
+
+osb.createTable('OfflineXact').
+    addColumn('seq', lf.Type.INTEGER).
+    addColumn('value', lf.Type.OBJECT).
+    addPrimaryKey(['seq'], true);
+
+osb.createTable('OfflineBlocks').
+    addColumn('barcode', lf.Type.STRING).
+    addColumn('reason', lf.Type.STRING).
+    addPrimaryKey(['barcode']);
+
+lf.connecting = true;
+osb.connect().then(function (db) {
+    lf.offlineDB = db;
+    lf.connecting = false;
+});
+
+/**
+ * Core Service - egLovefield
+ *
+ * Lovefield wrapper factory for low level offline stuff
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egLovefield', ['$q','$rootScope','egCore','$timeout', 
+                 function($q , $rootScope , egCore , $timeout) { 
+    
+    var service = {};
+
+    function connectOrGo (resolver) {
+        if (lf.offlineDB) {
+            return resolver();
+        }
+
+        // apparently, this might take a while...
+        if (lf.connecting) return $timeout(function() {
+                return connectOrGo(resolver);
+        });
+
+        console.log('egLovefield connecting to offline DB');
+
+        try {
+            return osb.connect().then(function (db) {
+                lf.offlineDB = db;
+                return resolver();
+            });
+        } catch (err) {
+                alert('attempted reconnect failure: ' + err.toString());
+        }
+    }
+
+    service.isCacheGood = function (type) {
+
+        return connectOrGo(function() {
+            var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
+
+            return lf.offlineDB.
+                select(cacheDate.cachedate).
+                from(cacheDate).
+                where(cacheDate.type.eq(type)).
+                exec().then(function(results) {
+                    if (results.length == 0) {
+                        return $q.when(false);
+                    }
+
+                    var now = new Date();
+    
+                    // hard-coded 1 day offline cache timeout
+                    return $q.when((now.getTime() - results[0]['cachedate'].getTime()) <= 86400000);
+                })
+        });
+    }
+
+    service.destroyPendingOfflineXacts = function () {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineXact');
+            return lf.offlineDB.
+                delete().
+                from(table).
+                exec();
+        });
+    }
+
+    service.retrievePendingOfflineXacts = function () {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineXact');
+            return lf.offlineDB.
+                select(table.value).
+                from(table).
+                exec().
+                then(function(list) {
+                    return $q.when(list.map(function(x) { return x.value }))
+                });
+        });
+    }
+
+    service.destroyOfflineBlocks = function () {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+            return $q.when(
+                lf.offlineDB.
+                    delete().
+                    from(table).
+                    exec()
+            );
+        });
+    }
+
+    service.addOfflineBlock = function (barcode, reason) {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+            return $q.when(
+                lf.offlineDB.
+                    insertOrReplace().
+                    into(table).
+                    values([ table.createRow({ barcode : barcode, reason : reason }) ]).
+                    exec()
+            );
+        });
+    }
+
+    // Returns a promise with true for blocked, false for not blocked
+    service.testOfflineBlock = function (barcode) {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+            return lf.offlineDB.
+                select().
+                from(table).
+                where(table.barcode.eq(barcode)).
+                exec().then(function(list) {
+                    return $q.when(Boolean(list.length > 0));
+                });
+        });
+    }
+
+    service.addOfflineXact = function (obj) {
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('OfflineXact');
+            return $q.when(
+                lf.offlineDB.
+                    insertOrReplace().
+                    into(table).
+                    values([ table.createRow({ value : obj }) ]).
+                    exec()
+            );
+        });
+    }
+
+    service.setStatCatsCache = function (statcats) {
+        if (lf.isOffline) return $q.when();
+
+        return connectOrGo(function() {
+            var table = lf.offlineDB.getSchema().table('StatCat');
+            var rlist = [];
+
+            angular.forEach(statcats, function (val) {
+                rlist.push(table.createRow({
+                    id    : val.id(),
+                    value : egCore.idl.toHash(val)
+                }));
+            });
+            return lf.offlineDB.
+                insertOrReplace().
+                into(table).
+                values(rlist).
+                exec();
+        });
+    }
+
+    service.getStatCatsCache = function () {
+        return connectOrGo(function() {
+
+            var table = lf.offlineDB.getSchema().table('StatCat');
+            var result = [];
+            return lf.offlineDB.
+                select(table.value).
+                from(table).
+                exec().then(function(list) {
+                    angular.forEach(list, function (s) {
+                        var sc = egCore.idl.fromHash('actsc', s.value);
+    
+                        if (angular.isArray(sc.default_entries())) {
+                            sc.default_entries(
+                                sc.default_entries().map( function (k) {
+                                    return egCore.idl.fromHash('actsced', k);
+                                })
+                            );
+                        }
+    
+                        if (angular.isArray(sc.entries())) {
+                            sc.entries(
+                                sc.entries().map( function (k) {
+                                    return egCore.idl.fromHash('actsce', k);
+                                })
+                            );
+                        }
+    
+                        result.push(sc);
+                    });
+                    return $q.when(result);
+                });
+    
+        });
+    }
+
+    service.setSettingsCache = function (settings) {
+        if (lf.isOffline) return $q.when();
+
+        return connectOrGo(function() {
+
+            var table = lf.offlineDB.getSchema().table('Setting');
+            var rlist = [];
+
+            angular.forEach(settings, function (val, key) {
+                rlist.push(
+                    table.createRow({
+                        name  : key,
+                        value : JSON.stringify(val)
+                    })
+                );
+            });
+
+            return lf.offlineDB.
+                insertOrReplace().
+                into(table).
+                values(rlist).
+                exec();
+        });
+    }
+
+    service.getSettingsCache = function (settings) {
+        return connectOrGo(function() {
+
+            var table = lf.offlineDB.getSchema().table('Setting');
+
+            var search_pred = table.name.isNotNull();
+            if (settings && settings.length) {
+                search_pred = table.name.in(settings);
+            }
+                
+            return lf.offlineDB.
+                select(table.name, table.value).
+                from(table).
+                where(search_pred).
+                exec().then(function(list) {
+                    angular.forEach(list, function (s) {
+                        s.value = JSON.parse(s.value)
+                    });
+                    return $q.when(list);
+                });
+        });
+    }
+
+    service.setListInOfflineCache = function (type, list) {
+        if (lf.isOffline) return $q.when();
+
+        return connectOrGo(function() {
+
+            service.isCacheGood(type).then(function(good) {
+                if (!good) {
+                    var object = lf.offlineDB.getSchema().table('Object');
+                    var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
+                    var pkey = egCore.idl.classes[type].pkey;
+        
+                    angular.forEach(list, function(item) {
+                        var row = object.createRow({
+                            type    : type,
+                            id      : '' + item[pkey](),
+                            object  : egCore.idl.toHash(item)
+                        });
+                        lf.offlineDB.insertOrReplace().into(object).values([row]).exec();
+                    });
+        
+                    var row = cacheDate.createRow({
+                        type      : type,
+                        cachedate : new Date()
+                    });
+        
+                    console.log('egLovefield saving ' + type + ' list');
+                    lf.offlineDB.insertOrReplace().into(cacheDate).values([row]).exec();
+                }
+            })
+        });
+    }
+
+    service.getListFromOfflineCache = function(type) {
+        return connectOrGo(function() {
+
+            var object = lf.offlineDB.getSchema().table('Object');
+
+            return lf.offlineDB.
+                select(object.object).
+                from(object).
+                where(object.type.eq(type)).
+                exec().then(function(results) {
+                    return $q.when(results.map(function(item) {
+                        return egCore.idl.fromHash(type,item['object'])
+                    }));
+                });
+        });
+    }
+
+    service.reconstituteList = function(type) {
+        if (lf.isOffline) {
+            console.log('egLovefield reading ' + type + ' list');
+            return service.getListFromOfflineCache(type).then(function (list) {
+                egCore.env.absorbList(list, type, true)
+                return $q.when(true);
+            });
+        }
+        return $q.when(false);
+    }
+
+    service.reconstituteTree = function(type) {
+        if (lf.isOffline) {
+            console.log('egLovefield reading ' + type + ' tree');
+
+            var pkey = egCore.idl.classes[type].pkey;
+            var parent_field = 'parent';
+
+            if (type == 'aou') {
+                parent_field = 'parent_ou';
+            }
+
+            return service.getListFromOfflineCache(type).then(function (list) {
+                var hash = {};
+                var top = null;
+                angular.forEach(list, function (item) {
+
+                    // Special case for aou, to reconstitue ou_type
+                    if (type == 'aou') {
+                        if (item.ou_type()) {
+                            item.ou_type( egCore.idl.fromHash('aout', item.ou_type()) );
+                        }
+                    }
+
+                    hash[''+item[pkey]()] = item;
+                    if (!item[parent_field]()) {
+                        top = item;
+                    } else if (angular.isObject(item[parent_field]())) {
+                        // un-objectify the parent
+                        item[parent_field](
+                            item[parent_field]()[pkey]()
+                        );
+                    }
+                });
+
+                angular.forEach(list, function (item) {
+                    item.children([]); // just clear it out if there's junk in there
+
+                    if (item[parent_field]()) {
+                        item[parent_field]( hash[''+item[parent_field]()] );
+                    }
+
+                    item.children( list.filter(function (kid) {
+                        return kid[parent_field]() == item[pkey]();
+                    }) );
+                });
+
+                egCore.env.absorbTree(top, type, true)
+                return $q.when(true)
+            });
+        }
+        return $q.when(false);
+    }
+
+    return service;
+}]);
+
index e9fcc2d..93efc44 100644 (file)
@@ -18,8 +18,8 @@
 angular.module('egCoreMod')
 
 .factory('egOrg', 
-       ['$q','egEnv','egAuth','egNet',
-function($q,  egEnv,  egAuth,  egNet) { 
+       ['$q','egEnv','egAuth','egNet','$injector',
+function($q,  egEnv,  egAuth,  egNet , $injector) { 
 
     var service = {};
 
@@ -98,17 +98,34 @@ function($q,  egEnv,  egAuth,  egNet) {
         return list;
     }
 
+    var egLovefield = null;
     // returns a promise, resolved with a hash of setting name =>
     // setting value for the selected org unit.  Org unit defaults to 
     // auth workstation org unit.
     service.settings = function(names, ou_id) {
+        if (!egLovefield) {
+            egLovefield = $injector.get('egLovefield');
+        }
+
+        // allow non-array
+        if (!angular.isArray(names)) names = [names];
+
+        if (lf.isOffline) {
+            return egLovefield.getSettingsCache(names)
+                .then(function(settings) {
+                    var hash = {};
+                    angular.forEach(settings, function (s) {
+                        hash[s.name] = s.value;
+                    });
+                    return $q.when(hash);
+                });
+        }
+
         var deferred = $q.defer();
         ou_id = ou_id || egAuth.user().ws_ou();
         var here = (ou_id == egAuth.user().ws_ou());
 
-        // allow non-array
-        if (!angular.isArray(names)) names = [names];
-        
+       
         if (here) { 
             // only cache org settings retrieved for the current 
             // workstation org unit.
@@ -136,9 +153,11 @@ function($q,  egEnv,  egAuth,  egNet) {
                 if (here) service.cachedSettings[key] = settings[key];
             });
 
-            // resolve with cached settings if 'here', since 'settings'
-            // will only contain settings we had to retrieve
-            deferred.resolve(here ? service.cachedSettings : settings);
+            return egLovefield.setSettingsCache(settings).then(function() {
+                // resolve with cached settings if 'here', since 'settings'
+                // will only contain settings we had to retrieve
+                deferred.resolve(here ? service.cachedSettings : settings);
+            });
         });
         return deferred.promise;
     }
index 97f8333..9734829 100644 (file)
@@ -49,9 +49,11 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg
     service.fleshPrintScope = function(scope) {
         if (!scope) scope = {};
         scope.today = new Date().toISOString();
-        scope.staff = egIDL.toHash(egAuth.user());
-        scope.current_location = 
-            egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+        if (!lf.isOffline) {
+            scope.staff = egIDL.toHash(egAuth.user());
+            scope.current_location = 
+                egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+        }
     }
 
     service.last_print = {};
index 670248c..5d0e7e4 100644 (file)
@@ -22,6 +22,8 @@ function($q,  $rootScope,  $location,  $window,  egIDL,  egAuth,  egEnv) {
     // returns true if we are staying on the current page
     // false if we are redirecting to login
     service.expiredAuthHandler = function() {
+        if (lf.isOffline) return true; // Only set by the offline UI
+
         console.debug('egStartup.expiredAuthHandler()');
         egAuth.logout(); // clean up
 
index f08ac32..6e5932e 100644 (file)
@@ -568,8 +568,8 @@ function($window , egStrings) {
            + '</ul>'
           + '</div>',
 
-        controller : ['$scope','$timeout','egCore','egStartup',
-              function($scope , $timeout , egCore , egStartup) {
+        controller : ['$scope','$timeout','egCore','egStartup','egLovefield','$q',
+              function($scope , $timeout , egCore , egStartup , egLovefield , $q) {
 
             if ($scope.alldisabled) {
                 $scope.disable_button = $scope.alldisabled == 'true' ? true : false;
@@ -586,32 +586,40 @@ function($window , egStrings) {
             //
             // controller() runs before link().
             // This post-startup code runs after link().
-            egStartup.go().then(function() {
-
-                $scope.orgList = egCore.org.list().map(function(org) {
-                    return {
-                        id : org.id(),
-                        shortname : org.shortname(), 
-                        depth : org.ou_type().depth()
+            egStartup.go(
+            ).then(
+                function() {
+                    return egCore.env.classLoaders.aou();
+                }
+            ).then(
+                function() {
+
+                    $scope.orgList = egCore.org.list().map(function(org) {
+                        return {
+                            id : org.id(),
+                            shortname : org.shortname(), 
+                            depth : org.ou_type().depth()
+                        }
+                    });
+                    
+    
+                    // Apply default values
+    
+                    if ($scope.stickySetting) {
+                        var orgId = egCore.hatch.getLocalItem($scope.stickySetting);
+                        if (orgId) {
+                            $scope.selected = egCore.org.get(orgId);
+                        }
                     }
-                });
-
-                // Apply default values
-
-                if ($scope.stickySetting) {
-                    var orgId = egCore.hatch.getLocalItem($scope.stickySetting);
-                    if (orgId) {
-                        $scope.selected = egCore.org.get(orgId);
+    
+                    if (!$scope.selected && !$scope.nodefault && egCore.auth.user()) {
+                        $scope.selected = 
+                            egCore.org.get(egCore.auth.user().ws_ou());
                     }
+    
+                    fire_orgsel_onchange(); // no-op if nothing is selected
                 }
-
-                if (!$scope.selected && !$scope.nodefault) {
-                    $scope.selected = 
-                        egCore.org.get(egCore.auth.user().ws_ou());
-                }
-
-                fire_orgsel_onchange(); // no-op if nothing is selected
-            });
+            );
 
             /**
              * Fire onchange handler after a timeout, so the
@@ -660,6 +668,17 @@ function($window , egStrings) {
     }
 })
 
+.directive('nextOnEnter', function () {
+    return function (scope, element, attrs) {
+        element.bind("keydown keypress", function (event) {
+            if(event.which === 13) {
+                $('#'+attrs.nextOnEnter).focus();
+                event.preventDefault();
+            }
+        });
+    };
+})
+
 /* http://eric.sau.pe/angularjs-detect-enter-key-ngenter/ */
 .directive('egEnter', function () {
     return function (scope, element, attrs) {
@@ -683,6 +702,7 @@ function($window , egStrings) {
     function(egStrings, egCore) {
         return {
             scope : {
+                id : '@',
                 closeText : '@',
                 ngModel : '=',
                 ngChange : '=',
diff --git a/Open-ILS/web/upup.min.js b/Open-ILS/web/upup.min.js
new file mode 100644 (file)
index 0000000..c28fe99
--- /dev/null
@@ -0,0 +1,7 @@
+//! UpUp
+//! version : 0.3.0
+//! author  : Tal Ater @TalAter
+//! license : MIT
+//! https://github.com/TalAter/UpUp
+(function(a){"use strict";var b=this,c=navigator.serviceWorker;if(!c)return b.UpUp=null,a;var d={"service-worker-url":"upup.sw.min.js"},e=!1;b.UpUp={start:function(a){this.addSettings(a),c.register(d["service-worker-url"],{scope:"./"}).then(function(a){e&&console.log("Service worker registration successful with scope: %c"+a.scope,"font-weight: bold; color: #00f;"),(a.installing||c.controller).postMessage({action:"set-settings",settings:d})}).catch(function(a){e&&console.log("Service worker registration failed: %c"+a,"font-weight: bold; color: #00f;")})},addSettings:function(b){b=b||{},"string"==typeof b&&(b={content:b}),["content","content-url","assets","service-worker-url","cache-version"].forEach(function(c){b[c]!==a&&(d[c]=b[c])})},debug:function(a){e=!(arguments.length>0)||!!a}}}).call(this);
+//# sourceMappingURL=upup.min.js.map
\ No newline at end of file
diff --git a/Open-ILS/web/upup.sw.min.js b/Open-ILS/web/upup.sw.min.js
new file mode 100644 (file)
index 0000000..87f2ece
--- /dev/null
@@ -0,0 +1,7 @@
+//! UpUp Service Worker
+//! version : 0.3.0
+//! author  : Tal Ater @TalAter
+//! license : MIT
+//! https://github.com/TalAter/UpUp
+var _CACHE_NAME_PREFIX="upup-cache",_calculateHash=function(a){a=a.toString();var b,c,d=0,e=a.length;if(0===e)return d;for(b=0;b<e;b++)c=a.charCodeAt(b),d=(d<<5)-d+c,d|=0;return d};self.addEventListener("message",function(a){"set-settings"===a.data.action&&_parseSettingsAndCache(a.data.settings)}),self.addEventListener("fetch",function(a){a.respondWith(fetch(a.request).catch(function(){return caches.match(a.request).then(function(b){return b||("navigate"===a.request.mode||"GET"===a.request.method&&a.request.headers.get("accept").includes("text/html")?caches.match("sw-offline-content"):void 0)})}))});var _parseSettingsAndCache=function(a){var b=_CACHE_NAME_PREFIX+"-"+(a["cache-version"]?a["cache-version"]+"-":"")+_calculateHash(a.content+a["content-url"]+a.assets);return caches.open(b).then(function(b){return a.assets&&b.addAll(a.assets.map(function(a){return new Request(a,{mode:"no-cors"})})),a["content-url"]?fetch(a["content-url"],{mode:"no-cors"}).then(function(a){return b.put("sw-offline-content",a)}):a.content?b.put("sw-offline-content",_buildResponse(a.content)):b.put("sw-offline-content",_buildResponse("You are offline"))}).then(function(){return caches.keys().then(function(a){return Promise.all(a.map(function(a){if(a.startsWith(_CACHE_NAME_PREFIX)&&b!==a)return caches.delete(a)}))})})},_buildResponse=function(a){return new Response(a,{headers:{"Content-Type":"text/html"}})};
+//# sourceMappingURL=upup.sw.min.js.map
\ No newline at end of file