providers_occurrences-api-provider.js

import BaseProvider from "./base-provider.js"
import jskos from "jskos-tools"
import * as _ from "../utils/lodash.js"
import * as errors from "../errors/index.js"
import * as utils from "../utils/index.js"

// Cache by registry URI
const cache = {}

// TODO: Modernize.

/**
 * JSKOS Occurrences API.
 *
 * This class provides access to concept occurrences via JSKOS API in [JSKOS format](https://gbv.github.io/jskos/).
 *
 * To use it in a registry, specify `provider` as "OccurrencesApi" and provide the API base URL as `api`:
 * ```json
 * {
 *  "uri": "http://coli-conc.gbv.de/registry/occurrences",
 *  "provider": "OccurrencesApi",
 *  "api": "https://coli-conc.gbv.de/occurrences/api/"
 * }
 * ```
 *
 * Additionally, the following JSKOS properties can be provided: `prefLabel`, `notation`, `definition`
 *
 * @extends BaseProvider
 * @category Providers
 */
export default class OccurrencesApiProvider extends BaseProvider {
  static supports = {
    occurrences: true,
    mappings: true,
  }

  get _cache() {
    return cache[this.uri]
  }

  /**
   * @private
   */
  _prepare() {
    cache[this.uri] = []
    this._occurrencesSupportedSchemes = []
  }

  /**
   * Returns whether a concept scheme is supported for occurrences.
   *
   * @private
   *
   * @param {Object} scheme JSKOS scheme to query
   */
  async _occurrencesIsSupported(scheme) {
    if (this._occurrencesSupportedSchemes && this._occurrencesSupportedSchemes.length) {
      // No action needed
    } else {
      // Load supported schemes from API
      try {
        const url = utils.concatUrl(this._api.api, "voc")
        const data = await this.axios({
          method: "get",
          url,
        })
        this._occurrencesSupportedSchemes = data || []
      } catch (error) {
        // Do nothing so that it is tried again next time
        // TODO: Save number of failures?
      }
    }
    let supported = false
    for (let supportedScheme of this._occurrencesSupportedSchemes) {
      if (jskos.compare(scheme, supportedScheme)) {
        supported = true
      }
    }
    return supported
  }

  /**
   * Wrapper around getOccurrences that converts occurrences into mappings.
   *
   * @param {Object} config config object for getOccurrences request
   * @returns {Object[]} array of JSKOS mapping objects
   */
  async getMappings(config) {
    const occurrences = await this.getOccurrences(config)
    const from = config.from
    const fromScheme = _.get(from, "inScheme[0]") || config.fromScheme
    const to = config.to
    const toScheme = _.get(to, "inScheme[0]") || config.toScheme
    const mappings = []
    // Convert occurrences to mappings
    for (let occurrence of occurrences) {
      if (!occurrence) {
        continue
      }
      let mapping = {}
      mapping.from = _.get(occurrence, "memberSet[0]")
      if (mapping.from) {
        mapping.from = { memberSet: [mapping.from] }
      } else {
        mapping.from = null
      }
      mapping.fromScheme = _.get(occurrence, "memberSet[0].inScheme[0]")
      mapping.to = _.get(occurrence, "memberSet[1]")
      if (mapping.to) {
        mapping.to = { memberSet: [mapping.to] }
      } else {
        mapping.to = { memberSet: [] }
      }
      mapping.toScheme = _.get(occurrence, "memberSet[1].inScheme[0]")
      // Swap sides if necessary
      if (from && jskos.compare(from, _.get(mapping, "to.memberSet[0]")) || to && jskos.compare(to, _.get(mapping, "from.memberSet[0]"))) {
        [mapping.from, mapping.fromScheme, mapping.to, mapping.toScheme] = [mapping.to, mapping.toScheme, mapping.from, mapping.fromScheme]
      }
      // Set fromScheme/toScheme if necessary
      if (!mapping.fromScheme && fromScheme) {
        mapping.fromScheme = fromScheme
      }
      if (!mapping.toScheme && toScheme) {
        mapping.toScheme = toScheme
      }
      mapping.type = [jskos.defaultMappingType.uri]
      mapping._occurrence = occurrence
      mapping = jskos.addMappingIdentifiers(mapping)
      mappings.push(mapping)
    }
    mappings._url = occurrences._url
    return mappings
  }

  /**
   * Returns a list of occurrences.
   *
   * @param {Object} config
   * @param {Object} [config.from] JSKOS concept to load occurrences for (from side)
   * @param {Object} [config.to] JSKOS concept to load occurrences for (to side)
   * @param {Object[]} [config.concepts] list of JSKOS concepts to load occurrences for
   * @returns {Object[]} array of JSKOS occurrence objects
   */
  async getOccurrences({ from, to, concepts, threshold = 0, ...config }) {
    let promises = []
    concepts = (concepts || []).concat([from, to]).filter(c => !!c)
    for (let concept of concepts) {
      promises.push(this._occurrencesIsSupported(_.get(concept, "inScheme[0]")).then(supported => {
        if (supported && concept.uri) {
          return concept.uri
        } else {
          return null
        }
      }))
    }
    let uris = await Promise.all(promises)
    uris = uris.filter(uri => uri != null)
    if (uris.length == 0) {
      throw new errors.InvalidOrMissingParameterError({ parameter: "concepts" })
    }
    promises = []
    for (let uri of uris) {
      promises.push(this._getOccurrences({
        ...config,
        params: {
          member: uri,
          scheme: "*",
          threshold,
        },
      }))
    }
    // Another request for co-occurrences between two specific concepts
    // TODO: Currently unsupported and therefore removed (2022-08-03)
    // if (uris.length > 1) {
    //   let urisString = uris.join(" ")
    //   promises.push(this._getOccurrences({
    //     ...config,
    //     params: {
    //       member: urisString,
    //       threshold: 5,
    //     },
    //   }))
    // }
    const results = await Promise.all(promises)
    let occurrences = _.concat([], ...results)
    // Filter duplicates
    let existingUris = []
    let indexesToDelete = []
    for (let i = 0; i < occurrences.length; i += 1) {
      let occurrence = occurrences[i]
      if (!occurrence) {
        continue
      }
      let uris = occurrence.memberSet.reduce((total, current) => total.concat(current.uri), []).sort().join(" ")
      if (existingUris.includes(uris)) {
        indexesToDelete.push(i)
      } else {
        existingUris.push(uris)
      }
    }
    indexesToDelete.forEach(value => {
      delete occurrences[value]
    })
    // Filter null values
    occurrences = occurrences.filter(o => o != null)
    // Sort occurrences
    occurrences = occurrences.sort((a, b) => parseInt(b.count || 0) - parseInt(a.count || 0))
    // Add URL(s)
    occurrences._url = results.map(result => result._url)

    return occurrences
  }

  /**
   * Internal function for getOccurrences that either makes an API request or uses a local cache.
   *
   * @private
   *
   * @param {Object} config passthrough of config parameter for axios request
   */
  async _getOccurrences(config) {
    // Use local cache.
    let resultsFromCache = this._cache.find(item => {
      return _.isEqual(item.config.params, config.params)
    })
    if (resultsFromCache) {
      return resultsFromCache.data
    }
    const data = await this.axios({
      ...config,
      method: "get",
      url: this._api.api,
    })
    this._cache.push({
      config,
      data,
    })
    if (this._cache.length > 20) {
      cache[this.uri] = this._cache.slice(this._cache.length - 20)
    }
    return data
  }
}

OccurrencesApiProvider.providerName = "OccurrencesApi"
OccurrencesApiProvider.stored = false