--- /dev/null
+The source code for the project is written Coffeescript.
+
+The source needs to be completed by writing a od_config.coffee file to define
+configuration parameters, primarily related to authentication. Use the template
+file 'od_config_template.coffee' by following the instructions in the comments.
+
+The source also needs to be compiled into Javascript and minimized before the
+code can be deployed on a production server.
+
+Prerequisites
+
+1. Install nodejs.org
+
+2. Install node packages
+# npm install --global coffee-script
+# npm install --global requirejs
+
+
+Production Deployment
+
+1. Put yourself at top-level project directory.
+
+2. Compile source files from Coffeescript to Javascript (long form)
+# coffee --compile --bare --output app src
+
+(short form)
+# coffee -cb -o app src
+
+3. Minify Javascript files
+# r.js -o build.js
+
+4. Deploy minified files
+# rsync -e 'ssh -l sitkastaff' -azv build/overdrive.js servername.domainname:/var/tmp/od
+
+
+Development
+
+During development, you will cycle between compiling source files, deploying
+unminified files, and testing.
+
+- Run the compiler in watch mode and as a background process.
+# coffee -cbw -o app src &
+
+- Edit a file. It will be compiled automatically via the background process.
+
+- Deploy unminified files that have been modified to the test server.
+# rsync -e 'ssh -l sitkastaff' -azv app/ servername.domainname:/var/tmp/od
+
+- Reload your browser, https://libraryname.servername.domainname, to run the
+ modified file. Ensure that the browser's cache is disabled.
--- /dev/null
+// Build specification for the use of r.js to minify js files
+// At the top-level directory, invoke as follows:
+//
+// # r.js -o build.js
+//
+({
+ appDir: './app' // un-minified input files
+ , baseUrl: './' // location of modules relative to appDir
+ , dir: './build' // minified output files
+
+ // Define bundles of local modules
+ , modules: [
+ { name: 'overdrive' }
+ ]
+
+ // Define external resources (eg, not sourced locally,from content delivery networks, etc)
+ , paths: {
+ jquery: 'empty:'
+ , jqueryui: 'empty:'
+ , lodash: 'empty:'
+ , moment: 'empty:'
+ , cookies: 'empty:'
+ , json: 'empty:'
+ }
+})
--- /dev/null
+# TODO cannot auto-focus on close button of action dialog
+# probably because it needs to be done asynchronously using setTimeout
+#
+define [
+ 'jquery'
+ 'lodash'
+ 'json'
+ 'od_api'
+ 'jquery-ui'
+], ($, _, json, od) ->
+
+ # Load a CSS file related to our use of the jqueryui dialog widget.
+ # We manually load the file in order to avoid modifying any .tt2 files.
+ do (url = '//ajax.googleapis.com/ajax/libs/jqueryui/1.11.1/themes/smoothness/jquery-ui.min.css') ->
+ link = document.createElement('link')
+ link.type = 'text/css'
+ link.rel = 'stylesheet'
+ link.href = url
+ document.getElementsByTagName('head')[0].appendChild(link)
+
+ # Return an abbreviation of the pathname of the current page,
+ # eg, if window.location.pathname equals 'eg/opac/record' or
+ # 'eg/opac/record/123', then return 'record', otherwise return ''
+ brief_name = ->
+ xs = window.location.pathname.match /eg\/opac\/(.+)/
+ if xs then xs[1].replace /\/\d+/, '' else ''
+ # TODO also defined in od_page_rewrite, but we don't want this module to
+ # depend on that module, because it depends on this module.
+
+ # Pluck out a sensible message from the reply to an action request
+ responseMessage = (x) -> (json.parse x.responseText).message
+
+ # Customize the dialog widget to guide a user through the intention of
+ # making a transaction
+ #
+ # Usage: $('<div>').dialogAction action: action, scenario: scenario
+ #
+ $.widget 'ui.dialogAction', $.ui.dialog,
+ options:
+ draggable: false
+ resizable: true
+ modal: true
+ buttons: [
+ {
+ text: 'Yes'
+ click: (ev) ->
+ ev.stopPropagation()
+ $(@).dialogAction 'yes_action'
+ return
+ }
+ {
+ text: 'No'
+ click: (ev) ->
+ ev.stopPropagation()
+ $(@).dialogAction 'non_action'
+ return
+ }
+ ]
+
+ # On create, perform custom positioning, and show custom title and
+ # body. When the dialog finally closes, destroy it.
+ _create: ->
+
+ intent = @options._scenario?.intent
+ position = @options._action?._of
+
+ # Text of Yes/No buttons in the intent scenario may be overridden
+ if intent
+ ob = @options.buttons
+ ib = intent.buttons
+ ob[0].text = ib?[0] or 'Yes'
+ ob[1].text = ib?[1] or 'No'
+
+ # Position of dialog box may be overridden
+ @options.position = of: position, at: 'top', my: 'top' if position
+
+ @_super()
+
+ # On creation, dialog message may be overidden by the intent scenario
+ @set_message 'intent', false if intent
+
+ @_on 'dialogactionclose': -> @_destroy()
+
+ # Depending on the given scenario, the title and body of the dialog
+ # screen may be set, and the close button may be shown or hidden.
+ # The title is a text string specified as the title property of the
+ # scenario, or it defaults to the etitle of the attched action object.
+ # The body is a text string specified as an argument or it defaults to
+ # the body property of the scenario.
+ set_message: (scenario, close, details) ->
+ s = @options._scenario[scenario]
+ @element.empty().append details or s.body
+ @option 'title', s.title or @options._action._of._etitle()
+ @_close_button close
+ return @
+
+ non_action: ->
+ @_on 'dialogactionclose': reroute if reroute = @options._scenario?.intent?.reroute
+ @close()
+ return @
+
+ # Respond to the Yes button by going ahead with the intended action
+ yes_action: ->
+
+ # At this point, dialog buttons are turned off.
+ @option 'buttons', []
+
+ # Make an API call
+ action = @options._action
+ od.api action.href, action.method, fields: $('form', @element).serializeArray()
+
+ # TODO progress message is not showing
+ # seems like the progress callback is never called
+ .progress => @set_message 'progress', true
+
+ # Re-use the dialog to show notifications with a close button
+ .then(
+ (x) => @set_message 'done', true
+ (x) => @set_message 'fail', true, responseMessage x
+ )
+
+ # On done and when the user closes the dialog, reroute the page
+ .done =>
+ @_on 'dialogactionclose': reroute if reroute = this.options._scenario?.done?.reroute
+
+ return @
+
+ # Show or hide the dialog close button
+ _close_button: (close) ->
+ @element.parent()
+ .find('.ui-dialog-titlebar-close')[if close then 'show' else 'hide']()
+ .end()
+ .end()
+
+
+ # Map action names to labels
+ # TODO also use this mapping in scenarios
+ Labels =
+ hold: 'Place hold'
+ addSuspension: 'Suspend'
+ releaseSuspension: 'Activate'
+ removeHold: 'Cancel'
+ checkout: 'Check out'
+ earlyReturn: 'Return title'
+ format: 'Select format'
+
+
+ # We define custom jQuery extensions to perform the actions defined by the
+ # Labels object. The main role of each of these extensions is to build a
+ # scenario object that specifies the layout and behaviour of an instance of
+ # the action dialog widget. The specification is dependent on the content
+ # of a given action object and hence the scenario object must be built
+ # dynamically.
+ #
+ # TODO Since all of these extensions end with making an identical call to
+ # the dialogAction widget, it would be good to abstract the call to the
+ # outside environment, perhaps redefine the extensions as simple functions.
+ # eg, fn:: action -> scenario
+ #
+ # TODO Map action names to re-routed page names. The rerouting function
+ # depends on current page, current action, and current scenario
+ #
+ $.fn.extend
+
+ # Build a dialog to place a hold
+ _hold: (action) ->
+
+ scenario =
+ intent:
+ body: $('<div>')._action_fields action.fields
+ buttons: [ 'Place hold', 'Cancel' ]
+ # TODO clicking cancel should return to search results, not
+ # to rerouted page
+ reroute: -> window.history.back()
+ done:
+ body: 'Hold was successfully placed. Close this box to be redirected to your holds list.'
+ reroute: -> window.location.replace '/eg/opac/myopac/holds?e_items'
+ fail: body: 'Hold was not placed. There may have been a network or server problem. Please try again.'
+ progress: body: 'Hold is being placed...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to cancel a hold
+ _removeHold: (action) ->
+
+ scenario =
+ intent: body: 'Are you sure you want to cancel this hold?'
+ done: body: 'Hold was successfully cancelled.'
+ fail: body: 'Hold was not cancelled. There may have been a network or server problem. Please try again.'
+ progress: body: 'Hold is being cancelled...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to suspend a hold
+ _addSuspension: (action) ->
+
+ scenario =
+ intent:
+ body: $('<div>')._action_fields action.fields
+ buttons: [ 'Suspend', 'Cancel' ]
+ done: body: 'Hold was successfully suspended'
+ fail: body: 'Hold was not suspended. There may have been a network or server problem. Please try again.'
+ progress: body: 'Hold is being suspended...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to release a suspension
+ _releaseSuspension: (action) ->
+
+ scenario =
+ intent: body: 'Are you sure you want this hold to activate again?'
+ done:
+ body: 'Suspension was successfully released. The page will reload to update your account status.'
+ reroute: -> window.location.reload true
+ fail: body: 'Suspension was not released. There may have been a network or server problem. Please try again.'
+ progress: body: 'Suspension is being released...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to checkout a title
+ _checkout: (action) ->
+
+ scenario =
+ intent:
+ body: $('<div>')._action_fields action.fields
+ buttons: [ 'Check out', 'Cancel' ]
+ reroute: ->
+ # if at placehold page, go back; otherwise, stay on same page
+ # TODO place_hold no longer relevant
+ window.history.back() if brief_name() is 'place_hold'
+ done:
+ body: 'Title was successfully checked out. Close this page to be redirected to your checkouts list.'
+ reroute: -> window.location.replace '/eg/opac/myopac/circs?e_items'
+ fail: body: 'Title was not checked out. There may have been a network or server problem. Please try again.'
+ progress: body: 'Title is being checked out...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to select a format of a title
+ _format: (action) ->
+
+ scenario =
+ intent:
+ body: $('<div>')._action_fields action.fields
+ buttons: [ 'Select format', 'Cancel' ]
+ done: body: 'Format was successfully selected.'
+ fail: body: 'Format was not selected. There may have been a network or server problem. Please try again.'
+ progress: body: 'Format is being selected...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build a dialog to return a title early
+ _earlyReturn: (action) ->
+
+ scenario =
+ intent: body: 'Are you sure you want to return this title before it expires?'
+ done: body: 'Title was successfully returned.'
+ fail: body: 'Title was not returned. There may have been a network or server problem. Please try again.'
+ progress: body: 'Title is being returned...'
+
+ @dialogAction _scenario: scenario, _action: action
+
+ # Build format buttons given specifications as follows.
+ # formats = [ { formatType: type, linkTemplates: { downloadLink: { href: href } } } ]
+ # actions = { downloadLink: { href: href, method: get, type: type } }
+ #
+ # TODO no need to define an action dialog because this is the only example of a get action
+ # and we can allow the default behaviour to occur.
+ #
+ # Do we need a dialogDownload widget?
+ # Confirm -> HTTP GET downloadLink.
+ # Fail -> Browser navigates to errorURL and shows error status.
+ # Done -> Response is a contentLink. HTTP Get contentLink.
+
+ _formats: (formats) ->
+ return @ unless formats
+
+ tpl = _.template """
+ <div>
+ <a href="<%= href %>" class="opac-button" style="margin-top: 0px; margin-bottom: 0px"><%= label %></a>
+ </div>
+ """
+
+ $buttons = for format in formats
+ {
+ formatType: n
+ linkTemplates:
+ downloadLink:
+ href: href
+ type: type
+ } = format
+
+ # Create a button for this action
+ $ tpl href: href, label: "Download #{od.labels n}"
+
+ @empty().append $buttons
+
+ # Build action buttons and dialogs given specifications as follows.
+ # actions = [ { name: { href: h, method: m, fields: [ { name: n, value: v, options: [...] } ] } ]
+ _actions: (actions) ->
+
+ tpl = _.template """
+ <div>
+ <a href="<%= href %>" class="opac-button" style="margin-top: 0px; margin-bottom: 0px"><%= label %></a>
+ </div>
+ """
+
+ # Find the related row
+ $tr = @closest('tr')
+
+ $buttons = for n, action of actions
+
+ # Extend the action object with context
+ $.extend action, _of: $tr, _name: n
+
+ # Create a button for this action
+ $ tpl href: action.href, label: Labels?[n] or n
+
+ # On clicking the button, build a new dialog using the extended action object
+ .on 'click', action, (ev) ->
+ ev.preventDefault()
+ $('<div>')['_' + ev.data._name] ev.data
+ # TODO apply dialogAction method directly as follows.
+ #$('<div>').dialogAction _scenario: Actions.scenario[ev.data._name], _action: ev.data
+ return false
+
+ @empty().append $buttons
+
+ # Build a form of input fields out of a list of action fields
+ _action_fields: (fields) ->
+
+ $('<form>')
+ ._action_field_hidden _.where(fields, name: 'reserveId')[0]
+ ._action_field_email _.where(fields, name: 'emailAddress')[0]
+ ._action_field_radio _.where(fields, name: 'formatType')[0]
+ ._action_field_suspend _.where(fields, name: 'suspensionType')[0]
+ ._action_field_date _.where(fields, name: 'numberOfDays')[0]
+
+ # Show a date field only if suspensionType of indefinite is selected
+ .on 'click', '[name=suspensionType]', (ev) ->
+ $input = $('[name=numberOfDays]')
+ switch @defaultValue
+ when 'limited' then $input.show()
+ when 'indefinite' then $input.hide()
+
+ .on 'submit', (ev) ->
+ $input = $('[name=numberOfDays]')
+
+ # Build a date field and initially hide it
+ _action_field_date: (field) ->
+
+ return @ unless field
+
+ $input = $ """
+ <input type="date" name="#{field.name}" value="#{field.value}" />
+ """
+ @append $input.hide()
+
+ # Build a hidden input
+ _action_field_hidden: (field) ->
+
+ return @ unless field
+
+ @append """
+ <input type="hidden" name="#{field.name}" value="#{field.value}" />
+ """
+
+ # Build an email input
+ _action_field_email: (field) ->
+
+ return @ unless field
+
+ $input = $ """
+ <div>
+ You will be notified by email when a copy becomes available
+ </div>
+ <div>
+ <label>Email address: <input type="email" name="#{field.name}" value="#{field.value}" />
+ </label>
+ </div>
+ """
+ $input.find('input').prop 'required', true unless Boolean field.optional
+
+ @append $input
+
+ # Build a group of radio buttons
+ _action_field_radio: (field) ->
+
+ return @ unless field
+
+ # If one of the format types is ebook reader, omit it from the
+ # list, because it is not a downloadable type
+ _.remove field.options, (f) -> f is 'ebook-overdrive'
+
+ # A hint specific to whether format types are optional or not is added to the form
+ hint = switch
+ when field.options.length is 1 then """
+ <div>Only one #{field.name} is available and it has been selected for you</div>
+ """
+ when Boolean field.optional then """
+ <div>You may select a #{field.name} at this time</div>
+ """
+ else """
+ <div>Please select one of the available #{field.name}s</div>
+ """
+ inputs = for v in field.options
+ $x = $ """
+ <div>
+ <input type="radio" name="#{field.name}" value="#{v}" />#{od.labels v}
+ </div>
+ """
+ $y = $x.find 'input'
+ $y.prop('required', true) unless Boolean field.optional
+ $y.prop('checked', true) unless field.options.length > 1
+ $x
+
+ @append hint
+ .append inputs
+
+ _action_field_suspend: (field) ->
+
+ return @ unless field
+
+ label =
+ indefinite: 'Suspend this hold indefinitely'
+ limited: 'Suspend this hold for a limited time'
+
+ inputs = for v in field.options
+ $x = $ """
+ <div><label>
+ <input type="radio" name="#{field.name}" value="#{v}" /> #{label[v]}
+ </label></div>
+ """
+ $y = $x.find 'input'
+ $y.prop('required', true) unless Boolean field.optional
+ $y.prop('checked', true) if v is 'indefinite'
+ $x
+
+ @append inputs
+
+ # We will delegate the handling of download links to the page's
+ # tbody. The sequence of operation is as follows. We need to get
+ # from a download link to make the download request, receive a
+ # content link as a response, and then perform a 'normal' get of
+ # the content link. A complexity is to handle the error responses.
+ #
+ # TODO The initial get could fail, in which case, the errorpageurl
+ # will be used to convey the failure status as a query string. We
+ # will redirect the error page to the current page and we recognize
+ # the error condition by analysing the query parameters.
+ #
+ # TODO Another error condition could occur if an Overdrive Read
+ # ebook is attempted to be downloaded. Here, the odreadauthurl
+ # will be used as a redirect location. We will also redirect to the
+ # current page and hopefully will be able to discern the state and
+ # show it accordingly.
+ #
+ _download_format: ->
+ @on 'click', 'td.formats a', (ev) ->
+ ev.preventDefault()
+
+ # We will return to the current page to handle errors
+ x = encodeURIComponent window.location.href
+ dl = @href
+ .replace /\{errorpageurl\}/, x
+ .replace /\{odreadauthurl\}/, x
+
+ od.api dl
+ .then(
+ (x) ->
+ window.open(
+ od.proxy x.links.contentlink.href # url
+ '_blank' #'Overdrive Read format' # title
+ 'resizable, scrollbars, status, menubar, toolbar, personalbar' # features
+ )
+ -> console.log 'failed to get download link'
+ )
+ .then(
+ -> # not expected to arrive here ever
+ -> console.log 'failed to get contentLink'
+ )
+
+ return
+
+
+ _notify: (title, text) ->
+
+ @dialog
+ position: my: 'top', at: 'right top'
+ minHeight: 0
+ autoOpen: true
+ draggable: false
+ resizable: true
+ show: effect: 'slideDown'
+ hide: effect: 'slideUp'
+ close: -> $(@).dialog 'destroy'
+ title: title
+
+ .text text
+
+ # Get the title of e-item in a row context
+ _etitle: -> @find('.title a').text()
--- /dev/null
+define [
+ 'jquery'
+ 'lodash'
+ 'json'
+ 'cookies'
+ 'moment'
+ 'od_config'
+], ($, _, json, C, M, config) ->
+
+ # Dump the given arguments or log them to console
+ log = ->
+ try
+ dump "#{x}\n" for x in arguments
+ return
+ catch
+ console.log arguments
+ return
+
+ $notify = $ {}
+
+ logError = (jqXHR, textStatus, errorThrown) ->
+ log "#{textStatus} #{jqXHR.status} #{errorThrown}"
+ $notify.trigger 'od.fail', arguments
+
+ # Define custom event names for this module. A custom event is triggered
+ # whenever result data becomes available after making an API request.
+ eventList = [
+ 'od.clientaccess'
+ 'od.libraryaccount'
+ 'od.metadata'
+ 'od.availability'
+
+ 'od.patronaccess'
+ 'od.patroninfo'
+ 'od.holds'
+ 'od.checkouts'
+ 'od.interests'
+ 'od.action'
+
+ 'od.hold.update'
+ 'od.hold.delete'
+ 'od.checkout.update'
+ 'od.checkout.delete'
+
+ 'od.prefs'
+ 'od.login'
+ 'od.logout'
+ 'od.error'
+ ]
+ eventObject = $({}).on eventList.join(' '), (e, x, y...) -> log e.namespace, x, y
+
+ # We require the service of a session object to store essentials bits of
+ # information during a login session and between page reloads. Here, we
+ # define the default version of the session, which will be used if no
+ # session is active.
+ default_session =
+
+ prefs: {}
+
+ credentials: {}
+
+ # An essential role of the session object is to store the response
+ # parameters that are provided as a result of authenticating the client
+ # or the patron. Two components, the token type and the token, are
+ # computed into an Authorization header so that they can be easily
+ # submitted to an ajax call.
+ token: {}
+ #parameters: ''
+ #headers: Authorization: ''
+
+ # Another role is to store the endpoints of the various APIs. These
+ # include the endpoints for authenticating the client or the patron and
+ # the endpoints for getting library or patron information. Upon
+ # authentication, other endpoints are dynamically accumulated within
+ # the session object.
+ links:
+ token: href: '//oauth.overdrive.com/token'
+ libraries: href: "//api.overdrive.com/v1/libraries/#{config.accountID}"
+ patrontoken: href: '//oauth-patron.overdrive.com/patrontoken'
+ patrons: href: '//patron.api.overdrive.com/v1/patrons/me'
+ holds: href: ''
+ checkouts: href: ''
+ products: ''
+ advantageAccounts: ''
+ search: ''
+ availability: ''
+
+ # Another role is to preserve the mapping between format id and format
+ # name that will be provided by the Library Account API.
+ labels: {}
+
+ # The session object uses a local storage mechanism based on window.name;
+ # see
+ # http://stackoverflow.com/questions/2035075/using-window-name-as-a-local-data-cache-in-web-browsers
+ # for pros and cons and alternatives.
+ #
+ # On page load, we unserialize the text string found in local storage into
+ # an object, or if there is no string yet, we create the default object.
+ session =
+ try
+ json.parse window.name
+ catch
+ default_session
+
+ # On window unload, we timestamp the current session object and serialize it
+ # into local storage so that it survives page reloads.
+ $(window).on 'unload', ->
+ session.now = M().toISOString()
+ window.name = json.stringify session
+
+ # Use a cheap, nasty way to enforce a sanity constraint: session link templates should have empty
+ # values unless the current session is logged in.
+ $.extend session.links, { search: '', availability: '' } unless Boolean C('eg_loggedin') or window.IAMXUL
+
+ # Return a new object from given an object that has a 'key' property and a
+ # 'value' property
+ to_object = (from, key, value) ->
+ to = {}
+ to[x[key]] = x[value] for x in from
+ return to
+
+ # Update library or patron account information for the session
+ update_session_cache = (x) ->
+
+ # Cache any links
+ $.extend session.links, x.links if x.links
+
+ # Cache any linkTemplates; these are sourced only via the Patron Information API
+ $.extend session.links, x.linkTemplates if x.linkTemplates
+
+ #$.extend session, x
+
+ # Cache any mapping between format names and format IDs
+ $.extend session.labels, to_object x.formats, 'id', 'name' if x.formats
+
+ return x
+
+ # Revert the session cache to its default version
+ expire_session_cache = ->
+ $.extend session, default_session
+
+ # Define a function to check if the session object contains a patron access
+ # token. It is enough to test if the parameters.scope text string mentions
+ # the word 'patron'.
+ #is_patron_access_token = -> /patron/i.test session.token.parameters?.scope
+ is_patron_access_token = -> /patron/i.test session.token?.scope
+
+ # Calculate a string encompassing the token type and access token of a
+ # given response object to an Overdrive login request
+ token_header = (x) -> Authorization: "#{x.token_type} #{x.access_token}"
+
+ # Customize the plain jQuery ajax to post a request for an access token
+ _api = (url, data) ->
+
+ $.ajax $.extend {},
+ # The Basic Authorization string is always added to the HTTP header.
+ headers: Authorization: "Basic #{config.credentials}"
+ # The URL endpoint is converted to its reverse proxy version,
+ # because we are using the Evergreen server as a reverse proxy to
+ # the Overdrive server.
+ url: proxy url
+ type: 'POST'
+ # We expect data to be always given; the ajax method will convert
+ # it to a query string.
+ data: data
+
+ # Replace the host domain of a given URL with a proxy domain. If the input
+ # URL specifies a protocol, it is stripped out so that the output will
+ # default to the client's protocol.
+ proxy = (x) ->
+ return unless x
+ y = x
+ y = y.replace 'https://', '//'
+ y = y.replace 'http://' , '//'
+ y = y.replace '//oauth-patron.overdrive.com', '/od/oauth-patron'
+ y = y.replace '//oauth.overdrive.com', '/od/oauth'
+ y = y.replace '//patron.api.overdrive.com', '/od/api-patron'
+ y = y.replace '//api.overdrive.com', '/od/api'
+ y = y.replace '//images.contentreserve.com', '/od/images'
+ y = y.replace '//fulfill.contentreserve.com', '/od/fulfill'
+ #log "proxy #{x} -> #{y}"
+ y
+
+ # Convert a serialized array into a serialized object
+ serializeObject = (a) ->
+ o = {}
+ $.each a, ->
+ v = @value or ''
+ if (n = o[@name]) isnt undefined
+ o[@name] = [n] unless n.push
+ o[@name].push v
+ else
+ o[@name] = v
+ return o
+
+ # TODO unused
+ $.fn.extend
+
+ # Convert this serialized array to a serialized object
+ _serializeObject: -> serializeObject @serializeArray()
+
+ # Serialize this to a json string, an object, an array, a query string, or just return itself
+ _serializeX: (X) ->
+ switch X
+ when 'j' then json.stringify @_serializeX 'o'
+ when 'k' then json.stringify @_serializeX 'a'
+ when 'p' then $.param @_serializeX 'a'
+ when 's' then @serialize()
+ when 'o' then serializeObject @_serializeX 'a'
+ when 'a' then @serializeArray()
+ else @
+
+ # Mutate an ISO 8601 date string into a Moment object. If the argument is
+ # just a date value, then it specifies an absolute date in ISO 8601 format.
+ # If the argument is a pair, then it specifies a date relative to now. For
+ # an ISO 8601 date, we correct for what seems to be an error in time zone,
+ # Zulu time is really East Coast time.
+ momentize = (date, unit) ->
+ switch arguments.length
+ when 1
+ if date then M(date.replace /Z$/, '-0400') else M()
+ when 2
+ if date then M().add date, unit else M()
+ else M()
+
+ # We define the public interface of the module
+ # TODO wrap od in jquery so that we can use it to trigger events and bind event handlers
+ od =
+
+ # Povides the anchor object for implementing a publish/subscribe
+ # mechanism for this module.
+ $: eventObject.on
+
+ # Notification that there are possible changes of values from
+ # preferences page that should be updated in the session cache
+ 'od.prefs': (ev, x) -> $.extend session.prefs, x
+
+ # Expire patron access token if user is no longer logged into EG
+ 'od.logout': (ev, x) ->
+ if x is 'eg'
+ expire_session_cache if is_patron_access_token()
+
+ log: log
+
+ # It's necessary to expose the session cache to other modules because
+ # they may need to access certain stored values, in particular, the
+ # place hold page needs to get access to the email address
+ session: session
+
+ proxy: proxy
+
+ # Map format id to format name using current session object
+ labels: (id) -> session.labels[id] or id
+
+ # Customize the plain jQuery ajax method to handle a GET or POST method
+ # for the Overdrive api.
+ api: (url, method, data) ->
+
+ # Do some pre-processing of data before it is sent to server
+ if method is 'post'
+
+ # Convert numberOfDays value from an ISO 8601 date string to
+ # number of days relative to now. There are two subtleties
+ # regarding rounding errors: First, we use only use now of
+ # resolution to days to avoid a local round-down from 1 to 0.
+ # Second, we need to add one to avoid a round-down at the OD
+ # server.
+ for v in data.fields when v.name is 'numberOfDays'
+ v.value = 1 + M(v.value).diff M().toArray()[0..2], 'days'
+
+ $.ajax $.extend {},
+ # The current Authorization string is always added to the HTTP header.
+ headers: session.token.headers
+ # The URL endpoint is converted to its reverse proxy version, because
+ # we are using the Evergreen server as a reverse proxy to the Overdrive
+ # server.
+ url: proxy url
+ # Will default to 'get' if no method string is supplied
+ type: method
+ # A given data object is expected to be in JSON format
+ contentType: 'application/json; charset=utf-8'
+ data: json.stringify data
+
+ .done ->
+
+ # For a post method, we get a data object in reply. We publish
+ # the object using an event named after the data type, eg,
+ # 'hold', 'checkout'. We can't easily recognize the data type
+ # by looking at the data, so we have to pattern match on the
+ # API URL.
+ if method is 'post'
+ if /\/holds|\/suspension/.test url
+ x = arguments[0]
+ x.holdPlacedDate = momentize x.holdPlacedDate
+ x.holdExpires = momentize x.holdExpires
+ if x.holdSuspension
+ x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
+ od.$.triggerHandler 'od.hold.update', x
+ if /\/checkouts/.test url
+ x = arguments[0]
+ x.expires = momentize x.expires
+ od.$.triggerHandler 'od.checkout.update', x
+
+ # For a delete method, we do not get a data object in reply,
+ # thus we pattern match for the specific ID and trigger an
+ # event with the ID.
+ if method is 'delete'
+ if id = url.match /\/holds\/(.+)\/suspension$/
+ return # no relevant event
+ if id = url.match /\/holds\/(.+)$/
+ od.$.triggerHandler 'od.hold.delete', reserveId: id[1]
+ if id = url.match /\/checkouts\/(.+)$/
+ od.$.triggerHandler 'od.checkout.delete', reserveId: id[2]
+
+ .fail ->
+ od.$.triggerHandler 'od.error', [url, arguments[0]]
+ #$('<div>')._notify arguments[1].statusText, arguments[0].responseText
+
+ # Get a library access token so that we can use the Discovery API
+ apiDiscAccess: ->
+
+ ok = (x) ->
+ # Cache the server's response object as a general reference point
+ #session.token.parameters = x
+ session.token = x
+ # Cache the access token so that it can be used in future api calls
+ session.token.headers = token_header x
+ od.$.triggerHandler 'od.clientaccess', x
+
+ _api session.links.token.href, grant_type: 'client_credentials'
+
+ .then ok, logError
+
+ # Use the Library Account API to get library account information,
+ # primarily the product link and the available formats. Since we
+ # schedule this call on every page load, it will also tell us if
+ # our access token has expired or not.
+ #
+ # If a retry is needed, we have to decide whether to get a library
+ # access token or a patron access token. However, getting the latter
+ # will, in the general case, require user credentials, which means we
+ # need to store the password in the browser across sessions. An
+ # alternative is to force a logout, so that the user needs to manually
+ # relogin. In effect, we would only proceed with a retry to get a
+ # library access token, but if the user has logged in, we would not.
+ #
+ apiAccount: ->
+
+ get = -> od.api session.links.libraries.href
+
+ ok = (x) ->
+ update_session_cache x
+ od.$.triggerHandler 'od.libraryaccount', x
+ return
+
+ retry = (jqXHR) ->
+
+ # Retry if we got a 401 error code
+ if jqXHR.status is 401
+
+ if is_patron_access_token()
+ # Current OD patron access token may have expired
+ od.$.triggerHandler 'od.logout', 'od'
+
+ else
+ # Renew our access token and retry the get operation
+ od.apiDiscAccess()
+ .then get, logError
+ .then ok
+
+ get().then ok, retry
+
+ # We define a two-phase sequence to get a patron access token, for example,
+ # login(credentials); do_something_involving_page_reload(); login();
+ # where credentials is an object containing username and password
+ # properties from the login form.
+ #
+ # Logging into Evergreen can proceed using either barcode or user name,
+ # but logging into Overdrive is only a natural act using barcode. In
+ # order to ensure that logging into OD with a username can proceed, we
+ # presume that EG has been logged into and, as a prelude, we get the
+ # Preferences page of the user so that we can scrape out the barcode
+ # value for logging into OD.
+ #
+ # The login sequence is associated with a cache that remembers the
+ # login response ('parameters') between login sessions. The epilogue to
+ # the login sequence is to use the Patron Information API to get URL
+ # links and templates that will allow the user to make further use of
+ # the Circulation API.
+ login: (credentials) ->
+
+ # Temporarily store the username and password from the login form
+ # into the session cache, and invalidate the session cache so that
+ # the final part of login sequence can complete.
+ if credentials
+ $.extend session.credentials, credentials
+ #delete session.token.parameters
+ session.token = {}
+ od.$.triggerHandler 'od.login'
+ return
+
+ # Return a promise to a resolved deferredment if session cache is still valid
+ # TODO is true if in staff client but shouldn't be
+ #if session.token.parameters
+ if is_patron_access_token()
+ return $.Deferred().resolve().promise()
+
+ # Request OD service for a patron access token using credentials
+ # pulled from the patron's preferences page
+ login = (prefs) ->
+
+ # Define a function to cut the value corresponding to a label
+ # from prefs
+ x = (label) ->
+ r = new RegExp "#{label}<\\/td>\\s+<td.+>(.*?)<\\/td>", 'i'
+ prefs.match(r)?[1] or ''
+
+ # Retrieve values from preferences page and save them in the
+ # session cache for later reference
+ $.extend( session.prefs,
+ barcode: x 'barcode'
+ email_address: x 'email address'
+ home_library: x 'home library'
+ )
+
+ # Use barcode as username or the username that was stored in
+ # session cache (in the hope that is a barcode) or give up with
+ # a null string
+ un = session.prefs.barcode or session.credentials?.username or ''
+
+ # Use the password that was stored in session cache or a dummy value
+ pw = if config.password_required is 'false' then 'xxxx' else session.credentials?.password or 'xxxx'
+
+ # Remove the stored credentials from cache as soon as they are
+ # no longer needed
+ session.credentials = {}
+
+ # Determine the Open Auth scope by mapping the long name of EG
+ # home library to OD authorization name
+ scope = "websiteid:#{config.websiteID} authorizationname:#{config.authorizationname session.prefs.home_library}"
+
+ # Try to get a patron access token from OD server
+ _api session.links.patrontoken.href,
+ grant_type: 'password'
+ username: un
+ password: pw
+ password_required: config.password_required
+ scope: scope
+
+ # Complete login sequence if the session cache is invalid
+ ok = (x) ->
+ #session.token.parameters = x
+ session.token = x
+ session.token.headers = token_header x
+ od.$.triggerHandler 'od.patronaccess', x
+
+ # Get patron preferences page
+ $.get '/eg/opac/myopac/prefs'
+ # Get the access token using credentials from preferences
+ .then login
+ # Update the session cache with access token
+ .then ok
+ # Update the session cache with session links
+ .then od.apiPatronInfo
+ .fail log
+
+ # TODO not used; EG catalogue is used instead
+ apiSearch: (x) ->
+ return unless x
+ od.api session.links.products.href, get, x
+
+ # TODO we can probably get away with using one normalization routine
+ # instead of using one for each type of data object, because they don't
+ # share property names.
+
+ apiMetadata: (x) ->
+ return unless x.id
+ od.api "#{session.links.products.href}/#{x.id}/metadata"
+
+ .then (y) ->
+ # Convert ID to upper case to match same case found in EG catalogue
+ y.id = y.id.toUpperCase()
+ # Provide a simplified notion of author: first name in creators
+ # list having a role of author
+ y.author = (v.name for v in y.creators when v.role is 'Author')[0] or ''
+ # Publish the metadata object
+ od.$.triggerHandler 'od.metadata', y
+ y
+
+ .fail -> od.$.triggerHandler 'od.metadata', x
+
+ apiAvailability: (x) ->
+ return unless x.id
+
+ url =
+ if (alink = session.links.availability?.href)
+ alink.replace '{crId}', x.id # Use this link if logged in
+ else
+ "#{session.links.products.href}/#{x.id}/availability"
+
+ od.api url
+
+ # Post-process the result, eg, fill in empty properties
+ .then (y) ->
+ # Normalize the result by adding zero values
+ y.copiesOwned = 0 unless y.copiesOwned
+ y.copiesAvailable = 0 unless y.copiesAvailable
+ y.numberOfHolds = 0 unless y.numberOfHolds
+
+ if y.actions?.hold
+ # The reserve ID is empty in the actions.hold.fields; we have to fill it ourselves.
+ _.where(y.actions.hold.fields, name: 'reserveId')[0].value = y.id
+ # We jam the email address from the prefs page into the fields object from the server
+ # so that the new form will display it.
+ if email_address = od.session.prefs.email_address
+ _.where(y.actions.hold.fields, name: 'emailAddress')[0].value = email_address
+
+ od.$.triggerHandler 'od.availability', y
+ arguments
+
+ .fail -> od.$.triggerHandler 'od.availability', x
+
+ apiPatronInfo: ->
+ ok = (x) ->
+ update_session_cache x
+ od.$.triggerHandler 'od.patroninfo', x
+ return
+
+ od.api session.links.patrons.href
+ .then ok, logError
+
+ apiHoldsGet: (x) ->
+ return unless is_patron_access_token()
+
+ od.api "#{session.links.holds.href}#{if x?.productID then x.productID else ''}"
+
+ # Post-process the result, eg, fill in empty properties, sort list,
+ # remove redundant actions or add missing actions
+ .then (y) ->
+
+ # Normalize the result by adding an empty holds list
+ xs = y.holds or []
+
+ # For each hold, convert any ISO 8601 date strings into a
+ # Moment object (at the local time zone)
+ for x in xs
+ x.holdPlacedDate = momentize x.holdPlacedDate
+ x.holdExpires = momentize x.holdExpires
+ if x.holdSuspension
+ x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
+
+ # Count the number of holds that can be checked out now
+ y.ready = _.countBy xs, (x) -> if x.actions.checkout then 'forCheckout' else 'other'
+ y.ready.forCheckout = 0 unless y.ready.forCheckout
+
+ # Delete action to release a suspension if a hold is not
+ # suspended, because such actions are redundant
+ delete x.actions.releaseSuspension for x in xs when not x.holdSuspension
+
+ # Sort the holds list by position and placed date
+ # and sort ready holds first
+ #y.holds = _.sortBy xs, ['holdListPosition', 'holdPlacedDate']
+ y.holds = _(xs)
+ .sortBy ['holdListPosition', 'holdPlacedDate']
+ .sortBy (x) -> x.actions.checkout
+ .value()
+
+ od.$.triggerHandler 'od.holds', y
+ arguments
+
+ apiCheckoutsGet: (x) ->
+ return unless is_patron_access_token()
+
+ od.api "#{session.links.checkouts.href}#{if x?.reserveID then x.reserveID else ''}"
+
+ # Post-process the result, eg, fill in empty properties, sort list,
+ # remove redundant actions or add missing actions
+ .then (y) ->
+
+ # Normalize the result by adding an empty checkouts list
+ xs = y.checkouts or []
+
+ # Convert any ISO 8601 date strings into a Moment object (at
+ # the local time zone)
+ for x in xs
+ x.expires = momentize x.expires
+
+ # Sort the checkout list by expiration date
+ y.checkouts = _.sortBy xs, 'expires'
+
+ od.$.triggerHandler 'od.checkouts', y
+ arguments
+
+ # Get a list of user's 'interests', ie, holds and checkouts
+ apiInterestsGet: ->
+ $.when(
+ od.apiHoldsGet()
+ od.apiCheckoutsGet()
+ )
+
+ # Consolidate the holds and checkouts information into an object
+ # that represents the 'interests' of the patron
+ .then (h, c) ->
+
+ # A useful condition to handle if the API calls could not
+ # be fulfilled because they are not within the scope of the
+ # current access token
+ # TODO possibly redundant or unnecessary
+ ###
+ unless h and c
+ page {}, {}
+ return
+ ###
+
+ h = h[0]
+ c = c[0]
+
+ interests =
+ nHolds: h.totalItems
+ nHoldsReady: h.ready.forCheckout
+ nCheckouts: c.totalItems
+ nCheckoutsReady: c.totalCheckouts
+ ofHolds: h.holds
+ ofCheckouts: c.checkouts
+ # The following property is a map from product ID to a hold or
+ # a checkout object, eg, interests.byID(124)
+ byID: do (hs = h.holds, cs = c.checkouts) ->
+ byID = {}
+ for v, n in hs
+ v.type = 'hold'
+ byID[v.reserveId] = v
+ for v, n in cs
+ v.type = 'checkout'
+ byID[v.reserveId] = v
+ return byID
+
+ # Publish patron's interests to all areas of the screen
+ od.$.triggerHandler 'od.interests', interests
+ return interests
+
+ return od
--- /dev/null
+# This file represents a template to write a configuration module for the
+# system.
+
+define [
+ 'moment'
+], (M) ->
+
+ # Mapping between long name of home library and Overdrive authorization name
+ longname =
+ 'long name one': 'name1'
+ 'long name two': 'name2'
+
+ # Default configuration of date formats for Moment object;
+ # see http://devdocs.io/moment/index#customization-long-date-formats
+ M.lang 'en', longDateFormat:
+ LT: "h:mm A",
+ L: "MM/DD/YYYY",
+ LL: "MMMM Do YYYY",
+ LLL: "MMMM Do YYYY LT",
+ LLLL: "dddd, MMMM Do YYYY LT"
+
+ return {
+
+ # Define the credentials to use to get client authentication to the
+ # API. The text string is a combination of the client key and client
+ # secret combined in the method described in
+ # https://developer.overdrive.com/apis/client-auth, which can be
+ # expressed by the following function:
+ #
+ # OAuthFormat = (key, secret) -> CryptoJS.enc.Base64.stringify CryptoJS.enc.Utf8.parse "#{key}:#{secret}"
+ #
+ credentials: '' # Base64 encoded text string
+
+ # Define the credentials to use to get patron authentication, as described in
+ # https://developer.overdrive.com/apis/patron-auth
+ accountID: 4321
+ websiteID: 321
+
+ # Define the mapping function between long name and authorization name
+ authorizationname: (id) -> longname[id]
+
+ # Define whether a user password is required to complete patron authentication
+ password_required: 'false' # or 'true'
+ }
--- /dev/null
+# Define custom jQuery extensions to rewrite content of existing pages
+# None of the extensions directly use the API, but they depend on od_action which does.
+
+define [
+ 'jquery'
+ 'lodash'
+ 'od_api'
+ 'jquery-ui'
+ 'od_action'
+ 'od_pages_opac'
+], ($, _, od) ->
+
+ $.fn.extend
+
+ # Given a map between classnames and numeric values,
+ # eg, { class1: 1, class2: -1 },
+ # increment the existing values of the containers with the classnames.
+ _counters: (x) ->
+ for n, v of x
+ $x = @find ".#{n}"
+ $x.text +($x.text()) + v
+ return @
+
+ _dashboard: (x) ->
+
+ if arguments.length is 0
+
+ # Add a new dashboard for to show counts of e-items; start with
+ # zero counts
+ base = '/eg/opac/myopac'
+ @append """
+ <div id="dashboard">
+ <span class="dash-align">
+ <a class="dash-link" href="#{base}/circs?e_items"><span class="ncheckouts" id="dash_checked">0</span> E-items Checked Out</a>
+ </span>
+ <span class="dash_divider">|</span>
+ <span class="dash-align">
+ <a class="dash-link" href="#{base}/holds?e_items"><span class="nholds" id="dash_holds">0</span> E-items on Hold</a>
+ </span>
+ <span class="dash_divider">|</span>
+ <span class="dash-align">
+ <a class="dash-link" href="#{base}/holds?e_items&available=1"><span class="nholdsready" id="dash_pickup">0</span> E-items Ready for Checkout</a>
+ </span>
+ </div>
+ """
+
+ # The following sequence is necessary to align the new dashboard
+ # with the existing ones, but do not know why it needs to be done
+ @find 'div'
+ .css float: 'none'
+ .end()
+
+ else
+ @_counters x # Change the values of the counters
+ return @
+
+ # Replace account summary area with one that shows links to go to
+ # e-items lists
+ _account_summary: (x) ->
+
+ if arguments.length is 0
+ # Parse a list of totals of physical items from the account summary table
+ totals = ( +(v.textContent.match(/\d+?/)[0]) for v in @find('td').not '[align="right"]' )
+
+ tpl = """
+ <tbody>
+ <tr>
+ <td>
+ <a href="/eg/opac/myopac/circs">
+ <span><span class="ncheckouts" /> Items Currently Checked out</span>
+ </a>
+ </td>
+ <td align="right">
+ <a href="/eg/opac/myopac/circs?e_items"><span class="n_checkouts" /> E-items Currently Checked out</a>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <a href="/eg/opac/myopac/holds"><span class="nholds" /> Items Currently on Hold</a>
+ </td>
+ <td align="right">
+ <a href="/eg/opac/myopac/holds?e_items"><span class="n_holds" /> E-items Currently on Hold</a>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <a href="/eg/opac/myopac/holds?available=1"><span class="nready" /> Items ready for pickup</a>
+ </td>
+ <td align="right">
+ <a href="/eg/opac/myopac/holds?e_items&available=1"><span class="n_ready" /> E-items ready for pickup</a>
+ </td>
+ </tr>
+ </tbody>
+ """
+ @empty().append tpl
+ return totals
+
+ else
+ @_counters x # Change the values of the counters
+
+ # Relabel a history tab
+ _tab_history: ->
+ $x = $('a', @)
+ $x.text "#{ $x.text() } (Physical Items)"
+ return @
+
+ # Add a new tab for e-items and select a tab relevant for the current page name.
+ # If page name contains 'history' then select any tabs with 'history' in its ID
+ # otherwise, if search parameters has 'e_items' property then select any tabs with 'eitems' in its ID
+ #$('#acct_holds_tabs, #acct_checked_tabs')._etabs()
+ _etabs: (page_name, e_items) ->
+
+ # Tab replacement is identified by container's id
+ new_tabs =
+ acct_holds_tabs: """
+ <div id="acct_holds_tabs">
+ <div class="align" id='tab_holds'>
+ <a href="holds#">Items on Hold</a>
+ </div>
+ <div class="align" id='tab_holds_eitems'>
+ <a href="holds?e_items">E-items on Hold</a>
+ </div>
+ <div class="align" id='tab_holds_history'>
+ <a href="hold_history">Holds History</a>
+ </div>
+ </div>
+ """
+ acct_checked_tabs: """
+ <div id="acct_checked_tabs">
+ <div class="align" id='tab_circs'>
+ <a href="circs#">Current Items Checked Out</a>
+ </div>
+ <div class="align" id='tab_circs_eitems'>
+ <a href="circs?e_items">E-items Checked Out</a>
+ </div>
+ <div class="align" id='tab_circs_history'>
+ <a href="circ_history">Check Out History</a>
+ </div>
+ </div>
+ """
+ @replaceWith new_tabs[@prop 'id']
+
+ # Compute the selected tab of the current page name
+ $selected =
+ # if page name ends with '_history', select the tab with id
+ # that ends with '_history'
+ if /_history$/.test page_name
+ $('[id$=_history]')
+ # else if search parameters has 'e_items' property, select the
+ # tab with id that ends with '_eitems'
+ else if e_items
+ $('[id$=_eitems]')
+ # else select the remaining tab
+ else
+ $('[id^=tab_]').not '[id$=_history],[id$=_eitems]'
+
+ $selected.addClass 'selected'
+
+ return @
+
+
+ # Resize columns of a table, either to fixed widths, or to be equal
+ # widths, ie, 100% divided by number of columns.
+ # Also, force width of table to 100%; don't know why this is necessary.
+ _resizeCols: ->
+
+ $table = @find 'table'
+ .css 'width', '100%'
+
+ # Resize to percentage widths given in the argument list
+ if arguments.length > 0
+ $th = $table.find 'th'
+ $td = $table.find 'td'
+ for width, n in arguments
+ $th.eq(n).css 'width', width
+ $td.eq(n).css 'width', width
+
+ # Otherwise, resize to equal widths
+ else
+ ncols = @find('th').length or 1
+ width = "#{100 / ncols}%"
+
+ $table
+ .find 'th'
+ .css 'width', width
+ .end()
+ .find 'td'
+ .css 'width', width
+ .end()
+
+ return @
+
+ # Show a container having a class name from a list of candidate, and hide the rest
+ _show_from: (which, candidates...) ->
+
+ @find(x).hide() for x in candidates
+ @find candidates[which]
+ .show()
+ .end()
+
+ # Replace a title of table with new text
+ _replace_title: (x) ->
+
+ @find '.header_middle span'
+ .eq 0
+ .text x
+ .end()
+
+ # Build an empty table for showing a list of holds
+ _holds_main: ->
+
+ table = """
+ <table cellpadding="0" cellspacing="0" border="0">
+ <thead id="acct_holds_main_header"><tr>
+ <th></th>
+ <th>Title/Author</th>
+ <th>Availability</th>
+ <th>Formats</th>
+ <th>Actions</th>
+ </tr></thead>
+ <tbody id="holds_temp_parent"></tbody>
+ </table>
+ <div class="warning_box">No holds found.</div>
+ """
+ @empty().append table
+ ._resizeCols '15%', '20%', '30%', '20%', '15%'
+
+ # Build <tr> elements for showing a list of holds
+ _holds_rows: (holds) ->
+ return [] unless holds
+
+ tpl = _.template """
+ <tr id="<%= id %>" name="acct_holds_temp" class="acct_holds_temp inactive-hold">
+ <td class="thumbnail"></td>
+ <td>
+ <div class="title" /> by <div class="author" />
+ </td>
+ <td class="availability"></td>
+ <td class="formats"></td>
+ <td class="actions"></td>
+ </tr>
+ """
+
+ ids = []
+ $rows = for hold in holds
+
+ ids.push hold.reserveId
+
+ # Build an empty row element that is uniquely identified by a
+ # product ID
+ $row = $ tpl id: hold.reserveId
+
+ # Fill the row with hold values and proxy the rest of the row
+ # with progress bars
+ $row
+ ._holds_row hold # hold values
+ ._row_meta() # progress bar
+ ._holds_row_avail() # progress bar
+
+ # Add hold rows to <tbody> and remove the warning box.
+ if $rows.length > 0
+ @find 'tbody'
+ .empty().append $rows
+ .end()
+ .find '.warning_box'
+ .remove()
+ .end()
+
+ return ids
+
+ _holds_row: (hold) ->
+
+ @find 'td.availability'
+ ._holds_row_avail1 hold
+ .end()
+ .find 'td.actions'
+ ._actions hold.actions
+ .end()
+
+ # Show a title, author, or format by using the given metadata object
+ _row_meta: (meta, classnames...) ->
+
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find(".#{n}").progressbar(status) for n in ['title', 'author', 'formats']
+
+ return @ unless meta
+
+ $title = $ """
+ <a href="/eg/opac/results?query=#{meta.title};locg=10;qtype=title">#{meta.title}</a>
+ """
+ $thumbnail = $ """
+ <img src="#{od.proxy meta.images?.thumbnail?.href}" alt="#{meta.title}" />
+ """
+ $author = $ """
+ <a href="/eg/opac/results?query=#{meta.author};locg=10;qtype=author">#{meta.author}</a>
+ """
+ for n in classnames
+ $n = @find ".#{n}"
+ switch n
+ when 'thumbnail' then $n.empty().append $thumbnail
+ when 'title' then $n.empty().append $title
+ when 'author' then $n.empty().append $author
+ when 'formats' then $n._show_formats meta
+ return @
+
+ _holds_row_avail1: (hold) ->
+
+ hold_status = if hold.holdSuspension then 0 else if hold.actions.checkout then 1 else 2
+
+ x = if hold.holdSuspension?.suspensionType is 'limited' then 'show' else 'hide'
+
+ tpl = _.template """
+ <div class="suspended">
+ <div style="color: red">Suspended <span class="limited">until <%= activates %></span></div>
+ <ul style="padding-left: 20px">
+ <li name="acct_holds_status"><%= position %> / <%= nHolds %> holds <span class="copies" /></li>
+ <li>Email notification will be sent to <%= email %></li>
+ <li>Hold was placed <%= placed %></li>
+ </ul>
+ </div>
+ <div class="unavailable">
+ <div>Waiting for copy</div>
+ <ul style="padding-left: 20px">
+ <li name="acct_holds_status"><%= position %> / <%= nHolds %> holds <span class="copies" /></li>
+ <li>Email notification will be sent to <%= email %></li>
+ <li>Hold was placed <%= placed %></li>
+ </ul>
+ </div>
+ <div class="available">
+ <div style="color: green">Ready for checkout</div>
+ <ul style="padding-left: 20px">
+ <li name="acct_holds_status"><%= position %> / <%= nHolds %> holds <span class="copies" /></li>
+ <li>Hold will expire <%= expires %></li>
+ </ul>
+ </div>
+ """
+ @empty().append tpl
+ position: hold.holdListPosition
+ nHolds: hold.numberOfHolds
+ email: hold.emailAddress
+ expires: hold.holdExpires.fromNow()
+ placed: hold.holdPlacedDate.fromNow()
+ activates: hold.holdSuspension?.numberOfDays.calendar()
+
+ # Illuminate areas of this row according to the hold status
+ ._show_from hold_status, '.suspended', '.available', '.unavailable'
+ # Show the hold suspension date only if suspension type is limited
+ .find('.limited')[x]()
+ .end()
+
+ # Complete building a <tr> element for showing a hold by using the
+ # given availability object
+ _holds_row_avail: (avail) ->
+
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find '.copies'
+ .progressbar status
+ .end()
+
+ return @ unless avail
+
+ text = """
+ on #{avail.copiesOwned} copies
+ """
+ @find '.copies'
+ .text text
+ .end()
+
+ # Build an empty table for showing a list of checkouts
+ _checkouts_main: ->
+
+ table = """
+ <table cellpadding="0" cellspacing="0" border="0">
+ <thead id="acct_checked_main_header"><tr>
+ <th></th>
+ <th>Title/Author</th>
+ <th>Availability</th>
+ <th>Formats</th>
+ <th>Actions</th>
+ </tr></thead>
+ <tbody id="holds_temp_parent"></tbody>
+ </table>
+ <div class="warning_box">No checkouts found.</div>
+ """
+ @empty().append table
+ ._resizeCols()
+
+ # Build <tr> elements for showing a list of checkouts
+ _checkouts_rows: (circs) ->
+ return [] unless circs
+
+ tpl = _.template """
+ <tr id="<%= id %>" name="acct_checked_temp" class="acct_checked_temp inactive-hold">
+ <td class="thumbnail"></td>
+ <td>
+ <div class="title" /> by <div class="author" />
+ </td>
+ <td class="availability"></td>
+ <td class="formats"></td>
+ <td class="actions"></td>
+ </tr>
+ """
+
+ ids = []
+ $rows = for circ in circs
+
+ ids.push circ.reserveId
+
+ # Build an empty row element that is uniquely identified by a
+ # product ID
+ $row = $ tpl id: circ.reserveId
+
+ # Fill the row with circ values and proxy the rest of the row
+ # with progress bars
+ $row
+ ._row_checkout circ # circ values
+ ._row_meta() # progress bars
+
+ # Add checkout rows to <tbody> and remove the warning box.
+ # <tbody> also has the responsibility of handling format buttons.
+ if $rows.length > 1
+ @find 'tbody'
+ .empty().append $rows
+ ._download_format()
+ .end()
+ .find '.warning_box'
+ .remove()
+ .end()
+
+ return ids
+
+ _row_checkout: (circ) ->
+
+ @find 'td.availability'
+ ._checkouts_row_avail circ
+ .end()
+ .find 'td.actions'
+ ._actions circ.actions
+ .end()
+ .find 'td.formats'
+ ._formats circ.formats
+ .end()
+
+ _checkouts_row_avail: (circ) ->
+
+ tpl = _.template """
+ <div>Expires <%= expires_relatively %></div>
+ <div><%= expires_exactly %></div>
+ """
+ @empty().append tpl
+ expires_relatively: circ.expires.fromNow()
+ expires_exactly: circ.expires.format 'YYYY MMM D, h:mm:ss a'
+
+ # Build a <tr> element to show the available actions of an item.
+ # If the item is available, the check out action should be possible,
+ # and if unavailable, the place hold action should be possible.
+ _holdings_row: (id) ->
+
+ tpl = _.template """
+ <tr id="<%= id %>" name="acct_holds_temp" class="acct_holds_temp inactive-hold">
+ <td class="thumbnail"></td>
+ <td>
+ <div class="title" /> by <div class="author" />
+ </td>
+ <td class="availability"></td>
+ <td class="formats"><ul></ul></td>
+ <td class="actions"></td>
+ </tr>
+ """
+ $row = $(tpl id: id)
+ ._row_meta() # progress bar
+ ._holdings_row_avail() # progress bar
+
+ @find 'tbody'
+ .empty().append $row
+ .end()
+ .find '.warning_box'
+ .remove()
+ .end()
+
+ # Complete building a <tr> element for a holding using the given availability object
+ _holdings_row_avail: (avail) ->
+
+ # Create or destroy progress bars
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find 'td.availability'
+ .progressbar status
+ .end()
+ .find 'td.actions'
+ .progressbar status
+ .end()
+
+ return @ unless avail
+
+ tpl = _.template """
+ <div class="unavailable">No copies are available for checkout</div>
+ <div class="available" style="color: green">A copy is available for checkout</div>
+ <div><%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds</div>
+ """
+ @find 'td.availability'
+ .append tpl
+ n_owned: avail.copiesOwned
+ n_avail: avail.copiesAvailable
+ n_holds: avail.numberOfHolds
+ .end()
+
+ # Build action buttons
+ .find 'td.actions'
+ ._actions avail.actions
+ .end()
+
+ # Illuminate areas of this row according to the holdings status
+ ._show_from (if avail.available then 0 else 1), '.available', '.unavailable'
+
--- /dev/null
+# Define custom jQuery extensions to rewrite content of existing pages
+# None of the extensions directly use the API, but they depend on od_action which does.
+
+define [
+ 'jquery'
+ 'lodash'
+ 'jquery-ui'
+], ($, _) ->
+
+ $.fn.extend
+
+ # Append a list of formats from a metadata object to this container
+ _show_formats: (x) ->
+ return @ unless x
+
+ $x =
+ if x.formats
+ $('<ul>')
+ .css 'padding-left', '20px'
+ .append _.map x.formats, (f) -> $('<li>').append f.name
+ else
+ $('<span>')
+ .css 'color', 'red'
+ .text 'No available formats'
+
+ @append $x
+
+
+ # Return an Overdrive product ID or null from a given DOM context.
+ # The context can be represented as a jQuery object or as a selector string
+ _productID: ->
+ href = $('a[href*="downloads.bclibrary.ca"], a[href*="elm.lib.overdrive.com"]', @).attr('href')
+ /ID=(.+)/.exec(href)?[1]
+
+ # Modify search result row to show e-holdings (available formats and
+ # copy status of an e-title) in a result_holdings_table
+ _results: ->
+ result = """
+ <tr name="e_holdings" class="result_table_title_cell">
+ <td colspan="2">
+ <table class="result_holdings_table">
+ <thead>
+ <tr>
+ <th>Available Formats</th>
+ <th>Status</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td class="formats"></td>
+ <td class="status"></td>
+ </tr>
+ </tbody>
+ </table>
+ </td>
+ </tr>
+ """
+ # For each result row that has an embedded Overdrive product ID
+ ids = []
+ for row in @ when id = $(row)._productID()
+
+ # Cache the ID so that we don't need to traverse the DOM again
+ ids.push id
+
+ # Adorn each row with a product ID
+ $(row).prop 'id', id
+ # Add an empty container of format and availability values
+ .find '.results_info_table > tbody'
+ .append result
+ .end()
+ # Set up progress bars
+ ._results_meta()
+ ._results_avail()
+
+ return ids
+
+ _results_meta: (meta) ->
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find('.result_holdings_table .formats')
+ .progressbar status
+ ._show_formats meta
+ .end()
+
+ _results_avail: (avail) ->
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find('.result_holdings_table .status')
+ .progressbar status
+ .end()
+
+ return @ unless avail
+
+ $x =
+ if avail.available is undefined
+ $('<span>')
+ .css 'color', 'red'
+ .text 'No longer available'
+ else
+ tpl = _.template """
+ <span><%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds</span>
+ """
+ $ tpl
+ n_avail: avail.copiesAvailable
+ n_owned: avail.copiesOwned
+ n_holds: avail.numberOfHolds
+
+ @find('.result_holdings_table .status')
+ .append $x
+ .end()
+
+ _record: ->
+
+ # Find the product ID of this record
+ id = @_productID()
+ return unless id
+
+ $record = $ """
+ <div id="copy_hold_counts"><div id="#{id}">
+ <span id="rdetail_copy_counts">
+ <h2>Available formats</h2>
+ <div class="formats"></div>
+ </span>
+ <span id="rdetail_hold_counts">
+ <h2>Status</h2>
+ <div class="status"></div>
+ </span>
+ </div></div>
+ """
+
+ $record
+ ._record_meta()
+ ._record_avail()
+
+ @after $record
+ return id
+
+ _record_meta: (meta) ->
+
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find '.formats'
+ .progressbar status
+ ._show_formats meta
+ .end()
+
+ _record_avail: (avail) ->
+
+ status = if arguments.length is 0 then value: false else 'destroy'
+ @find '.status'
+ .progressbar status
+ .end()
+
+ return @ unless avail
+
+ $x =
+ if avail.available is undefined
+ $('<span>')
+ .css 'color', 'red'
+ .text 'No longer available'
+ else
+ tpl = _.template """
+ <span><%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds</span>
+ """
+ $ tpl
+ n_avail: avail.copiesAvailable
+ n_owned: avail.copiesOwned
+ n_holds: avail.numberOfHolds
+
+ @find '.status'
+ .append $x
+ .end()
+
+ # Replace a place hold link with another link that is more relevant to
+ # the availability of the title in a row context.
+ _replace_place_hold_link: (avail, type_of_interest) ->
+ return @ unless avail
+
+ if avail.available is undefined
+ @find '.place_hold'
+ .remove()
+ .end()
+
+ # Find the place hold link that we want to replace
+ $a = @find '.place_hold > a'
+
+ # Parse the existing link text string for an indication of the item
+ # format
+ item_format = (text = $a.text()).match(/E-book/) or text.match(/E-audiobook/) or 'E-item'
+ # Parse the existing link title for an item title, or if absent,
+ # default to item format
+ item_title = $a.prop('title').match(/on (.+)/)?[1] or item_format
+
+ # Calculate the new text, title, and href properties, depending on
+ # whether the user has an interest on the item and whether the item
+ # is available or not
+ [text, title, href] = switch type_of_interest
+
+ # If the user has already placed a hold on the item,
+ # we modify the link to go to the holds list
+ when 'hold'
+ [
+ 'Go to<br>E-items On Hold'
+ 'Go to E-items On Hold'
+ '/eg/opac/myopac/holds?e_items'
+ ]
+
+ # If the user has already checked out the item,
+ # we modify the link to go to the checkout list
+ when 'checkout'
+ [
+ 'Go to<br>E-items Checked Out'
+ 'Go to E-items Checked Out'
+ '/eg/opac/myopac/circs?e_items'
+ ]
+
+ # If the user has no prior interest in the item, we modify the
+ # link to go to the place hold form with the relevant query
+ # parameters.
+ else
+ params = $.param
+ e_items: 1
+ interested: avail.id
+
+ # The new text depends on avail.available and on the format, eg,
+ # Place Hold on E-book
+ # Place Hold on E-audiobook
+ # Check Out E-book
+ # Check Out E-audiobook
+ verb = if avail.available then 'Check Out' else 'Place Hold on'
+ url = if avail.available then '/eg/opac/myopac/circs' else '/eg/opac/myopac/holds'
+ [
+ "#{verb}<br>#{item_format}"
+ "#{verb} #{item_title}"
+ "#{url}?#{params}"
+ ]
+
+ # Replacing the link means we have to do three things.
+ $a
+ # Change the title and href properties of the link
+ .prop
+ title: title
+ href: href
+ # Change the alt property of the link's image, which we equate to
+ # the link's title property
+ .find 'img'
+ .prop 'alt', title
+ .end()
+ # Change the contents of the text label, which resides in a
+ # container with two possible class names, depending on whether we
+ # are on the results page or the record page
+ .find '.result_place_hold, .place_hold'
+ .replaceWith text
+ .end()
+
+ return @
--- /dev/null
+# TODO memory leaks
+#
+# TODO Author/Title links could specify ebook filter
+#
+# TODO If logged in, could bypass place hold page and use action dialogue directly
+#
+# TODO Simple, cheap two-way data binding:
+# We could publish a partial request object as an abstract way of making
+# an API request, ie, od.$.triggerHandler 'od.metadata', id: id
+# Subscribe to same event to receive reply object, ie,
+# od.$.on 'od.metadata', (ev, reply) -> # do something with reply
+
+require.config
+
+ paths:
+ jquery: 'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min'
+ 'jquery-ui': 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.1/jquery-ui.min'
+ lodash: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min'
+ moment: 'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.5.1/moment.min'
+ cookies: 'https://cdnjs.cloudflare.com/ajax/libs/Cookies.js/0.3.1/cookies.min'
+ json: 'https://cdnjs.cloudflare.com/ajax/libs/json3/3.3.0/json3.min'
+
+ waitSeconds: 120
+
+require [
+ 'jquery'
+ 'lodash'
+ 'cookies'
+ 'od_api'
+ 'od_pages_opac'
+ 'od_pages_myopac'
+ 'od_action'
+], ($, _, C, od) ->
+
+ # Indicate the logged in status; the value is determined within document
+ # ready handler.
+ logged_in = false
+
+ # Various debugging functions; not used in production
+ log_page = -> console.log window.location.pathname
+ notify = (what) -> console.log "#{what} is in progress"
+ failed = (what) -> console.log "#{what} failed"
+ reload_page = -> window.location.reload true
+ replace_page = (href) -> window.location.replace href
+
+ # Query a search string of the current page for the value or existence of a
+ # property
+ search_params = (p) ->
+ # Convert for example, '?a=1&b=2' to { a:1, b:2 },
+ o =
+ if xs = (decodeURI window.location.search)?.split('?')?[1]?.split('&')
+ _.zipObject( x.split('=') for x in xs )
+ else
+ {}
+ # Return either the value of a specific property, whether the property
+ # exists, or the whole object
+ if arguments.length is 1 then o[p] or o.hasOwnProperty p else o
+
+
+ # Return an abbreviation of the pathname of the current page,
+ # eg, if window.location.pathname equals 'eg/opac/record' or
+ # 'eg/opac/record/123', then return 'record', otherwise return ''
+ page_name = ->
+ xs = window.location.pathname.match /eg\/opac\/(.+)/
+ if xs then xs[1].replace /\/\d+/, '' else ''
+
+ # Make a map from an item ID to a status indicating whether it is on the
+ # holds list or the checkout list
+ # eg, var status = ids(holds, checkouts)[id]
+ item_status = (holds, checkouts) ->
+ ids = {}
+ ids[v.reserveId] = 'hold' for v, n in holds
+ ids[v.reserveId] = 'checkout' for v, n in checkouts
+ return ids
+
+ # Routing table: map an URL pattern to a handler that will perform actions
+ # or modify areas on the screen.
+ routes =
+
+ # Scan through property names and execute the function value if the
+ # name pattern matches against the window.location.pathname, eg,
+ # routes.handle(). handle() does not try to execute itself. Returns a
+ # list of results for each handler that was executed. A result is
+ # undefined if no subscriptions to an OD service was needed.
+ handle: (p = window.location.pathname) ->
+ for n, v of routes when n isnt 'handle'
+ v() if (new RegExp n).test p
+
+ 'eg\/opac': ->
+
+ # Add a new dashboard to show total counts of e-items.
+ # Start the dashboard w/ zero counts.
+ $dash = $('#dash_wrapper')._dashboard()
+
+ od.$.on
+
+ # Set the dashboard counts to summarize the patron's account
+ 'od.interests': (ev, x) -> $dash._dashboard
+ ncheckouts: x.nCheckouts
+ nholds: x.nHolds
+ nholdsready: x.nHoldsReady
+
+ # Decrement the dashboard counts because an item has been
+ # removed from the holds or checkout list
+ 'od.hold.delete': -> $dash._dashboard nholds: -1
+ 'od.checkout.delete': -> $dash._dashboard ncheckouts: -1
+
+ # Log out of EG if we are logged in and if an OD patron access
+ # token seems to have expired
+ 'od.logout': (ev, x) ->
+ if x is 'od'
+ $('#logout_link').trigger 'click' if logged_in
+
+ 'opac\/myopac': ( this_page = page_name() ) ->
+
+ # Add a new tab for e-items to the current page if it is showing a
+ # system of tabs
+ $('#acct_holds_tabs, #acct_checked_tabs')._etabs this_page, search_params 'e_items'
+ # Relabel history tabs if they are showing on current page
+ $('#tab_holds_history, #tab_circs_history')._tab_history()
+ return
+
+ 'opac\/home': ->
+
+ # Signal that EG may have logged out
+ od.$.triggerHandler 'od.logout', 'eg' unless logged_in
+
+ 'opac\/login': ->
+
+ # On submitting the login form, we initiate the login sequence with the
+ # username/password from the login form
+ $('form', '#login-form-box').one 'submit', ->
+ od.login
+ username: $('[name=username]').val()
+ password: $('[name=password]').val()
+
+
+ # TODO In order to perform OD login after EG login, we could
+ # automatically get the prefs page and scrape the barcode value,
+ # but in the general case, we would also need the password value
+ # that was previously submitted on the login page.
+
+ # We could scrape the barcode value from the prefs page by having it
+ # being parsed into DOM within an iframe (using an inscrutable sequence
+ # of DOM traversal). Unfortunately, it will reload script tags and
+ # make XHR calls unnecessarily.
+ #
+ # The alternative is to GET the prefs page and parse the HTML string
+ # directly for the barcode value, but admittedly, we need to use an
+ # inscrutable regex pattern.
+
+ # On the myopac account summary area, add links to hold list and
+ # checkout list of e-items
+ 'myopac\/main': ( $table = $('.acct_sum_table') ) ->
+ return unless $table.length
+
+ totals = $table._account_summary()
+
+ od.$.on 'od.interests', (ev, x) ->
+
+ $table._account_summary
+ ncheckouts: totals[0]
+ nholds: totals[1]
+ nready: totals[2]
+ n_checkouts: x.nCheckouts
+ n_holds: x.nHolds
+ n_ready: x.nHoldsReady
+
+ # Each time the patron's preferences page is shown, publish values that
+ # might have changed because the patron has edited them. Example
+ # scenario: patron changes email address on the prefs page and then
+ # places hold, expecting the place hold form to default to the newer
+ # address.
+ 'myopac\/prefs': ->
+ $tr = $('#myopac_summary_tbody > tr')
+ em = $tr.eq(6).find('td').eq(1).text()
+ bc = $tr.eq(7).find('td').eq(1).text()
+ hl = $tr.eq(8).find('td').eq(1).text()
+ od.$.triggerHandler 'od.prefs', email_address:em, barcode:bc, home_library:hl
+
+ 'opac\/results': (interested = {}) ->
+
+ # List of hrefs which correspond to Overdrive e-items
+ # TODO this list is duplicated in module od_pages_opac
+ hrefs = [
+ 'a[href*="downloads.bclibrary.ca"]' # Used in OPAC
+ 'a[href*="elm.lib.overdrive.com"]' # Used in XUL staff client
+ ]
+
+ # Prepare each row of the results table which has an embedded
+ # Overdrive product ID. A list of Overdrive product IDs is
+ # returned, which can be used to find each row directly.
+ ids = $(hrefs.join ',').closest('.result_table_row')._results()
+ return if ids?.length is 0
+
+ od.$.on
+
+ # When patron holds and checkouts become available...
+ 'od.interests': (ev, x) ->
+
+ # Initiate request for each Overdrive product ID
+ for id in ids
+ od.apiMetadata id: id
+ od.apiAvailability id: id
+
+ # Cache the relationship between product IDs and patron
+ # holds and checkouts, ie, has the patron placed a hold on
+ # an ID or checked out an ID?
+ interested = x.byID
+
+ # Fill in format values when they become available
+ 'od.metadata': (ev, x) -> $("##{x.id}")._results_meta x
+
+ # Fill in availability values when they become available
+ 'od.availability': (ev, x) ->
+ $("##{x.id}")
+ ._results_avail x
+ ._replace_place_hold_link x, interested[x.id]?.type
+
+ 'opac\/record': (interested = {}) ->
+
+ # Add an empty container of format and availability values
+ return unless id = $('div.rdetail_uris')._record()
+
+ od.$.on
+
+ # When patron holds and checkouts become available...
+ 'od.interests': (ev, x) ->
+
+ # Initiate request for metadata and availability values when
+ od.apiMetadata id: id
+ od.apiAvailability id: id
+
+ # Has the user placed a hold on an ID or checked out an ID?
+ interested = x.byID
+
+ # Fill in format values when they become available
+ 'od.metadata': (ev, x) -> $("##{x.id}")._record_meta x
+
+ # Fill in availability values when they become available
+ 'od.availability': (ev, x) ->
+ $("##{x.id}")._record_avail x
+ $('#rdetail_actions_div')._replace_place_hold_link x, interested[x.id]?.type
+
+ # For the case where the patron is trying to place a hold if not logged
+ # in, there is a loophole in the Availability API; if using a patron
+ # access token and patron already has a hold on it, avail.actions.hold
+ # will still be present, falsely indicating that patron may place a
+ # hold, which will lead to a server error. The same situation will
+ # occur if patron has already checked out. It seems the OD server does
+ # not check the status of the item wrt the patron before generating the
+ # server response.
+ #
+ # To fix the problem, we will check if avail.id is already held or
+ # checked out, and if so, then go back history two pages so that
+ # original result list or record page is shown, with the proper action
+ # link generated when the page reloads.
+
+ # Replace the original Place Hold form with a table row to show
+ # available actions, either 'Check out' or 'Place hold', depending on
+ # whether the item is available or not, respectively.
+ #
+ # The following page handler does not replace the place_hold page, but
+ # is meant to be called by the place hold link.
+ # If the place_hold page is encountered, the handler will return
+ # without doing anything, because no id is passed in.
+ 'opac\/place_hold': (id, interested = {}) ->
+ return unless id
+
+ $('#myopac_holds_div')._replace_title 'Place E-Item on Hold'
+ $('#myopac_checked_div')._replace_title 'Check out E-Item'
+
+ $('#holds_main, #checked_main, .warning_box').remove()
+
+ $div = $('<div id="#holds_main">')
+ ._holds_main() # Add an empty table
+ ._holdings_row id # Add an empty row
+ .appendTo $('#myopac_holds_div, #myopac_checked_div')
+
+ # Fill in metadata values when they become available
+ od.$.on
+
+ 'od.interests': (ev, x) ->
+ od.apiMetadata id: id
+ od.apiAvailability id: id
+ # Has the user placed a hold on an ID or checked out an ID?
+ interested = x.byID
+
+ 'od.metadata': (ev, x) ->
+ $("##{x.id}")._row_meta x, 'thumbnail', 'title', 'author', 'formats'
+
+ 'od.availability': (ev, x) ->
+ # Check if this patron has checked out or placed a hold on
+ # avail.id and if so, then go back two pages to the result list
+ # or record page. The page being skipped over is the login page
+ # that comes up because the user needs to log in before being
+ # able to see the place hold page. Thus, the logic is only
+ # relevant if the user has not logged in before trying to place
+ # a hold.
+ if interested[x.id]?.type
+ window.history.go -2
+ else
+ $("##{x.id}")._holdings_row_avail x
+
+ 'myopac\/holds': ->
+
+ # If we arrive here with an interested ID value, we are intending
+ # to place a hold on an e-item
+ if id = search_params 'interested'
+ return routes['opac\/place_hold'] id
+
+ # Rewrite the text in the warning box to distinguish physical items from e-items
+ unless search_params 'e_items'
+ $('.warning_box').text $('.warning_box').text().replace ' holds', ' physical holds'
+ return
+
+ return unless ($holds_div = $('#myopac_holds_div')).length
+
+ $holds_div._replace_title 'Current E-Items on Hold'
+
+ $('#holds_main, .warning_box').remove()
+
+ # Replace with an empty table for a list of holds for e-items
+ $div = $('<div id="#holds_main">')
+ ._holds_main()
+ .appendTo $holds_div
+
+ # Subscribe to notifications of relevant data objects
+ od.$.on
+
+ 'od.interests': (ev, x) ->
+
+ # Focus on patron's hold interests, and if the search
+ # parameters say so, further focus on holds of items that
+ # are ready to be checked out
+ holds = x?.ofHolds
+ holds = _.filter(holds, (x) -> x.actions.checkout) if search_params 'available'
+
+ # Add an empty list of holds
+ ids = $div._holds_rows holds
+
+ # Try to get the metadata and availability values for
+ # this hold
+ for id in ids
+ od.apiMetadata id: id
+ od.apiAvailability id: id
+
+ # Add metadata values to a hold
+ 'od.metadata': (ev, x) -> $("##{x.id}")._row_meta x, 'thumbnail', 'title', 'author', 'formats'
+ # Add availability values to a hold
+ 'od.availability': (ev, x) -> $("##{x.id}")._holds_row_avail x
+
+ 'od.hold.update': (ev, x) -> $("##{x.reserveId}")._holds_row x
+ 'od.hold.delete': (ev, x) -> $("##{x.reserveId}").remove()
+
+ 'myopac\/circs': ->
+
+ # If we arrive here with an interested ID value, we are intending
+ # to checking out an e-item
+ if id = search_params 'interested'
+ return routes['opac\/place_hold'] id
+
+ # Rewrite the text in the warning box to distinguish physical items from e-items
+ unless search_params 'e_items'
+ $('.warning_box').text $('.warning_box').text().replace ' items', ' physical items'
+ return
+
+ return unless ($checked_div = $('#myopac_checked_div')).length
+
+ $checked_div._replace_title 'Current E-Items Checked Out'
+
+ $('#checked_main, .warning_box').remove()
+
+ # Build an empty table for a list of checkouts of e-items
+ $div = $('<div id="#checked_main">')
+ ._checkouts_main()
+ .appendTo $checked_div
+
+ # Subscribe to notifications of relevant data objects
+ od.$.on
+
+ 'od.interests': (ev, x) ->
+
+ # Fill in checkout list
+ ids = $div._checkouts_rows x?.ofCheckouts
+
+ # Try to get metadata values for these checkouts
+ od.apiMetadata id: id for id in ids
+
+ # Add metadata values to a checkout
+ 'od.metadata': (ev, x) -> $("##{x.id}")._row_meta x, 'thumbnail', 'title', 'author'
+
+ 'od.checkout.update': (ev, x) -> $("##{x.reserveId}")._row_checkout x
+ 'od.checkout.delete': (ev, x) -> $("##{x.reserveId}").remove()
+
+ # Begin sequence after the DOM is ready...
+ $ ->
+
+ return if window.IAMXUL # Comment out to run inside XUL staff client
+
+ # We are logged into EG if indicated by a cookie or if running
+ # inside XUL staff client.
+ logged_in = Boolean C('eg_loggedin') or window.IAMXUL
+
+ # Dispatch handlers corresponding to the current location
+ # and return immediately if none of them require OD services
+ return if _.every routes.handle() , (r) -> r is undefined
+
+ # Try to get library account info
+ od.apiAccount()
+
+ # If we are logged in, we 'compute' the patron's interests in product
+ # IDs; otherwise, we set patron interests to an empty object.
+ .then ->
+
+ # If logged in, ensure that we have a patron access token from OD
+ # before getting patron's 'interests'
+ if logged_in
+ od.login().then od.apiInterestsGet
+
+ # Otherwise, return no interests
+ # TODO should do the following in od_api module
+ else
+ interests = byID: {}
+ od.$.triggerHandler 'od.interests', interests
+ return interests
+
+ return
+ return