Initial commit
authorSteven Chan <steven3416@gmail.com>
Wed, 27 Aug 2014 20:51:18 +0000 (13:51 -0700)
committerSteven Chan <steven3416@gmail.com>
Wed, 27 Aug 2014 20:51:18 +0000 (13:51 -0700)
Signed-off-by: Steven Chan <steven3416@gmail.com>
README.txt [new file with mode: 0644]
build.js [new file with mode: 0644]
src/od_action.coffee [new file with mode: 0644]
src/od_api.coffee [new file with mode: 0644]
src/od_config_template.coffee [new file with mode: 0644]
src/od_pages_myopac.coffee [new file with mode: 0644]
src/od_pages_opac.coffee [new file with mode: 0644]
src/overdrive.coffee [new file with mode: 0644]

diff --git a/README.txt b/README.txt
new file mode 100644 (file)
index 0000000..8927d6e
--- /dev/null
@@ -0,0 +1,50 @@
+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. 
diff --git a/build.js b/build.js
new file mode 100644 (file)
index 0000000..6607a9a
--- /dev/null
+++ b/build.js
@@ -0,0 +1,25 @@
+// 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:'
+       }
+})
diff --git a/src/od_action.coffee b/src/od_action.coffee
new file mode 100644 (file)
index 0000000..e4bcada
--- /dev/null
@@ -0,0 +1,502 @@
+# 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()
diff --git a/src/od_api.coffee b/src/od_api.coffee
new file mode 100644 (file)
index 0000000..59ebf6a
--- /dev/null
@@ -0,0 +1,641 @@
+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
diff --git a/src/od_config_template.coffee b/src/od_config_template.coffee
new file mode 100644 (file)
index 0000000..03a3cd5
--- /dev/null
@@ -0,0 +1,44 @@
+# 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'
+       }
diff --git a/src/od_pages_myopac.coffee b/src/od_pages_myopac.coffee
new file mode 100644 (file)
index 0000000..8526812
--- /dev/null
@@ -0,0 +1,514 @@
+# 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'
+
diff --git a/src/od_pages_opac.coffee b/src/od_pages_opac.coffee
new file mode 100644 (file)
index 0000000..9ced00d
--- /dev/null
@@ -0,0 +1,253 @@
+# 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 @
diff --git a/src/overdrive.coffee b/src/overdrive.coffee
new file mode 100644 (file)
index 0000000..bb341e7
--- /dev/null
@@ -0,0 +1,429 @@
+# 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