providers_label-search-suggestion-provider.js

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

// TODO: Only keep the last 20 results in cache.
// TODO: Try to remove dependencies on `selected`, `scheme._registry.registry.uri`, etc.

/**
 * Label search suggestion provider.
 *
 * This provider offers mapping recommendations based on label match via the `/search` endpoint of JSKOS APIs.
 *
 * The provider requires that a list of initialized registries with search endpoints is provided via `cdk` (which refers to the CDK instance that's using this provider).
 *
 * To use it in a registry, specify `provider` as "LabelSearchSuggestion":
 * ```json
 * {
 *  "uri": "http://coli-conc.gbv.de/registry/coli-conc-recommendations"
 *  "provider": "LabelSearchSuggestion"
 * }
 * ```
 *
 * You can provide a list of excluded schemes as JSKOS objects in `excludedSchemes`.
 *
 * Experimental: You can also provide API URL overrides in the `overrides` field by giving a list of scheme objects each with a `search` field that contains the API URL. Note: This behavior is experimental and can change without a new major version.
 *
 * Additionally, the following JSKOS properties can be provided: `prefLabel`, `notation`, `definition`
 *
 * @extends BaseProvider
 * @category Providers
 */
export default class LabelSearchSuggestionProvider extends BaseProvider {

  /**
   * @private
   */
  _prepare() {
    this._cache = []
    this.has.mappings = true
    // Explicitly set other capabilities to false
    listOfCapabilities.filter(c => !this.has[c]).forEach(c => {
      this.has[c] = false
    })
  }

  /**
   * Override `supportsScheme` to check whether a search URI is available for the scheme's registry.
   *
   * @param {Object} scheme - target scheme to check for support
   * @returns {boolean}
   */
  supportsScheme(scheme) {
    return super.supportsScheme(scheme) && _.get(scheme, "_registry.has.search", false)
  }

  /**
   * Returns a list of mappings.
   *
   * @param {Object} config
   * @param {Object} config.from JSKOS concept on from side
   * @param {Object} config.to JSKOS concept on to side
   * @param {Object} config.mode mappings mode
   * @param {Object} config.selected selected mappings in Cocoda
   * @returns {Object[]} array of JSKOS mapping objects
   */
  async getMappings({ from, to, mode, selected, limit = 10, ...config }) {
    // TODO: Why mode?
    if (mode != "or") {
      return []
    }
    if (!selected) {
      throw new errors.InvalidOrMissingParameterError({ parameter: "selected" })
    }
    let promises = []
    if (from && this.supportsScheme(selected.scheme[false])) {
      promises.push(this._getMappings({ ...config, concept: from, sourceScheme: selected.scheme[true], targetScheme: selected.scheme[false], limit }))
    } else {
      promises.push(Promise.resolve([]))
    }
    if (to && this.supportsScheme(selected.scheme[true])) {
      promises.push(this._getMappings({ ...config, concept: to, sourceScheme: selected.scheme[false], targetScheme: selected.scheme[true], limit, swap: true }))
    } else {
      promises.push(Promise.resolve([]))
    }
    let [fromResult, toResult] = await Promise.all(promises)
    // Filter all duplicates from toResult
    toResult = toResult.filter(m => !fromResult.find(n => jskos.compareMappingMembers(m, n)))
    // Reduce number of results until limit is reached
    while (fromResult.length + toResult.length > limit) {
      if (toResult.length >= fromResult.length) {
        toResult = toResult.slice(0, -1)
      } else {
        fromResult = fromResult.slice(0, -1)
      }
    }
    return _.union(fromResult, toResult)
  }

  /**
   * Internal function to get mapping recommendations for a certain concept with sourceScheme and targetScheme.
   *
   * @private
   *
   * @param {Object} config
   * @param {Object} config.concept
   * @param {Object} config.sourceScheme
   * @param {Pbject} config.targetScheme
   * @param {boolean} config.swap - whether to reverse the direction of the mappings
   */
  async _getMappings({ concept, sourceScheme, targetScheme, limit, swap = false, ...config }) {
    if (!concept || !sourceScheme || !targetScheme) {
      return []
    }
    // If source scheme is the same as target scheme, skip
    if (jskos.compare(sourceScheme, targetScheme)) {
      return []
    }
    // Prepare label
    // TODO: Can we use a language prioritiy list like for requests?
    const language = jskos.languagePreference.selectLanguage(concept.prefLabel) || this._defaultLanguages[0]
    let label = jskos.prefLabel(concept, {
      fallbackToUri: false,
      language,
    })
    if (!label) {
      return []
    }
    // Adjust prefLabel by removing everything from the first non-whitespace, non-letter character
    // (copied from Cocoda)
    const regexResult = /^[\s\wäüöÄÜÖß]*\w/.exec(label)
    label = regexResult ? regexResult[0] : label
    // Get results from API or cache
    let results = await this._getResults({ ...config, label, targetScheme, limit })
    // Fallback to broader concept(s) if no results
    if (!results.length && concept.broader?.length) {
      for (const broader of concept.broader) {
        const label = jskos.prefLabel(broader, {
          fallbackToUri: false,
          language,
        })
        if (!label) {
          continue
        }
        results = await this._getResults({ ...config, label, targetScheme, limit })
        // Stop if there are results from a broader concept
        if (results.length) {
          break
        }
      }
    }
    // Map results to actual mappings
    let mappings = results.map(result => ({
      fromScheme: sourceScheme,
      from: { memberSet: [concept] },
      toScheme: targetScheme,
      to: { memberSet: [result] },
      type: ["http://www.w3.org/2004/02/skos/core#mappingRelation"],
    }))
    if (swap) {
      // Swap mapping sides if only `to` was set
      mappings = mappings.map(mapping => Object.assign(mapping, {
        fromScheme: mapping.toScheme,
        from: mapping.to,
        toScheme: mapping.fromScheme,
        to: mapping.from,
      }))
    }
    return mappings
  }

  /**
   * Internal function that either makes an API request or uses a local cache.
   *
   * @private
   *
   * @param {Object} config
   * @param {string} config.label
   * @param {Object} config.targetScheme
   */
  async _getResults({ label, targetScheme, limit, ...config }) {
    // Use local cache.
    let resultsFromCache = (this._cache[targetScheme.uri] || {})[label]
    if (resultsFromCache && resultsFromCache._limit >= limit) {
      return resultsFromCache
    }
    // Determine search URI for target scheme's registry
    const registry = _.get(targetScheme, "_registry")
    if (!registry || registry.has.search === false) {
      return []
    }
    // Determine whether an URL override is defined
    let url = (this._jskos.overrides || []).find(o => jskos.compare(o, targetScheme))?.search
    // API request
    const data = await registry.search({
      ...config,
      url,
      search: label,
      scheme: targetScheme,
      limit,
    })
    // Save result in cache
    if (!this._cache[targetScheme.uri]) {
      this._cache[targetScheme.uri] = {}
    }
    this._cache[targetScheme.uri][label] = data
    this._cache[targetScheme.uri][label]._limit = limit
    return data
  }

}

LabelSearchSuggestionProvider.providerName = "LabelSearchSuggestion"
LabelSearchSuggestionProvider.stored = false