LP#1640255 Hatch native messaging extension
[Evergreen.git] / Open-ILS / web / js / ui / default / staff / services / hatch.js
1 /**
2  * Core Service - egHatch
3  *
4  * Dispatches print and data storage requests to the appropriate handler.
5  *
6  * With each top-level request, if a connection to Hatch is established,
7  * the request is relayed.  If a connection has not been attempted, an
8  * attempt is made then the request is handled.  If Hatch is known to be
9  * inaccessible, requests are routed to local handlers.
10  *
11  * Most handlers also provide direct remote and local variants to the
12  * application can decide to which to use as needed.
13  *
14  * Local storage requests are handled by $window.localStorage.
15  *
16  * Note that all top-level and remote requests return promises.  All
17  * local requests return immediate values, since local requests are
18  * never asynchronous.
19  *
20  * BEWARE: never store "fieldmapper" objects, since their structure
21  * may change over time as the IDL changes.  Always flatten objects
22  * into key/value pairs before calling set*Item()
23  *
24  */
25 angular.module('egCoreMod')
26
27 .factory('egHatch',
28            ['$q','$window','$timeout','$interpolate','$http','$cookies',
29     function($q , $window , $timeout , $interpolate , $http , $cookies) {
30
31     var service = {};
32     service.msgId = 1;
33     service.messages = {};
34     service.pending = [];
35     service.hatchAvailable = null;
36     service.state = 'IDLE'; // IDLE, INIT, CONNECTED, NO_CONNECTION
37
38     // write a message to the Hatch port
39     service.sendToHatch = function(msg) {
40         var msg2 = {};
41
42         // shallow copy and scrub msg before sending
43         angular.forEach(msg, function(val, key) {
44             if (key.match(/deferred/)) return;
45             msg2[key] = val;
46         });
47
48         console.debug("sending to Hatch: " + JSON.stringify(msg2,null,2));
49
50         msg2.from = 'page';
51         $window.postMessage(msg2, $window.location.origin);
52     }
53
54     // Send the request to Hatch if it's available.  
55     // Otherwise handle the request locally.
56     service.attemptHatchDelivery = function(msg) {
57
58         msg.msgid = service.msgId++;
59         msg.deferred = $q.defer();
60
61         if (service.state == 'NO_CONNECTION') {
62             msg.deferred.reject(msg);
63
64         } else if (service.state.match(/CONNECTED|INIT/)) {
65             // Hatch is known to be open
66             service.messages[msg.msgid] = msg;
67             service.sendToHatch(msg);
68
69         } else if (service.state == 'IDLE') { 
70             service.messages[msg.msgid] = msg;
71             service.pending.push(msg);
72             $timeout(service.openHatch);
73         }
74
75         return msg.deferred.promise;
76     }
77
78
79     // resolve the promise on the given request and remove
80     // it from our tracked requests.
81     service.resolveRequest = function(msg) {
82
83         if (!service.messages[msg.msgid]) {
84             console.warn('no cached message for ' 
85                 + msg.msgid + ' : ' + JSON.stringify(msg, null, 2));
86             return;
87         }
88
89         // for requests sent through Hatch, only the cached 
90         // request will have the original promise attached
91         msg.deferred = service.messages[msg.msgid].deferred;
92         delete service.messages[msg.msgid]; // un-cache
93
94         switch (service.state) {
95
96             case 'CONNECTED': // received a standard Hatch response
97                 if (msg.status == 200) {
98                     msg.deferred.resolve(msg.content);
99                 } else {
100                     msg.deferred.reject();
101                     console.warn("Hatch command failed with status=" 
102                         + msg.status + " and message=" + msg.message);
103                 }
104                 break;
105
106             case 'INIT':
107                 if (msg.status == 200) {
108                     service.hatchAvailable = true; // public flag
109                     service.state = 'CONNECTED';
110                     service.hatchOpened();
111                 } else {
112                     msg.deferred.reject();
113                     service.hatchWontOpen(msg.message);
114                 }
115                 break;
116
117             default:
118                 console.warn(
119                     "Received message in unexpected state: " + service.state); 
120         }
121     }
122
123     service.openHatch = function() {
124
125         // When the Hatch extension loads, it tacks an attribute onto
126         // the page body to indicate it's available.
127
128         if (!$window.document.body.getAttribute('hatch-is-open')) {
129             service.hatchWontOpen('Hatch is not available');
130             return;
131         }
132
133         $window.addEventListener("message", function(event) {
134             // We only accept messages from our own content script.
135             if (event.source != window) return;
136
137             // We only care about messages from the Hatch extension.
138             if (event.data && event.data.from == 'extension') {
139
140                 console.debug('Hatch says: ' 
141                     + JSON.stringify(event.data, null, 2));
142
143                 service.resolveRequest(event.data);
144             }
145         }); 
146
147         service.state = 'INIT';
148         service.attemptHatchDelivery({action : 'init'});
149     }
150
151     service.hatchWontOpen = function(err) {
152         console.debug("Hatch connection failed: " + err);
153         service.state = 'NO_CONNECTION';
154         service.hatchAvailable = false;
155         service.hatchClosed();
156     }
157
158     service.hatchClosed = function() {
159         service.printers = [];
160         service.printConfig = {};
161         while ( (msg = service.pending.shift()) ) {
162             msg.deferred.reject(msg);
163             delete service.messages[msg.msgid];
164         }
165         if (service.onHatchClose)
166             service.onHatchClose();
167     }
168
169     // Returns true if Hatch is required or if we are currently
170     // communicating with the Hatch service. 
171     service.usingHatch = function() {
172         return service.state == 'CONNECTED' || service.hatchRequired();
173     }
174
175     // Returns true if this browser (via localStorage) is 
176     // configured to require Hatch.
177     service.hatchRequired = function() {
178         return service.getLocalItem('eg.hatch.required');
179     }
180
181     service.hatchOpened = function() {
182         // let others know we're connected
183         if (service.onHatchOpen) service.onHatchOpen();
184
185         // Deliver any previously queued requests 
186         while ( (msg = service.pending.shift()) ) {
187             service.sendToHatch(msg);
188         };
189     }
190
191     service.getPrintConfig = function() {
192         if (service.printConfig) 
193             return $q.when(service.printConfig);
194
195         return service.getRemoteItem('eg.print.config')
196         .then(function(conf) { 
197             return (service.printConfig = conf || {}) 
198         });
199     }
200
201     service.setPrintConfig = function(conf) {
202         service.printConfig = conf;
203         return service.setRemoteItem('eg.print.config', conf);
204     }
205
206
207     service.remotePrint = function(
208         context, contentType, content, withDialog) {
209
210         return service.getPrintConfig().then(
211             function(conf) {
212                 // print configuration retrieved; print
213                 return service.attemptHatchDelivery({
214                     action : 'print',
215                     config : conf[context],
216                     content : content, 
217                     contentType : contentType,
218                     showDialog : withDialog,
219                 });
220             }
221         );
222     }
223
224     // launch the print dialog then attach the resulting configuration
225     // to the requested context, then store the final values.
226     service.configurePrinter = function(context, printer) {
227
228         // load current settings
229         return service.getPrintConfig()
230
231         // dispatch the print configuration request
232         .then(function(config) {
233
234             // loaded remote config
235             if (!config[context]) config[context] = {};
236             config[context].printer = printer;
237             return service.attemptHatchDelivery({
238                 key : 'no-op', 
239                 action : 'print-config',
240                 config : config[context]
241             })
242         })
243
244         // set the returned settings to the requested context
245         .then(function(newconf) {
246             if (angular.isObject(newconf)) {
247                 newconf.printer = printer;
248                 return service.printConfig[context] = newconf;
249             } else {
250                 console.warn("configurePrinter() returned " + newconf);
251             }
252         })
253
254         // store the newly linked settings
255         .then(function() {
256             service.setItem('eg.print.config', service.printConfig);
257         })
258
259         // return the final settings to the caller
260         .then(function() {return service.printConfig});
261     }
262
263     service.getPrinters = function() {
264         if (service.printers) // cached printers
265             return $q.when(service.printers);
266
267         return service.attemptHatchDelivery({action : 'printers'}).then(
268
269             // we have remote printers; sort by name and return
270             function(printers) {
271                 service.printers = printers.sort(
272                     function(a,b) {return a.name < b.name ? -1 : 1});
273                 return service.printers;
274             },
275
276             // remote call failed and there is no such thing as local
277             // printers; return empty set.
278             function() { return [] } 
279         );
280     }
281
282     // get the value for a stored item
283     service.getItem = function(key) {
284         return service.getRemoteItem(key)['catch'](
285             function(msg) {
286                 if (service.hatchRequired()) {
287                     console.error("Unable to getItem: " + key
288                      + "; hatchRequired=true, but hatch is not connected");
289                      return null;
290                 }
291                 return service.getLocalItem(msg.key);
292             }
293         );
294     }
295
296     service.getRemoteItem = function(key) {
297         return service.attemptHatchDelivery({
298             key : key,
299             action : 'get'
300         })
301     }
302
303     service.getLocalItem = function(key) {
304         var val = $window.localStorage.getItem(key);
305         if (val == null) return;
306         return JSON.parse(val);
307     }
308
309     service.getLoginSessionItem = function(key) {
310         var val = $cookies.get(key);
311         if (val == null) return;
312         return JSON.parse(val);
313     }
314
315     service.getSessionItem = function(key) {
316         var val = $window.sessionStorage.getItem(key);
317         if (val == null) return;
318         return JSON.parse(val);
319     }
320
321     /**
322      * @param tmp bool Store the value as a session cookie only.  
323      * tmp values are removed during logout or browser close.
324      */
325     service.setItem = function(key, value) {
326         return service.setRemoteItem(key, value)['catch'](
327             function(msg) {
328                 if (service.hatchRequired()) {
329                     console.error("Unable to setItem: " + key
330                      + "; hatchRequired=true, but hatch is not connected");
331                      return null;
332                 }
333                 return service.setLocalItem(msg.key, value);
334             }
335         );
336     }
337
338     // set the value for a stored or new item
339     service.setRemoteItem = function(key, value) {
340         return service.attemptHatchDelivery({
341             key : key, 
342             content : value, 
343             action : 'set',
344         });
345     }
346
347     // Set the value for the given key.
348     // "Local" items persist indefinitely.
349     // If the value is raw, pass it as 'value'.  If it was
350     // externally JSONified, pass it via jsonified.
351     service.setLocalItem = function(key, value, jsonified) {
352         if (jsonified === undefined ) 
353             jsonified = JSON.stringify(value);
354         $window.localStorage.setItem(key, jsonified);
355     }
356
357     // Set the value for the given key.  
358     // "LoginSession" items are removed when the user logs out or the 
359     // browser is closed.
360     // If the value is raw, pass it as 'value'.  If it was
361     // externally JSONified, pass it via jsonified.
362     service.setLoginSessionItem = function(key, value, jsonified) {
363         service.addLoginSessionKey(key);
364         if (jsonified === undefined ) 
365             jsonified = JSON.stringify(value);
366         $cookies.put(key, jsonified);
367     }
368
369     // Set the value for the given key.  
370     // "Session" items are browser tab-specific and are removed when the
371     // tab is closed.
372     // If the value is raw, pass it as 'value'.  If it was
373     // externally JSONified, pass it via jsonified.
374     service.setSessionItem = function(key, value, jsonified) {
375         if (jsonified === undefined ) 
376             jsonified = JSON.stringify(value);
377         $window.sessionStorage.setItem(key, jsonified);
378     }
379
380     // assumes the appender and appendee are both strings
381     // TODO: support arrays as well
382     service.appendLocalItem = function(key, value) {
383         var item = service.getLocalItem(key);
384         if (item) {
385             if (typeof item != 'string') {
386                 logger.warn("egHatch.appendLocalItem => "
387                     + "cannot append to a non-string item: " + key);
388                 return;
389             }
390             value = item + value; // concatenate our value
391         }
392         service.setLocalitem(key, value);
393     }
394
395     // remove a stored item
396     service.removeItem = function(key) {
397         return service.removeRemoteItem(key)['catch'](
398             function(msg) { 
399                 return service.removeLocalItem(msg.key) 
400             }
401         );
402     }
403
404     service.removeRemoteItem = function(key) {
405         return service.attemptHatchDelivery({
406             key : key,
407             action : 'remove'
408         });
409     }
410
411     service.removeLocalItem = function(key) {
412         $window.localStorage.removeItem(key);
413     }
414
415     service.removeLoginSessionItem = function(key) {
416         service.removeLoginSessionKey(key);
417         $cookies.remove(key);
418     }
419
420     service.removeSessionItem = function(key) {
421         $window.sessionStorage.removeItem(key);
422     }
423
424     /**
425      * Remove all "LoginSession" items.
426      */
427     service.clearLoginSessionItems = function() {
428         angular.forEach(service.getLoginSessionKeys(), function(key) {
429             service.removeLoginSessionItem(key);
430         });
431
432         // remove the keys cache.
433         service.removeLocalItem('eg.hatch.login_keys');
434     }
435
436     // if set, prefix limits the return set to keys starting with 'prefix'
437     service.getKeys = function(prefix) {
438         return service.getRemoteKeys(prefix)['catch'](
439             function() { 
440                 if (service.hatchRequired()) {
441                     console.error("Unable to get pref keys; "
442                      + "hatchRequired=true, but hatch is not connected");
443                      return [];
444                 }
445                 return service.getLocalKeys(prefix) 
446             }
447         );
448     }
449
450     service.getRemoteKeys = function(prefix) {
451         return service.attemptHatchDelivery({
452             key : prefix,
453             action : 'keys'
454         });
455     }
456
457     service.getLocalKeys = function(prefix) {
458         var keys = [];
459         var idx = 0;
460         while ( (k = $window.localStorage.key(idx++)) !== null) {
461             // key prefix match test
462             if (prefix && k.substr(0, prefix.length) != prefix) continue; 
463             keys.push(k);
464         }
465         return keys;
466     }
467
468
469     /**
470      * Array of "LoginSession" keys.
471      * Note we have to store these as "Local" items so browser tabs can
472      * share them.  We could store them as cookies, but it's more data
473      * that has to go back/forth to the server.  A "LoginSession" key name is
474      * not private, though, so it's OK if they are left in localStorage
475      * until the next login.
476      */
477     service.getLoginSessionKeys = function(prefix) {
478         var keys = [];
479         var idx = 0;
480         var login_keys = service.getLocalItem('eg.hatch.login_keys') || [];
481         angular.forEach(login_keys, function(k) {
482             // key prefix match test
483             if (prefix && k.substr(0, prefix.length) != prefix) return;
484             keys.push(k);
485         });
486         return keys;
487     }
488
489     service.addLoginSessionKey = function(key) {
490         var keys = service.getLoginSessionKeys();
491         if (keys.indexOf(key) < 0) {
492             keys.push(key);
493             service.setLocalItem('eg.hatch.login_keys', keys);
494         }
495     }
496
497     service.removeLoginSessionKey = function(key) {
498         var keys = service.getLoginSessionKeys().filter(function(k) {
499             return k != key;
500         });
501         service.setLocalItem('eg.hatch.login_keys', keys);
502     }
503
504     return service;
505 }])
506