56cb0674c8dfe928a89565f76669e02ae3d13596
[sitka/overdrive-evergreen-opac.git] / src / od_api.coffee
1 define [
2         'jquery'
3         'lodash'
4         'json'
5         'cookies'
6         'moment'
7         'od_config'
8         'od_session'
9 ], ($, _, json, C, M, config, Session) ->
10
11         # Dump the given arguments or log them to console
12         log = ->
13                 try
14                         dump "#{x}\n" for x in arguments
15                         return
16                 catch
17                         console.log arguments
18                         return
19         
20         $notify = $ {}
21
22         logError = (jqXHR, textStatus, errorThrown) ->
23                 log "#{textStatus} #{jqXHR.status} #{errorThrown}"
24                 $notify.trigger 'od.fail', arguments
25
26         # Define custom event names for this module.  A custom event is triggered
27         # whenever result data becomes available after making an API request.
28         eventList = [
29                 'od.clientaccess'
30                 'od.libraryaccount'
31                 'od.metadata'
32                 'od.availability'
33
34                 'od.patronaccess'
35                 'od.patroninfo'
36                 'od.holds'
37                 'od.checkouts'
38                 'od.interests'
39                 'od.action'
40
41                 'od.hold.update'
42                 'od.hold.delete'
43                 'od.checkout.update'
44                 'od.checkout.delete'
45
46                 'od.prefs'
47                 'od.login'
48                 'od.logout'
49                 'od.error'
50         ]
51         eventObject = $({}).on eventList.join(' '), (e, x, y...) -> log e.namespace, x, y
52
53         # On page load, we unserialize the text string found in local storage into
54         # an object, or if there is no string yet, we create the default object.
55         # The session object uses a local storage mechanism based on window.name;
56         # see
57         # http://stackoverflow.com/questions/2035075/using-window-name-as-a-local-data-cache-in-web-browsers
58         # for pros and cons and alternatives.
59         session = new Session window.name, Boolean C('eg_loggedin') or window.IAMXUL
60
61         # On window unload, we serialize it into local storage so that it survives
62         # page reloads.
63         $(window).on 'unload', -> window.name = session.store()
64
65         # Customize the plain jQuery ajax to post a request for an access token
66         _api = (url, data) ->
67
68                 $.ajax $.extend {},
69                         # The Basic Authorization string is always added to the HTTP header.
70                         headers: Authorization: "Basic #{config.credentials}"
71                         # The URL endpoint is converted to its reverse proxy version,
72                         # because we are using the Evergreen server as a reverse proxy to
73                         # the Overdrive server.
74                         url: proxy url
75                         type: 'POST'
76                         # We expect data to be always given; the ajax method will convert
77                         # it to a query string.
78                         data: data
79
80         # Replace the host domain of a given URL with a proxy domain.  If the input
81         # URL specifies a protocol, it is stripped out so that the output will
82         # default to the client's protocol.
83         proxy = (x) ->
84                 return unless x
85                 y = x
86                 y = y.replace 'https://', '//'
87                 y = y.replace 'http://' , '//'
88                 y = y.replace '//oauth-patron.overdrive.com', '/od/oauth-patron'
89                 y = y.replace        '//oauth.overdrive.com', '/od/oauth'
90                 y = y.replace   '//patron.api.overdrive.com', '/od/api-patron'
91                 y = y.replace          '//api.overdrive.com', '/od/api'
92                 y = y.replace  '//images.contentreserve.com', '/od/images'
93                 y = y.replace '//fulfill.contentreserve.com', '/od/fulfill'
94                 #log "proxy #{x} -> #{y}"
95                 y
96
97         # Convert a serialized array into a serialized object
98         serializeObject = (a) ->
99                 o = {}
100                 $.each a, ->
101                         v = @value or ''
102                         if (n = o[@name]) isnt undefined
103                                 o[@name] = [n] unless n.push
104                                 o[@name].push v
105                         else
106                                 o[@name] = v
107                 return o
108
109         # TODO unused
110         $.fn.extend
111
112                 # Convert this serialized array to a serialized object
113                 _serializeObject: -> serializeObject @serializeArray()
114
115                 # Serialize this to a json string, an object, an array, a query string, or just return itself
116                 _serializeX: (X) ->
117                         switch X
118                                 when 'j' then json.stringify @_serializeX 'o'
119                                 when 'k' then json.stringify @_serializeX 'a'
120                                 when 'p' then $.param @_serializeX 'a'
121                                 when 's' then @serialize()
122                                 when 'o' then serializeObject @_serializeX 'a'
123                                 when 'a' then @serializeArray()
124                                 else @
125
126         # Mutate an ISO 8601 date string into a Moment object.  If the argument is
127         # just a date value, then it specifies an absolute date in ISO 8601 format.
128         # If the argument is a pair, then it specifies a date relative to now.  For
129         # an ISO 8601 date, we correct for what seems to be an error in time zone,
130         # Zulu time is really East Coast time.
131         momentize = (date, unit) ->
132                 switch arguments.length
133                         when 1
134                                 if date then M(date.replace /Z$/, '-0400') else M()
135                         when 2
136                                 if date then M().add date, unit else M()
137                         else M()
138
139         # We define the public interface of the module
140         # TODO wrap od in jquery so that we can use it to trigger events and bind event handlers
141         od =
142
143                 # Povides the anchor object for implementing a publish/subscribe
144                 # mechanism for this module.
145                 $: eventObject.on
146
147                         # Notification that there are possible changes of values from
148                         # preferences page that should be updated in the session cache
149                         'od.prefs': (ev, x) -> session.prefs.update x
150
151                         # Expire patron access token if user is no longer logged into EG
152                         'od.logout': (ev, x) ->
153                                 if x is 'eg'
154                                         session = new Session() if session.token.is_patron_access()
155
156                 log: log
157
158                 proxy: proxy
159
160                 # Map format id to format name using current session object
161                 labels: (id) -> session.labels[id] or id
162
163                 # Customize the plain jQuery ajax method to handle a GET or POST method
164                 # for the Overdrive api.
165                 api: (url, method, data) ->
166
167                         #  Do some pre-processing of data before it is sent to server
168                         if method is 'post'
169
170                                 # Convert numberOfDays value from an ISO 8601 date string to
171                                 # number of days relative to now.  There are two subtleties
172                                 # regarding rounding errors: First, we use only use now of
173                                 # resolution to days to avoid a local round-down from 1 to 0.
174                                 # Second, we need to add one to avoid a round-down at the OD
175                                 # server.
176                                 for v in data.fields when v.name is 'numberOfDays'
177                                         v.value = 1 + M(v.value).diff M().toArray()[0..2], 'days'
178
179                         $.ajax $.extend {},
180                                 # The current Authorization string is always added to the HTTP header.
181                                 headers: Authorization: "#{session.token.token_type} #{session.token.access_token}"
182                                 # The URL endpoint is converted to its reverse proxy version, because
183                                 # we are using the Evergreen server as a reverse proxy to the Overdrive
184                                 # server.
185                                 url: proxy url
186                                 # Will default to 'get' if no method string is supplied
187                                 type: method
188                                 # A given data object is expected to be in JSON format
189                                 contentType: 'application/json; charset=utf-8'
190                                 data: json.stringify data
191
192                         .done ->
193
194                                 # For a post method, we get a data object in reply.  We publish
195                                 # the object using an event named after the data type, eg,
196                                 # 'hold', 'checkout'.  We can't easily recognize the data type
197                                 # by looking at the data, so we have to pattern match on the
198                                 # API URL.
199                                 if method is 'post'
200                                         if /\/holds|\/suspension/.test url
201                                                 x = arguments[0]
202                                                 x.holdPlacedDate = momentize x.holdPlacedDate
203                                                 x.holdExpires = momentize x.holdExpires
204                                                 if x.holdSuspension
205                                                         x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
206                                                 od.$.triggerHandler 'od.hold.update', x
207                                         if /\/checkouts/.test url
208                                                 x = arguments[0]
209                                                 x.expires = momentize x.expires
210                                                 od.$.triggerHandler 'od.checkout.update', x
211
212                                 # For a delete method, we do not get a data object in reply,
213                                 # thus we pattern match for the specific ID and trigger an
214                                 # event with the ID.
215                                 if method is 'delete'
216                                         if id = url.match /\/holds\/(.+)\/suspension$/
217                                                 return # no relevant event
218                                         if id = url.match /\/holds\/(.+)$/
219                                                 od.$.triggerHandler 'od.hold.delete', reserveId: id[1]
220                                         if id = url.match /\/checkouts\/(.+)$/
221                                                 od.$.triggerHandler 'od.checkout.delete', reserveId: id[2]
222
223                         .fail ->
224                                 od.$.triggerHandler 'od.error', [url, arguments[0]]
225                                 #$('<div>')._notify arguments[1].statusText, arguments[0].responseText
226
227                 # Get a library access token so that we can use the Discovery API
228                 apiDiscAccess: ->
229
230                         ok = (x) ->
231                                 # Cache the server's response object and publish it to other
232                                 # modules
233                                 session.token.update x
234                                 od.$.triggerHandler 'od.clientaccess', x
235
236                         _api session.links.token.href, grant_type: 'client_credentials'
237
238                         .then ok, logError
239
240                 # Use the Library Account API to get library account information,
241                 # primarily the product link and the available formats.  Since we
242                 # schedule this call on every page load, it will also tell us if
243                 # our access token has expired or not.
244                 #
245                 # If a retry is needed, we have to decide whether to get a library
246                 # access token or a patron access token.  However, getting the latter
247                 # will, in the general case, require user credentials, which means we
248                 # need to store the password in the browser across sessions.  An
249                 # alternative is to force a logout, so that the user needs to manually
250                 # relogin. In effect, we would only proceed with a retry to get a
251                 # library access token, but if the user has logged in, we would not.
252                 #
253                 apiAccount: ->
254
255                         get = -> od.api session.links.libraries.href
256
257                         ok = (x) ->
258                                 session.links.update x
259                                 session.labels.update x
260                                 od.$.triggerHandler 'od.libraryaccount', x
261                                 return
262
263                         retry = (jqXHR) ->
264
265                                 # Retry if we got a 401 error code
266                                 if jqXHR.status is 401
267
268                                         if session.token.is_patron_access()
269                                                 # Current OD patron access token may have expired
270                                                 od.$.triggerHandler 'od.logout', 'od'
271
272                                         else
273                                                 # Renew our access token and retry the get operation
274                                                 od.apiDiscAccess()
275                                                 .then get, logError
276                                                 .then ok
277
278                         get().then ok, retry
279
280                 # We define a two-phase sequence to get a patron access token, for example,
281                 # login(credentials); do_something_involving_page_reload(); login();
282                 # where credentials is an object containing username and password
283                 # properties from the login form.
284                 #
285                 # Logging into Evergreen can proceed using either barcode or user name,
286                 # but logging into Overdrive is only a natural act using barcode. In
287                 # order to ensure that logging into OD with a username can proceed, we
288                 # presume that EG has been logged into and, as a prelude, we get the
289                 # Preferences page of the user so that we can scrape out the barcode
290                 # value for logging into OD.
291                 #
292                 # The login sequence is associated with a cache that remembers the
293                 # login response ('parameters') between login sessions. The epilogue to
294                 # the login sequence is to use the Patron Information API to get URL
295                 # links and templates that will allow the user to make further use of
296                 # the Circulation API.
297                 login: (credentials) ->
298
299                         # Temporarily store the username and password from the login form
300                         # into the session cache, and invalidate the session token so that
301                         # the final part of login sequence can complete.
302                         if credentials
303                                 session.creds.update credentials
304                                 session.token.update()
305                                 od.$.triggerHandler 'od.login'
306                                 return
307
308                         # Return a promise to a resolved deferredment if session token is still valid
309                         # TODO is true if in staff client but shouldn't be
310                         if session.token.is_patron_access()
311                                 return $.Deferred().resolve().promise()
312
313                         # Request OD service for a patron access token using credentials
314                         # pulled from the patron's preferences page
315                         login = (prefs) ->
316
317                                 # Define a function to cut the value corresponding to a label
318                                 # from prefs
319                                 x = (label) ->
320                                         r = new RegExp "#{label}<\\/td>\\s+<td.+>(.*?)<\\/td>", 'i'
321                                         prefs.match(r)?[1] or ''
322
323                                 # Retrieve values from preferences page and save them in the
324                                 # session cache for later reference
325                                 session.prefs.update
326                                         barcode:       x 'barcode'
327                                         email_address: x 'email address'
328                                         home_library:  x 'home library'
329
330                                 # Use barcode as username or the username that was stored in
331                                 # session cache (in the hope that is a barcode) or give up with
332                                 # a null string
333                                 un = session.prefs.barcode or session.creds.un()
334
335                                 # Use the password that was stored in session cache or a dummy value
336                                 pw = session.creds.pw config.password_required
337
338                                 # Remove the stored credentials from cache as soon as they are
339                                 # no longer needed
340                                 session.creds.update()
341
342                                 # Determine the Open Auth scope by mapping the long name of EG
343                                 # home library to OD authorization name
344                                 scope = "websiteid:#{config.websiteID} authorizationname:#{config.authorizationname session.prefs.home_library}"
345
346                                 # Try to get a patron access token from OD server
347                                 _api session.links.patrontoken.href,
348                                         grant_type: 'password'
349                                         username: un
350                                         password: pw
351                                         password_required: config.password_required
352                                         scope: scope
353
354                         # Complete login sequence if the session cache is invalid
355                         ok = (x) ->
356                                 session.token.update x
357                                 od.$.triggerHandler 'od.patronaccess', x
358
359                         # Get patron preferences page
360                         $.get '/eg/opac/myopac/prefs'
361                         # Get the access token using credentials from preferences
362                         .then login
363                         # Update the session cache with access token
364                         .then ok
365                         # Update the session cache with session links
366                         .then od.apiPatronInfo
367                         .fail log
368
369                 # TODO not used; EG catalogue is used instead
370                 apiSearch: (x) ->
371                         return unless x
372                         od.api session.links.products.href, get, x
373
374                 # TODO we can probably get away with using one normalization routine
375                 # instead of using one for each type of data object, because they don't
376                 # share property names.
377
378                 apiMetadata: (x) ->
379                         return unless x.id
380                         od.api "#{session.links.products.href}/#{x.id}/metadata"
381
382                         .then (y) ->
383                                 # Convert ID to upper case to match same case found in EG catalogue
384                                 y.id = y.id.toUpperCase()
385                                 # Provide a simplified notion of author: first name in creators
386                                 # list having a role of author
387                                 y.author = (v.name for v in y.creators when v.role is 'Author')[0] or ''
388                                 # Publish the metadata object
389                                 od.$.triggerHandler 'od.metadata', y
390                                 y
391
392                         .fail -> od.$.triggerHandler 'od.metadata', x
393
394                 apiAvailability: (x) ->
395                         return unless x.id
396
397                         url =
398                                 if (alink = session.links.availability?.href)
399                                         alink.replace '{crId}', x.id # Use this link if logged in
400                                 else
401                                         "#{session.links.products.href}/#{x.id}/availability"
402
403                         od.api url
404
405                         # Post-process the result, eg, fill in empty properties
406                         .then (y) ->
407                                 # Normalize the result by adding zero values
408                                 y.copiesOwned     = 0 unless y.copiesOwned
409                                 y.copiesAvailable = 0 unless y.copiesAvailable
410                                 y.numberOfHolds   = 0 unless y.numberOfHolds
411
412                                 if y.actions?.hold
413                                         # The reserve ID is empty in the actions.hold.fields; we have to fill it ourselves.
414                                         _.where(y.actions.hold.fields, name: 'reserveId')[0].value = y.id
415                                         # We jam the email address from the prefs page into the fields object from the server
416                                         # so that the new form will display it.
417                                         if email_address = session.prefs.email_address
418                                                 _.where(y.actions.hold.fields, name: 'emailAddress')[0].value = email_address
419
420                                 od.$.triggerHandler 'od.availability', y
421                                 arguments
422
423                         .fail -> od.$.triggerHandler 'od.availability', x
424
425                 apiPatronInfo: ->
426                         ok = (x) ->
427                                 session.links.update x
428                                 od.$.triggerHandler 'od.patroninfo', x
429                                 return
430
431                         od.api session.links.patrons.href
432                         .then ok, logError
433
434                 apiHoldsGet: (x) ->
435                         return unless session.token.is_patron_access()
436
437                         od.api "#{session.links.holds.href}#{if x?.productID then x.productID else ''}"
438
439                         # Post-process the result, eg, fill in empty properties, sort list,
440                         # remove redundant actions or add missing actions
441                         .then (y) ->
442
443                                 # Normalize the result by adding an empty holds list
444                                 xs = y.holds or []
445
446                                 # For each hold, convert any ISO 8601 date strings into a
447                                 # Moment object (at the local time zone)
448                                 for x in xs
449                                         x.holdPlacedDate = momentize x.holdPlacedDate
450                                         x.holdExpires = momentize x.holdExpires
451                                         if x.holdSuspension
452                                                 x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
453
454                                 # Count the number of holds that can be checked out now
455                                 y.ready = _.countBy xs, (x) -> if x.actions.checkout then 'forCheckout' else 'other'
456                                 y.ready.forCheckout = 0 unless y.ready.forCheckout
457
458                                 # Delete action to release a suspension if a hold is not
459                                 # suspended, because such actions are redundant
460                                 delete x.actions.releaseSuspension for x in xs when not x.holdSuspension
461
462                                 # Sort the holds list by position and placed date
463                                 # and sort ready holds first
464                                 #y.holds = _.sortBy xs, ['holdListPosition', 'holdPlacedDate']
465                                 y.holds = _(xs)
466                                         .sortBy ['holdListPosition', 'holdPlacedDate']
467                                         .sortBy (x) -> x.actions.checkout
468                                         .value()
469
470                                 od.$.triggerHandler 'od.holds', y
471                                 arguments
472
473                 apiCheckoutsGet: (x) ->
474                         return unless session.token.is_patron_access()
475
476                         od.api "#{session.links.checkouts.href}#{if x?.reserveID then x.reserveID else ''}"
477
478                         # Post-process the result, eg, fill in empty properties, sort list,
479                         # remove redundant actions or add missing actions
480                         .then (y) ->
481
482                                 # Normalize the result by adding an empty checkouts list
483                                 xs = y.checkouts or []
484
485                                 # Convert any ISO 8601 date strings into a Moment object (at
486                                 # the local time zone)
487                                 for x in xs
488                                         x.expires = momentize x.expires
489
490                                 # Sort the checkout list by expiration date
491                                 y.checkouts = _.sortBy xs, 'expires'
492
493                                 od.$.triggerHandler 'od.checkouts', y
494                                 arguments
495
496                 # Get a list of user's 'interests', ie, holds and checkouts
497                 apiInterestsGet: ->
498                         $.when(
499                                 od.apiHoldsGet()
500                                 od.apiCheckoutsGet()
501                         )
502
503                         # Consolidate the holds and checkouts information into an object
504                         # that represents the 'interests' of the patron
505                         .then (h, c) ->
506
507                                 # A useful condition to handle if the API calls could not
508                                 # be fulfilled because they are not within the scope of the
509                                 # current access token 
510                                 # TODO possibly redundant or unnecessary
511                                 ###
512                                 unless h and c
513                                         page {}, {}
514                                         return
515                                 ###
516
517                                 h = h[0]
518                                 c = c[0]
519
520                                 interests =
521                                         nHolds: h.totalItems
522                                         nHoldsReady: h.ready.forCheckout
523                                         nCheckouts: c.totalItems
524                                         nCheckoutsReady: c.totalCheckouts
525                                         ofHolds: h.holds
526                                         ofCheckouts: c.checkouts
527                                         # The following property is a map from product ID to a hold or
528                                         # a checkout object, eg, interests.byID(124)
529                                         byID: do (hs = h.holds, cs = c.checkouts) ->
530                                                 byID = {}
531                                                 for v, n in hs
532                                                         v.type = 'hold'
533                                                         byID[v.reserveId] = v
534                                                 for v, n in cs
535                                                         v.type = 'checkout'
536                                                         byID[v.reserveId] = v
537                                                 return byID
538
539                                 # Publish patron's interests to all areas of the screen
540                                 od.$.triggerHandler 'od.interests', interests
541                                 return interests
542
543         return od