From 8ebc3fffe7cfb346396262d47630317f1508fecc Mon Sep 17 00:00:00 2001 From: Steven Chan Date: Wed, 27 Aug 2014 13:51:18 -0700 Subject: [PATCH 1/1] Initial commit Signed-off-by: Steven Chan --- README.txt | 50 ++++ build.js | 25 ++ src/od_action.coffee | 502 +++++++++++++++++++++++++++++++++ src/od_api.coffee | 641 ++++++++++++++++++++++++++++++++++++++++++ src/od_config_template.coffee | 44 +++ src/od_pages_myopac.coffee | 514 +++++++++++++++++++++++++++++++++ src/od_pages_opac.coffee | 253 +++++++++++++++++ src/overdrive.coffee | 429 ++++++++++++++++++++++++++++ 8 files changed, 2458 insertions(+) create mode 100644 README.txt create mode 100644 build.js create mode 100644 src/od_action.coffee create mode 100644 src/od_api.coffee create mode 100644 src/od_config_template.coffee create mode 100644 src/od_pages_myopac.coffee create mode 100644 src/od_pages_opac.coffee create mode 100644 src/overdrive.coffee diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..8927d6e --- /dev/null +++ b/README.txt @@ -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 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 index 0000000..e4bcada --- /dev/null +++ b/src/od_action.coffee @@ -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: $('
').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: $('
')._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: $('
')._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: $('
')._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: $('
')._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 """ + + """ + + $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 """ + + """ + + # 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() + $('
')['_' + ev.data._name] ev.data + # TODO apply dialogAction method directly as follows. + #$('
').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) -> + + $('
') + ._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 = $ """ + + """ + @append $input.hide() + + # Build a hidden input + _action_field_hidden: (field) -> + + return @ unless field + + @append """ + + """ + + # Build an email input + _action_field_email: (field) -> + + return @ unless field + + $input = $ """ +
+ You will be notified by email when a copy becomes available +
+
+ +
+ """ + $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 """ +
Only one #{field.name} is available and it has been selected for you
+ """ + when Boolean field.optional then """ +
You may select a #{field.name} at this time
+ """ + else """ +
Please select one of the available #{field.name}s
+ """ + inputs = for v in field.options + $x = $ """ +
+ #{od.labels v} +
+ """ + $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 = $ """ +
+ """ + $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 index 0000000..59ebf6a --- /dev/null +++ b/src/od_api.coffee @@ -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]] + #$('
')._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>", '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 index 0000000..03a3cd5 --- /dev/null +++ b/src/od_config_template.coffee @@ -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 index 0000000..8526812 --- /dev/null +++ b/src/od_pages_myopac.coffee @@ -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 """ + + """ + + # 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 = """ + + + + + Items Currently Checked out + + + + E-items Currently Checked out + + + + + Items Currently on Hold + + + E-items Currently on Hold + + + + + Items ready for pickup + + + E-items ready for pickup + + + + """ + @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: """ + + """ + acct_checked_tabs: """ + + """ + @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 = """ + + + + + + + + + +
Title/AuthorAvailabilityFormatsActions
+
No holds found.
+ """ + @empty().append table + ._resizeCols '15%', '20%', '30%', '20%', '15%' + + # Build elements for showing a list of holds + _holds_rows: (holds) -> + return [] unless holds + + tpl = _.template """ + + + +
by
+ + + + + + """ + + 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 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 = $ """ + #{meta.title} + """ + $thumbnail = $ """ + #{meta.title} + """ + $author = $ """ + #{meta.author} + """ + 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 """ +
+
Suspended until <%= activates %>
+
    +
  • <%= position %> / <%= nHolds %> holds
  • +
  • Email notification will be sent to <%= email %>
  • +
  • Hold was placed <%= placed %>
  • +
+
+
+
Waiting for copy
+
    +
  • <%= position %> / <%= nHolds %> holds
  • +
  • Email notification will be sent to <%= email %>
  • +
  • Hold was placed <%= placed %>
  • +
+
+
+
Ready for checkout
+
    +
  • <%= position %> / <%= nHolds %> holds
  • +
  • Hold will expire <%= expires %>
  • +
+
+ """ + @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 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 = """ + + + + + + + + + +
Title/AuthorAvailabilityFormatsActions
+
No checkouts found.
+ """ + @empty().append table + ._resizeCols() + + # Build elements for showing a list of checkouts + _checkouts_rows: (circs) -> + return [] unless circs + + tpl = _.template """ + + + +
by
+ + + + + + """ + + 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 and remove the warning box. + # 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 """ +
Expires <%= expires_relatively %>
+
<%= expires_exactly %>
+ """ + @empty().append tpl + expires_relatively: circ.expires.fromNow() + expires_exactly: circ.expires.format 'YYYY MMM D, h:mm:ss a' + + # Build a 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 """ + + + +
by
+ + +
    + + + """ + $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 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 """ +
    No copies are available for checkout
    +
    A copy is available for checkout
    +
    <%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds
    + """ + @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 index 0000000..9ced00d --- /dev/null +++ b/src/od_pages_opac.coffee @@ -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 + $('
      ') + .css 'padding-left', '20px' + .append _.map x.formats, (f) -> $('
    • ').append f.name + else + $('') + .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 = """ + + + + + + + + + + + + + + + +
      Available FormatsStatus
      + + + """ + # 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 + $('') + .css 'color', 'red' + .text 'No longer available' + else + tpl = _.template """ + <%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds + """ + $ 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 = $ """ +
      + +

      Available formats

      +
      +
      + +

      Status

      +
      +
      +
      + """ + + $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 + $('') + .css 'color', 'red' + .text 'No longer available' + else + tpl = _.template """ + <%= n_avail %> of <%= n_owned %> available, <%= n_holds %> holds + """ + $ 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
      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
      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}
      #{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 index 0000000..bb341e7 --- /dev/null +++ b/src/overdrive.coffee @@ -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 = $('
      ') + ._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 = $('
      ') + ._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 = $('
      ') + ._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 -- 2.11.0