Source: adapters/openlibrary-autocomplete-adapter.js

/* global $, AC, libreviews */
'use strict';

// Internal deps
const AbstractAutocompleteAdapter = require('./abstract-autocomplete-adapter');

/**
 * Perform book metadata lookups on openlibrary.org. Like other frontend
 * adapters, it is shallow and does not care for language, authorship,
 * or other details.
 *
 * @extends AbstractAutocompleteAdapter
 */
class OpenLibraryAutocompleteAdapter extends AbstractAutocompleteAdapter {

  /**
   * See {@link AbstractAutocompleteAdapter} for parameter documentation, not
   * shown here due to [jsdoc bug](https://github.com/jsdoc3/jsdoc/issues/1012).
   *
   * @inheritdoc
   */
  constructor(updateCallback, searchBoxSelector) {
    super(updateCallback, searchBoxSelector);

    // Standard adapter settings
    this.sourceID = 'openlibrary';
    this.supportedPattern = new RegExp('^https*://openlibrary.org/(works|books)/(OL[^/.]+)(?:/(?:.*))*$', 'i');

    /**
     * How many results to get per query. This is pretty low since a result
     * takes up a fair amount of space in the UI, esp. on mobile.
     *
     * @type {Number}
     */
    this.limit = 6;
  }


  /**
   * Obtain data from Open Library for a given URL.
   *
   * @param {String} url
   *  URL to an Open Library book or work
   * @returns {Promise}
   *  resolves to a {@link LookupResult} if successful, rejects with an error
   *  if not
   */
  lookup(url) {
    return new Promise((resolve, reject) => {
      let m = url.match(this.supportedPattern);
      if (m === null)
        return reject(new Error('URL does not appear to reference an Open Library work or edition.'));

      // Open Library distinguishes works and editions. Editions contain
      // significantly more metadata and are generally preferred. We cannot
      // guess the edition, however -- even if only on 1 exists in Open Library,
      // others may exist in the world.
      let isEdition = m[1] == 'books';

      // The string at the end of the original URL must be strpiped off for
      // obtaining the JSON representation.
      let jsonURL = isEdition ? `https://openlibrary.org/books/${m[2]}.json` :
        `https://openlibrary.org/works/${m[2]}.json`;

      $.get(jsonURL)
        .done(data => {
          // We need at least a label to work with
          if (typeof data !== 'object' || !data.title)
            return reject(new Error('Result from Open Library did not include a work or edition title.'));

          let label = data.title,
            subtitle = data.subtitle;
          resolve({
            data: {
              label,
              subtitle
            },
            sourceID: this.sourceID
          });
        })
        .fail(reject);
    });
  }


  /**
   * Merge results from two Open Library search queries into a single array.
   * The overall size will not exceed `this.limit`.
   *
   * @param {array} results - set of results from two or more search queries
   * @returns {array} de-duplicated result array
   */
  mergeResults(results) {
    let data = {
      docs: []
    };
    let knownKeys = [];

    for (let result of results) {
      if (typeof result == 'object' && result.docs && result.docs.length) {
        for (let doc of result.docs) {
          if (!knownKeys.includes(doc.key) && data.docs.length < this.limit) {
            data.docs.push(doc);
            knownKeys.push(doc.key);
          }
        }
      }
    }
    return data;
  }

  /**
   * Perform an in-place sort on results from a search query, putting any
   * exact matches against the query first
   *
   * @param {Array} docs - "docs" array from the Open Library JSON search API
   * @param {String} query - original query text that yielded this result
   */
  sortMatches(docs, query) {
    let hasExact = str => str.toUpperCase().indexOf(query.toUpperCase()) != -1;
    docs.sort((a, b) => {
      if (typeof a != 'object' || typeof b != 'object' ||
        a.title_suggest === undefined || b.title_suggest === undefined)
        return 0;

      if (hasExact(a.title_suggest) && !hasExact(b.title_suggest))
        return -1;
      else if (!hasExact(a.title_suggest) && hasExact(b.title_suggest))
        return 1;
      else
        return 0;
    });
  }


  /**
   * Render callback for the autocomplete widget. For each result, we show
   * authorship and edition information, which goes a bit beyond what we
   * could cram into the default rendering.
   *
   * @param {Object} row
   *  row data object as obtained via
   *  {@link OpenLibraryAutocompleteAdapter#_requestHandler}
   * @returns {Element}
   *  the element to insert into the DOM for this row
   * @this OpenLibraryAutocompleteAdapter#ac
   */
  _renderRowHandler(row) {
    // Row-level CSS gets added by library
    let $el = $('<div>');
    $('<span>')
      .addClass(this.getCSS('PRIMARY_SPAN'))
      .append($(AC.createMatchTextEls(this.value,
        row[this.primaryTextKey])))
      .appendTo($el);

    let description = '';
    let hasAuthor = row.authors && row.authors.length;
    if (hasAuthor) {
      let authorList = row.authors.join(', ');
      description += `<b>${authorList}</b>`;
    }

    let edNum = row.publishers ? row.publishers.length : 0;

    if (edNum) {
      if (hasAuthor)
        description += '<br>';

      let yearStr;
      if (row.years && row.years.length) {
        let minYear = Reflect.apply(Math.min, this, row.years);
        let maxYear = Reflect.apply(Math.max, this, row.years);
        // Different languages may express ranges differently, use different
        // whitespace, etc., so the message is substituted into the main
        // message.
        yearStr = minYear == maxYear ?
          libreviews.msg('single year', { stringParam: minYear }) :
          libreviews.msg('year range', { numberParams: [minYear, maxYear] });
      } else
        yearStr = libreviews.msg('single year', { stringParam: libreviews.msg('unknown year') });

      if (edNum == 1)
        description += libreviews.msg('one edition', { stringParam: yearStr });
      else
        // Pass along number of editions
        description += libreviews.msg('multiple editions', { numberParam: edNum, stringParam: yearStr });
    }

    if (description) {
      $('<span>')
        .addClass(this.getCSS('SECONDARY_SPAN'))
        .html(description)
        .appendTo($el);
    }
    return $el[0];
  }


