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