[open-ils-commits] [GIT] Evergreen ILS branch rel_3_5 updated. 8c4ae290499cf56c1b465b489929e99930b5c8ca

Evergreen Git git at git.evergreen-ils.org
Tue Apr 28 15:21:19 EDT 2020


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

The branch, rel_3_5 has been updated
       via  8c4ae290499cf56c1b465b489929e99930b5c8ca (commit)
       via  27d7149f63f10e7327bfa20b1f5777d3ab54b1ab (commit)
       via  de722bbabe441e6abd1957897541994120c1d0ad (commit)
       via  2ac1ca873857d6343791e409f2b510acd5dba18f (commit)
      from  a55c50339955291063b809a46f59fcb7707d8787 (commit)

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

- Log -----------------------------------------------------------------
commit 8c4ae290499cf56c1b465b489929e99930b5c8ca
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Dec 26 12:17:55 2019 -0500

    LP1848550 / LP1835128 Redirect to AngJS splash page
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>

diff --git a/Open-ILS/src/eg2/src/app/staff/login.component.ts b/Open-ILS/src/eg2/src/app/staff/login.component.ts
index 5a8d7c4ca6..207c525bdf 100644
--- a/Open-ILS/src/eg2/src/app/staff/login.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/login.component.ts
@@ -94,8 +94,12 @@ export class StaffLoginComponent implements OnInit {
                         // Force reload of the app after a successful login.
                         // This allows the route resolver to re-run with a
                         // valid auth token and workstation.
-                        window.location.href =
-                            this.ngLocation.prepareExternalUrl(url);
+
+                        // Temporarily redirect to AngularJS splash page
+                        // (LP#1848550/LP#1835128)
+                        window.location.href = '/eg/staff/splash';
+                            // this.ngLocation.prepareExternalUrl(url);
+
                     });
                 }
             },

commit 27d7149f63f10e7327bfa20b1f5777d3ab54b1ab
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 2 12:26:22 2019 -0500

    LP1848550 Cache org settings in IndexedDB (Angular)
    
    Teach the org settings lookup code to store cacheable settings (those
    for "here") in IndexedDB to reduce the number of repetitive network
    calls for org unit settings.
    
    Settings cached in IndexedDB are cleared upon successful login to avoid
    stale values.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>

diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
index 2a2a59dfd7..456f93f6f9 100644
--- a/Open-ILS/src/eg2/src/app/core/org.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/org.service.ts
@@ -1,9 +1,11 @@
 import {Injectable} from '@angular/core';
 import {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
 import {IdlObject, IdlService} from './idl.service';
 import {NetService} from './net.service';
 import {AuthService} from './auth.service';
 import {PcrudService} from './pcrud.service';
+import {DbStoreService} from './db-store.service';
 
 type OrgNodeOrId = number | IdlObject;
 
@@ -31,6 +33,7 @@ export class OrgService {
     private orgTypeList: IdlObject[] = [];
 
     constructor(
+        private db: DbStoreService,
         private net: NetService,
         private auth: AuthService,
         private pcrud: PcrudService
@@ -201,61 +204,105 @@ export class OrgService {
         });
     }
 
-    /**
-     * Populate 'target' with settings from cache where available.
-     * Return the list of settings /not/ pulled from cache.
-     */
-    private settingsFromCache(names: string[], target: any) {
-        const cacheKeys = Object.keys(this.settingsCache);
-
-        cacheKeys.forEach(key => {
-            const matchIdx = names.indexOf(key);
-            if (matchIdx > -1) {
-                target[key] = this.settingsCache[key];
-                names.splice(matchIdx, 1);
+    private appendSettingsFromCache(names: string[], batch: OrgSettingsBatch) {
+        names.forEach(name => {
+            if (name in this.settingsCache) {
+                batch[name] = this.settingsCache[name];
             }
         });
-
-        return names;
     }
 
-    /**
-     * Fetch org settings from the network.
-     * 'auth' is null for anonymous lookup.
-     */
-    private settingsFromNet(orgId: number,
-        names: string[], auth?: string): Promise<any> {
-
-        const settings = {};
-        return new Promise((resolve, reject) => {
-            this.net.request(
-                'open-ils.actor',
-                'open-ils.actor.ou_setting.ancestor_default.batch',
-                orgId, names, auth
-            ).subscribe(
-                blob => {
-                    Object.keys(blob).forEach(key => {
-                        const val = blob[key]; // null or hash
-                        settings[key] = val ? val.value : null;
-                    });
-                    resolve(settings);
-                },
-                err => reject(err)
-            );
+    // Pulls setting values from IndexedDB.
+    // Update local cache with any values found.
+    private appendSettingsFromDb(names: string[],
+        batch: OrgSettingsBatch): Promise<OrgSettingsBatch> {
+
+        if (names.length === 0) { return Promise.resolve(batch); }
+
+        return this.db.request({
+            schema: 'cache',
+            table: 'Setting',
+            action: 'selectWhereIn',
+            field: 'name',
+            value: names
+        }).then(settings => {
+
+            // array of key => JSON-string objects
+            settings.forEach(setting => {
+                const value = JSON.parse(setting.value);
+                // propagate to local cache as well
+                batch[setting.name] = this.settingsCache[setting.name] = value;
+            });
+
+            return batch;
         });
     }
 
+    // Add values for the list of named settings from the 'batch' to
+    // IndexedDB, copying the local cache as well.
+    private addSettingsToDb(names: string[],
+        batch: OrgSettingsBatch): Promise<OrgSettingsBatch> {
+
+        const rows = [];
+        names.forEach(name => {
+            // Anything added to the db should also be cached locally.
+            this.settingsCache[name] = batch[name];
+            rows.push({name: name, value: JSON.stringify(batch[name])});
+        });
+
+        if (rows.length === 0) { return Promise.resolve(batch); }
+
+        return this.db.request({
+            schema: 'cache',
+            table: 'Setting',
+            action: 'insertOrReplace',
+            rows: rows
+        }).then(_ => batch);
+    }
 
     /**
-     *
+     * Append the named settings from the network to the in-progress
+     * batch of settings.  'auth' is null for anonymous lookup.
      */
+    private appendSettingsFromNet(orgId: number, names: string[],
+        batch: OrgSettingsBatch, auth?: string): Promise<OrgSettingsBatch> {
+
+        if (names.length === 0) { return Promise.resolve(batch); }
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.ou_setting.ancestor_default.batch',
+            orgId, names, auth
+
+        ).pipe(tap(settings => {
+            Object.keys(settings).forEach(key => {
+                const val = settings[key]; // null or hash
+                batch[key] = val ? val.value : null;
+            });
+        })).toPromise().then(_ => batch);
+    }
+
+    // Given a set of setting names and an in-progress settings batch,
+    // return the list of names which are not yet represented in the ,
+    // indicating their data needs to be fetched from the next layer up
+    // (cache, network, etc.).
+    settingNamesRemaining(names: string[], settings: OrgSettingsBatch): string[] {
+        return names.filter(name => !(name in settings));
+    }
+
+    // Returns a key/value batch of org unit settings.
+    // Cacheable settings (orgId === here) are pulled from local cache,
+    // then IndexedDB, then the network.  Non-cacheable settings are
+    // fetched from the network each time.
     settings(name: string | string[],
         orgId?: number, anonymous?: boolean): Promise<OrgSettingsBatch> {
 
         let names = [].concat(name);
-        const settings = {};
         let auth: string = null;
         let useCache = false;
+        const batch: OrgSettingsBatch = {};
+
+        if (names.length === 0) { return Promise.resolve(batch); }
 
         if (this.auth.user()) {
             if (orgId) {
@@ -276,23 +323,29 @@ export class OrgService {
             return Promise.resolve({});
         }
 
-        if (useCache) {
-            names = this.settingsFromCache(names, settings);
+        if (!useCache) {
+            return this.appendSettingsFromNet(orgId, names, batch, auth);
         }
 
-        // All requested settings found in cache (or name list is empty)
-        if (names.length === 0) {
-            return Promise.resolve(settings);
-        }
+        this.appendSettingsFromCache(names, batch);
+        names = this.settingNamesRemaining(names, batch);
 
-        return this.settingsFromNet(orgId, names, auth)
-        .then(sets => {
-            if (useCache) {
-                Object.keys(sets).forEach(key => {
-                    this.settingsCache[key] = sets[key];
-                });
-            }
-            return sets;
+        return this.appendSettingsFromDb(names, batch)
+        .then(_ => {
+
+            names = this.settingNamesRemaining(names, batch);
+
+            return this.appendSettingsFromNet(orgId, names, batch, auth)
+            .then(__ => this.addSettingsToDb(names, batch));
+        });
+    }
+
+    // remove setting values cached in the indexeddb settings table.
+    clearCachedSettings(): Promise<any> {
+        return this.db.request({
+            schema: 'cache',
+            table: 'Setting',
+            action: 'deleteAll'
         });
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/staff/login.component.ts b/Open-ILS/src/eg2/src/app/staff/login.component.ts
index 2f2b9312e7..5a8d7c4ca6 100644
--- a/Open-ILS/src/eg2/src/app/staff/login.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/login.component.ts
@@ -3,6 +3,7 @@ import {Location} from '@angular/common';
 import {Router, ActivatedRoute} from '@angular/router';
 import {AuthService, AuthWsState} from '@eg/core/auth.service';
 import {StoreService} from '@eg/core/store.service';
+import {OrgService} from '@eg/core/org.service';
 
 @Component({
   templateUrl : './login.component.html'
@@ -26,6 +27,7 @@ export class StaffLoginComponent implements OnInit {
       private ngLocation: Location,
       private renderer: Renderer2,
       private auth: AuthService,
+      private org: OrgService,
       private store: StoreService
     ) {}
 
@@ -83,12 +85,18 @@ export class StaffLoginComponent implements OnInit {
                     this.auth.workstationState = AuthWsState.PENDING;
                     this.router.navigate(
                         [`/staff/admin/workstation/workstations/remove/${workstation}`]);
+
                 } else {
-                    // Force reload of the app after a successful login.
-                    // This allows the route resolver to re-run with a
-                    // valid auth token and workstation.
-                    window.location.href =
-                        this.ngLocation.prepareExternalUrl(url);
+
+                    // Initial login clears cached org unit settings.
+                    this.org.clearCachedSettings().then(_ => {
+
+                        // Force reload of the app after a successful login.
+                        // This allows the route resolver to re-run with a
+                        // valid auth token and workstation.
+                        window.location.href =
+                            this.ngLocation.prepareExternalUrl(url);
+                    });
                 }
             },
             notOk => {

commit de722bbabe441e6abd1957897541994120c1d0ad
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 2 12:26:08 2019 -0500

    LP1854850 Angular IndexedDB Shared Worker Communication
    
    Implements the client side of the communication to the Evergreen
    IndexedDB shared worker for Angular.
    
    From this may be built caching mechanisms, offline transaction
    handling, etc.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>

diff --git a/Open-ILS/src/eg2/src/app/core/db-store.service.ts b/Open-ILS/src/eg2/src/app/core/db-store.service.ts
new file mode 100644
index 0000000000..259e478f41
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/core/db-store.service.ts
@@ -0,0 +1,185 @@
+import {Injectable} from '@angular/core';
+
+/** Service to relay requests to/from our IndexedDB shared worker */
+
+// TODO: move to a more generic location.
+const WORKER_URL = '/js/ui/default/staff/offline-db-worker.js';
+
+// Tell TS about SharedWorkers
+// https://stackoverflow.com/questions/13296549/typescript-enhanced-sharedworker-portmessage-channel-contracts
+interface SharedWorker extends AbstractWorker {
+    port: MessagePort;
+}
+
+declare var SharedWorker: {
+    prototype: SharedWorker;
+    new (scriptUrl: any, name?: any): SharedWorker;
+};
+// ---
+
+// Requests in flight to the shared worker
+interface ActiveRequest {
+   id: number;
+   resolve(response: any): any;
+   reject(error: any): any;
+}
+
+// Shared worker request structure.  This is the request that's
+// relayed to the shared worker.
+// DbStoreRequest.id === ActiveRequest.id
+interface DbStoreRequest {
+    schema: string;
+    action: string;
+    field?: string;
+    value?: any;
+    table?: string;
+    rows?: any[];
+    id?: number;
+}
+
+// Expected response structure from the shared worker.
+// Note callers only recive the 'result' content, which may
+// be anything.
+interface DbStoreResponse {
+    status: string;
+    result: any;
+    error?: string;
+    id?: number;
+}
+
+ at Injectable({providedIn: 'root'})
+export class DbStoreService {
+
+    autoId = 0; // each request gets a unique id.
+    cannotConnect: boolean;
+
+    activeRequests: {[id: number]: ActiveRequest} = {};
+
+    // Schemas we should connect to
+    activeSchemas: string[] = ['cache']; // add 'offline' in the offline UI
+
+    // Schemas we are in the process of connecting to
+    schemasInProgress: {[schema: string]: Promise<any>} = {};
+
+    // Schemas we have successfully connected to
+    schemasConnected: {[schema: string]: boolean} = {};
+
+    worker: SharedWorker = null;
+
+    constructor() {}
+
+    private connectToWorker() {
+        if (this.worker || this.cannotConnect) { return; }
+
+        try {
+            this.worker = new SharedWorker(WORKER_URL);
+        } catch (E) {
+            console.warn('SharedWorker() not supported', E);
+            this.cannotConnect = true;
+            return;
+        }
+
+        this.worker.onerror = err => {
+            this.cannotConnect = true;
+            console.error('Cannot connect to DB shared worker', err);
+        };
+
+        // List for responses and resolve the matching pending request.
+        this.worker.port.addEventListener(
+            'message', evt => this.handleMessage(evt));
+
+        this.worker.port.start();
+    }
+
+    private handleMessage(evt: MessageEvent) {
+        const response: DbStoreResponse = evt.data as DbStoreResponse;
+        const reqId = response.id;
+        const req = this.activeRequests[reqId];
+
+        if (!req) {
+            console.error('Recieved response for unknown request', reqId);
+            return;
+        }
+
+        // Request is no longer active.
+        delete this.activeRequests[reqId];
+
+        if (response.status === 'OK') {
+            req.resolve(response.result);
+        } else {
+            console.error('worker request failed with', response.error);
+            req.reject(response.error);
+        }
+    }
+
+    // Send a request to the web worker and register the request
+    // for future resolution.  Store the request ID in the request
+    // arguments, so it's included in the response, and in the
+    // activeRequests list for linking.
+    private relayRequest(req: DbStoreRequest): Promise<any> {
+        return new Promise((resolve, reject) => {
+            const id = req.id = this.autoId++;
+            this.activeRequests[id] = {id: id, resolve: resolve, reject: reject};
+            this.worker.port.postMessage(req);
+        });
+    }
+
+    // Connect to all active schemas, requesting each be created
+    // when necessary.
+    private connectToSchemas(): Promise<any> {
+        const promises = [];
+
+        this.activeSchemas.forEach(schema =>
+            promises.push(this.connectToOneSchema(schema)));
+
+        return Promise.all(promises).then(
+            _ => {},
+            err => this.cannotConnect = true
+        );
+    }
+
+    private connectToOneSchema(schema: string): Promise<any> {
+
+        if (this.schemasConnected[schema]) {
+            return Promise.resolve();
+        }
+
+        if (this.schemasInProgress[schema]) {
+            return this.schemasInProgress[schema];
+        }
+
+        const promise = new Promise((resolve, reject) => {
+
+            this.relayRequest({schema: schema, action: 'createSchema'})
+
+            .then(_ =>
+                this.relayRequest({schema: schema, action: 'connect'}))
+
+            .then(
+                _ => {
+                    this.schemasConnected[schema] = true;
+                    delete this.schemasInProgress[schema];
+                    resolve();
+                },
+                err => reject(err)
+            );
+        });
+
+        return this.schemasInProgress[schema] = promise;
+    }
+
+    request(req: DbStoreRequest): Promise<any> {
+
+        // NO-OP if we're already connected.
+        this.connectToWorker();
+
+        // If we are unable to connect, it means we are in an
+        // environment that does not support shared workers.
+        // Treat all requests as a NO-OP.
+        if (this.cannotConnect) { return Promise.resolve(); }
+
+        return this.connectToSchemas().then(_ => this.relayRequest(req));
+    }
+}
+
+

commit 2ac1ca873857d6343791e409f2b510acd5dba18f
Author: Jeff Davis <jeff.davis at bc.libraries.coop>
Date:   Tue Nov 26 16:12:37 2019 -0800

    LP#1848550: client-side caching of org settings for AngularJS
    
    The web client almost always does live lookups any time it needs to
    check an org setting.  But these settings rarely change, so it would
    make sense to cache them.  They're already cached using Lovefield in
    order to support offline; this commit checks the cache first, and only
    does a live lookup if the setting is uncached.
    
    The settings cache is cleared on login to ensure stale values don't
    stick around forever.  To refresh the cache, simply logout and then
    login; cached values will be deleted and the latest values will be
    retrieved from the server when they are needed.
    
    Signed-off-by: Jeff Davis <jeff.davis at bc.libraries.coop>
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>

diff --git a/Open-ILS/web/js/ui/default/staff/services/auth.js b/Open-ILS/web/js/ui/default/staff/services/auth.js
index 03e5d43534..50818e5b34 100644
--- a/Open-ILS/web/js/ui/default/staff/services/auth.js
+++ b/Open-ILS/web/js/ui/default/staff/services/auth.js
@@ -6,8 +6,10 @@
 angular.module('egCoreMod')
 
 .factory('egAuth', 
-       ['$q','$timeout','$rootScope','$window','$location','egNet','egHatch',
-function($q , $timeout , $rootScope , $window , $location , egNet , egHatch) {
+       ['$q','$timeout','$rootScope','$window','$location','egNet','egHatch','$injector',
+function($q , $timeout , $rootScope , $window , $location , egNet , egHatch , $injector) {
+
+    var egLovefield = null;
 
     var service = {
         // the currently active user (au) object
@@ -288,9 +290,13 @@ function($q , $timeout , $rootScope , $window , $location , egNet , egHatch) {
     }
 
     service.handle_login_ok = function(args, evt) {
+        if (!egLovefield) {
+            egLovefield = $injector.get('egLovefield');
+        }
         service.ws = args.workstation; 
         egHatch.setLoginSessionItem('eg.auth.token', evt.payload.authtoken);
         egHatch.setLoginSessionItem('eg.auth.time', evt.payload.authtime);
+        egLovefield.destroySettingsCache(); // force refresh of settings cache on login (LP#1848550)
         service.poll();
     }
 
diff --git a/Open-ILS/web/js/ui/default/staff/services/lovefield.js b/Open-ILS/web/js/ui/default/staff/services/lovefield.js
index e7ec4960c8..683390aafd 100644
--- a/Open-ILS/web/js/ui/default/staff/services/lovefield.js
+++ b/Open-ILS/web/js/ui/default/staff/services/lovefield.js
@@ -335,6 +335,15 @@ angular.module('egCoreMod')
         );
     }
 
+    service.destroySettingsCache = function () {
+        if (lf.isOffline || service.cannotConnect) return $q.when();
+        return service.request({
+            schema: 'cache',
+            table: 'Setting',
+            action: 'deleteAll'
+        });
+    }
+
     service.setListInOfflineCache = function (type, list) {
         if (lf.isOffline || service.cannotConnect) return $q.when();
 
diff --git a/Open-ILS/web/js/ui/default/staff/services/org.js b/Open-ILS/web/js/ui/default/staff/services/org.js
index 36c9ed2e6b..99657d162d 100644
--- a/Open-ILS/web/js/ui/default/staff/services/org.js
+++ b/Open-ILS/web/js/ui/default/staff/services/org.js
@@ -111,6 +111,9 @@ function($q,  egEnv,  egAuth,  egNet , $injector) {
         if (!angular.isArray(names)) names = [names];
 
         if (lf.isOffline) {
+            // for offline, just use whatever we have managed to cache,
+            // even if the value is expired (since we can't refresh it
+            // from the server)
             return egLovefield.getSettingsCache(names).then(
                 function(settings) {
                     var hash = {};
@@ -126,26 +129,71 @@ function($q,  egEnv,  egAuth,  egNet , $injector) {
 
         if (!egAuth.user()) return $q.when();
 
-        var deferred = $q.defer();
         ou_id = ou_id || egAuth.user().ws_ou();
-        var here = (ou_id == egAuth.user().ws_ou());
+        if (ou_id != egAuth.user().ws_ou()) {
+            // we only cache settings for the current working location;
+            // if we have requested settings for some other org unit,
+            // skip the cache and pull settings directly from the server
+            return service.settingsFromServer(names, ou_id);
+        }
+
+        var deferred = $q.defer();
+        
+        var newNames = [];
+        angular.forEach(names, function(name) {
+            if (!angular.isDefined(service.cachedSettings[name]))
+                // we don't have a value for this setting yet 
+                newNames.push(name)
+        });
 
-       
-        if (here) { 
-            // only cache org settings retrieved for the current 
-            // workstation org unit.
-            var newNames = [];
+        // only retrieve uncached values
+        names = newNames;
+        if (names.length == 0)
+            return $q.when(service.cachedSettings);
+
+        // get settings from offline cache where possible;
+        // otherwise, get settings from server
+        egLovefield.getSettingsCache(names)
+        .then(function(settings) {
+
+            // populate values from offline cache
+            angular.forEach(settings, function (s) {
+                service.cachedSettings[s.name] = s.value;
+            });
+
+            // check if any requested settings were not in offline cache
+            var uncached = [];
             angular.forEach(names, function(name) {
                 if (!angular.isDefined(service.cachedSettings[name]))
-                    newNames.push(name)
+                    uncached.push(name);
             });
 
-            // only retrieve uncached values
-            names = newNames;
-            if (names.length == 0)
-                return $q.when(service.cachedSettings);
+            if (uncached.length == 0) {
+                // all requested settings were in the offline cache already
+                deferred.resolve(service.cachedSettings);
+            } else {
+                // cache was missing some settings; grab those from the server
+                service.settingsFromServer(uncached, ou_id)
+                .then(function() {
+                    deferred.resolve(service.cachedSettings);
+                });
+            }
+        });
+        return deferred.promise;
+    }
+
+    service.settingsFromServer = function(names, ou_id) {
+        if (!egLovefield) {
+            egLovefield = $injector.get('egLovefield');
         }
 
+        // allow non-array
+        if (!angular.isArray(names)) names = [names];
+
+        var deferred = $q.defer();
+        ou_id = ou_id || egAuth.user().ws_ou();
+        var here = (ou_id == egAuth.user().ws_ou());
+
         egNet.request(
             'open-ils.actor',
             'open-ils.actor.ou_setting.ancestor_default.batch',

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

Summary of changes:
 Open-ILS/src/eg2/src/app/core/db-store.service.ts  | 185 +++++++++++++++++++++
 Open-ILS/src/eg2/src/app/core/org.service.ts       | 159 ++++++++++++------
 Open-ILS/src/eg2/src/app/staff/login.component.ts  |  22 ++-
 Open-ILS/web/js/ui/default/staff/services/auth.js  |  10 +-
 .../web/js/ui/default/staff/services/lovefield.js  |   9 +
 Open-ILS/web/js/ui/default/staff/services/org.js   |  72 ++++++--
 6 files changed, 385 insertions(+), 72 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/core/db-store.service.ts


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list