  /**
   * Query the Open Library's main search endpoint for this search string,
   * store the results in the instance of the autocomplete widget, and render
   * them.
   *
   * Fires off two requests per query to perform both a stemmed search and a
   * wildcard search. Optionally also supports author searches if split off
   * from main query string with ";".
   *
   * @param  {String} query
   *  the unescaped query string
   * @this OpenLibraryAutocompleteAdapter#ac
   */
  _requestHandler(query) {
    let time = Date.now();

    // Keep track of most recently fired query so we can ignore responses
    // coming in late
    if (this.latestQuery === undefined || this.latestQuery < time)
      this.latestQuery = time;

    this.results = [];
    query = query.trim();

    // Nothing to do - clear out the display & abort
    if (!query) {
      this.adapter.disableSpinner();
      this.render();
      return;
    }

    // Turn on spinner
    this.adapter.enableSpinner();

    // Lucene special characters
    query = query.replace(/(["+\-~![\]^\\&|(){}])/g, '\\$&');

    // Inconsistent behavior, best to strip
    query = query.replace(/:/g, '');

    // Turn double or more spaces into single spaces
    query = query.replace(/ {2,}/g, ' ');

    // We allow combining author and title search by adding an author w/ ";"
    let titleComponent = query,
      authorComponent = '',
      titleQuery = '',
      authorQuery = '',
      authorStart = titleComponent.indexOf(';'),
      hasAuthorComponent = authorStart != -1; // Single semicolon doesn't count

    if (hasAuthorComponent) {
      authorComponent = query.substr(authorStart + 1).trim();
      titleComponent = query.substr(0, query.length - (authorComponent.length + 1)).trim();
    }

    if (titleComponent)
      titleQuery = titleComponent.split(' ').map(word => `title:${word}`).join(' AND ');
    if (authorComponent) {
      authorQuery = authorComponent.split(' ').map(word => `author:${word}`).join(' AND ');
      if (titleQuery)
        authorQuery = ' ' + authorQuery;

      // All author searches get wildcarded. Since the author field does not
      // appear to be stemmed, this matches both partial and complete names.
      authorQuery += '*';
    }
    let q = titleQuery + authorQuery;
    let queryObj = {
      q,
      limit: this.adapter.limit,
      mode: 'everything'
    };

    let queryObj2;
    // Add a second wildcard query for longer searches. This may produce 0
    // results due to stemming conflicts, so we still have to fire off the
    // other query, as well.
    if (titleComponent.length >= 3 || authorComponent.length >= 3) {
      let q2 = titleQuery;
      if (titleComponent.length >= 3)
        q2 += '*';

      q2 += authorQuery;

      // if (authorComponent.length >= 3)
      //   q2 += '*';
      queryObj2 = Object.assign({}, queryObj);
      queryObj2.q = q2;
    }

    let getQuery = queryObj => new Promise((resolve, reject) => {
      $.ajax({
          url: 'https://openlibrary.org/search.json',
          dataType: 'json',
          data: queryObj
        })
        .done(resolve)
        .fail(reject);
    });

    let queries = [getQuery(queryObj)];

    if (queryObj2)
      queries.push(getQuery(queryObj2));

    Promise
      .all(queries)
      .then(results => {

        // Eliminate duplicate keys and merge results into a single array
        let data = this.adapter.mergeResults(results);

        // Rank any exact matches of the query string first (in-place sort)
        this.adapter.sortMatches(data.docs, query);

        // Don't update if a more recent query has superseded this one
        if (time < this.latestQuery)
          return;

        this.results = [];

        if (typeof data === 'object' && data.docs && data.docs.length) {
          this.results = data.docs.map(item =>
            ({
              url: `https://openlibrary.org${item.key}`,
              label: item.title_suggest,
              authors: item.author_name,
              publishers: item.publisher || [],
              years: item.publish_year || []
            }));
          this.render();
        } else {
          this.render();
          this.renderNoResults();
        }
        this.adapter.disableSpinner();
      })
      .catch(_error => {
        // Show generic error
        $('#generic-action-error').removeClass('hidden');
        window.libreviews.repaintFocusedHelp();
        // Turn off spinner
        this.adapter.disableSpinner();
      });
  }

}

module.exports = OpenLibraryAutocompleteAdapter;