Source: src/js/models/resourceMap/ResourceMapNormalization.js

"use strict";

define([
  "common/ValidationUtilities",
  "models/resourceMap/RDFGraph",
  "models/resourceMap/ResourceMapCommon",
], (ValidationUtilities, RDFGraph, ResourceMapCommon) => {
  const { createValidationIssue } = ValidationUtilities;
  const {
    NS,
    ResourceMapConflictError,
    describeTerm,
    identifierLiteralPid,
    isAbsoluteNamedNode,
  } = ResourceMapCommon;

  /**
   * Copy an RDF statement into plain diagnostic data.
   * @param {RDFGraphStatement} statement RDF statement
   * @returns {object} Copyable statement details
   */
  function describeStatement(statement) {
    return {
      subject: describeTerm(statement?.subject),
      predicate: describeTerm(statement?.predicate),
      object: describeTerm(statement?.object),
    };
  }

  /**
   * Remove duplicate statements that contain the same subject and object.
   * @param {RDFGraphStatement[]} statements Statements to deduplicate
   * @returns {RDFGraphStatement[]} One statement per subject/object pair
   */
  function distinctPairs(statements) {
    const pairsByKey = new Map();
    statements.forEach((statement) => {
      const key = RDFGraph.buildKey([
        RDFGraph.buildTermKey(statement.subject),
        RDFGraph.buildTermKey(statement.object),
      ]);
      pairsByKey.set(key, statement);
    });
    return [...pairsByKey.values()];
  }

  /**
   * Stop import when the graph does not identify exactly one Resource Map and
   * the package aggregation it describes. The graph is left unchanged.
   * @param {RDFGraph} graph Imported graph
   * @param {string} resourceMapPid Outer Resource Map PID
   * @param {string} reason Machine readable failure reason
   * @param {RDFGraphStatement|null} [selected] Candidate selected before a
   * guard failed
   * @throws {ResourceMapConflictError} Always
   */
  function throwOwnershipConflict(
    graph,
    resourceMapPid,
    reason,
    selected = null,
  ) {
    const forwardStatements = graph.findStatements({
      predicate: NS.ORE("describes"),
    });
    const inverseStatements = graph.findStatements({
      predicate: NS.ORE("isDescribedBy"),
    });
    const validInversePairs = distinctPairs(
      inverseStatements.filter(
        ({ subject, object }) =>
          isAbsoluteNamedNode(subject) && isAbsoluteNamedNode(object),
      ),
    );
    const malformedOwnershipStatements = [
      ...forwardStatements.filter(
        ({ subject, object }) =>
          !isAbsoluteNamedNode(subject) || !isAbsoluteNamedNode(object),
      ),
      ...inverseStatements.filter(
        ({ subject, object }) =>
          !isAbsoluteNamedNode(subject) || !isAbsoluteNamedNode(object),
      ),
    ];
    const issue = createValidationIssue({
      field: "resourceMap",
      code: "ambiguousResourceMapRoot",
      reason,
      message:
        "The RDF does not identify exactly one Resource Map document and package aggregation.",
      resourceMapPid,
      forwardStatements: forwardStatements.map(describeStatement),
      inversePairs: validInversePairs.map(describeStatement),
      malformedOwnershipStatements:
        malformedOwnershipStatements.map(describeStatement),
      selectedCandidate: selected ? describeStatement(selected) : null,
    });

    throw new ResourceMapConflictError(issue.message, {
      code: "ambiguousResourceMapRoot",
      details: {
        resourceMapPid,
        reason,
        issues: [issue],
      },
    });
  }

  /**
   * After raw validation, replace equivalent identifier spellings with one
   * literal containing the exact PID.
   * @param {ResourceMap} resourceMap Resource Map being repaired
   * @param {NamedNode} node Managed RDF node
   * @param {string} pid Approved PID
   */
  function canonicalizeIdentifier(resourceMap, node, pid) {
    resourceMap.graph.removeStatementsMatching({
      subject: node,
      predicate: NS.DCTERMS("identifier"),
    });

    resourceMap.graph.addStatementIfMissing({
      subject: node,
      predicate: NS.DCTERMS("identifier"),
      object: RDFGraph.createLiteral(pid, undefined, NS.XSD("string")),
    });
  }

  /**
   * Repair the known issue #946 output where a member's `isAggregatedBy` target
   * was written as a malformed URI instead of the package aggregation node.
   * @param {ResourceMap} resourceMap Resource Map being repaired
   * @param {Set<string>} rawForwardMemberKeys Exact members found through
   * `package --aggregates--> member` statements
   */
  function repairMalformedMembershipArtifact(
    resourceMap,
    rawForwardMemberKeys,
  ) {
    const aggregationNode = RDFGraph.createNamedNode(
      resourceMap.aggregationUri,
    );

    resourceMap.graph
      .findStatements({ predicate: NS.ORE("isAggregatedBy") })
      .filter(({ subject, object }) => {
        if (
          !rawForwardMemberKeys.has(RDFGraph.buildTermKey(subject)) ||
          !RDFGraph.isNamedNode(object)
        ) {
          return false;
        }
        const malformed = ResourceMapCommon.extractMalformedResourceValue(
          object.value,
        );
        return (
          malformed?.datatypeUri === NS.XSD("anyURI").value &&
          malformed.lexicalValue === resourceMap.aggregationUri
        );
      })
      .forEach((statement) => {
        // This exact NamedNode value is the output created by issue #946.
        // Preserve near matches and ordinary literals because MetacatUI cannot
        // prove they came from that bug.
        resourceMap.graph.removeStatement(statement);
        resourceMap.graph.addStatementIfMissing({
          subject: statement.subject,
          predicate: NS.ORE("isAggregatedBy"),
          object: aggregationNode,
        });
      });
  }

  /**
   * Store both RDF directions of a link from metadata to documented data, but
   * only when both exact RDF nodes are package members.
   * @param {ResourceMap} resourceMap Resource Map being repaired
   * @param {Set<string>} memberKeys Exact selected member term keys
   */
  function repairDocumentationReciprocity(resourceMap, memberKeys) {
    [
      [NS.CITO("documents"), NS.CITO("isDocumentedBy")],
      [NS.CITO("isDocumentedBy"), NS.CITO("documents")],
    ].forEach(([predicate, inversePredicate]) => {
      resourceMap.graph.findStatements({ predicate }).forEach((statement) => {
        if (
          !memberKeys.has(RDFGraph.buildTermKey(statement.subject)) ||
          !memberKeys.has(RDFGraph.buildTermKey(statement.object))
        ) {
          return;
        }
        resourceMap.graph.addStatementIfMissing({
          subject: statement.object,
          predicate: inversePredicate,
          object: statement.subject,
        });
      });
    });
  }

  /**
   * Select the Resource Map and aggregation from exactly one explicit
   * `ResourceMap --describes--> aggregation` statement.
   * @param {RDFGraph} graph Imported RDF graph
   * @param {string} resourceMapPid Outer Resource Map PID
   * @param {RDFGraphStatement[]} forwardStatements `ore:describes` statements
   * @returns {{resourceMapUri:string, aggregationUri:string,
   * recoveredFromInverse:boolean}} Selected Resource Map and aggregation
   */
  function selectForwardRoot(graph, resourceMapPid, forwardStatements) {
    const relative = forwardStatements.some(
      ({ subject, object }) =>
        (RDFGraph.isNamedNode(subject) &&
          !ResourceMapCommon.isAbsoluteIri(subject.value)) ||
        (RDFGraph.isNamedNode(object) &&
          !ResourceMapCommon.isAbsoluteIri(object.value)),
    );
    const malformed = forwardStatements.some(
      ({ subject, object }) =>
        !isAbsoluteNamedNode(subject) || !isAbsoluteNamedNode(object),
    );
    const pairs = distinctPairs(forwardStatements);
    if (malformed || pairs.length !== 1) {
      let reason = "ambiguous";
      if (relative) reason = "relative";
      else if (malformed) reason = "malformed";
      throwOwnershipConflict(graph, resourceMapPid, reason);
    }
    return {
      resourceMapUri: pairs[0].subject.value,
      aggregationUri: pairs[0].object.value,
      recoveredFromInverse: false,
    };
  }

  /**
   * When the forward `describes` statement is missing, recover the Resource Map
   * and aggregation from exactly one
   * `aggregation --isDescribedBy--> ResourceMap` statement.
   * @param {RDFGraph} graph Imported RDF graph
   * @param {string} resourceMapPid Outer Resource Map PID
   * @param {object} endpoints Configured service URLs used to recognize map
   * identifiers written as resolver or object URLs
   * @returns {{resourceMapUri:string, aggregationUri:string,
   * recoveredFromInverse:boolean}} Selected Resource Map and aggregation
   */
  function selectInverseRoot(graph, resourceMapPid, endpoints) {
    // Use an inverse `ore:isDescribedBy` statement only when there is no
    // `ore:describes` statement. Count exact inverse pairs before checking
    // types or PIDs so a more complete candidate cannot silently win.
    const inverseStatements = graph.findStatements({
      predicate: NS.ORE("isDescribedBy"),
    });
    const inversePairs = distinctPairs(
      inverseStatements.filter(
        ({ subject, object }) =>
          isAbsoluteNamedNode(subject) && isAbsoluteNamedNode(object),
      ),
    );
    if (inversePairs.length !== 1) {
      const hasRelativeInverse = inverseStatements.some(
        ({ subject, object }) =>
          (RDFGraph.isNamedNode(subject) &&
            !ResourceMapCommon.isAbsoluteIri(subject.value)) ||
          (RDFGraph.isNamedNode(object) &&
            !ResourceMapCommon.isAbsoluteIri(object.value)),
      );
      let reason = "missing";
      if (inversePairs.length > 1) reason = "ambiguous";
      else if (hasRelativeInverse) reason = "relative";
      else if (inverseStatements.length) reason = "malformed";
      throwOwnershipConflict(graph, resourceMapPid, reason);
    }

    const selected = inversePairs[0];
    const rootIdentifiers = graph.findStatements({
      subject: selected.object,
      predicate: NS.DCTERMS("identifier"),
    });
    // Preserve URL-shaped PIDs when comparing configured identifier URLs.
    const passesGuards =
      graph.hasStatement({
        subject: selected.subject,
        predicate: NS.RDF("type"),
        object: NS.ORE("Aggregation"),
      }) &&
      graph.hasStatement({
        subject: selected.object,
        predicate: NS.RDF("type"),
        object: NS.ORE("ResourceMap"),
      }) &&
      rootIdentifiers.every(({ object }) => RDFGraph.isLiteral(object)) &&
      rootIdentifiers.length > 0 &&
      rootIdentifiers.every(
        ({ object }) =>
          identifierLiteralPid(object, resourceMapPid, endpoints) ===
          resourceMapPid,
      );

    if (!passesGuards) {
      throwOwnershipConflict(graph, resourceMapPid, "contradictory", selected);
    }

    return {
      resourceMapUri: selected.object.value,
      aggregationUri: selected.subject.value,
      recoveredFromInverse: true,
    };
  }

  /**
   * Find the Resource Map document and package aggregation in imported RDF,
   * then apply only repairs whose values are certain. Configured endpoint URIs
   * are converted only when their identifier claims agree.
   * @class ResourceMapNormalization
   * @classcategory Models/ResourceMap
   * @since 2.39.0
   */
  class ResourceMapNormalization {
    /**
     * Identify exactly one Resource Map and package aggregation in imported RDF.
     * Reject ambiguous candidates instead of ranking them, and do not edit the
     * graph.
     * @param {RDFGraph} graph Imported RDF graph
     * @param {string} resourceMapPid Outer Resource Map PID
     * @param {object} [endpoints] Configured resolve and object service URLs used
     * to read URL-valued map identifiers when checking the reverse link
     * @returns {{resourceMapUri:string, aggregationUri:string,
     * recoveredFromInverse:boolean}} Selected Resource Map and aggregation
     */
    static selectImportedRoot(graph, resourceMapPid, endpoints) {
      const forwardStatements = graph.findStatements({
        predicate: NS.ORE("describes"),
      });
      if (forwardStatements.length) {
        return selectForwardRoot(graph, resourceMapPid, forwardStatements);
      }
      return selectInverseRoot(graph, resourceMapPid, endpoints);
    }

    /**
     * Create a normalization helper for one ResourceMap.
     * @param {object} options Normalization options
     * @param {ResourceMap} options.resourceMap Resource Map being normalized
     */
    constructor({ resourceMap } = {}) {
      this.resourceMap = resourceMap;
    }

    /**
     * After all original PID claims have been validated, add only missing or
     * equivalent statements whose intended values are certain. Convert eligible
     * configured endpoint URIs to CN resolver URLs.
     * @param {object} identity Raw identity inspection from validation
     * @param {boolean} [recoveredFromInverse] Whether the Resource Map and
     * aggregation were found through one exact `ore:isDescribedBy` statement
     */
    repairImportedGraph(identity, recoveredFromInverse = false) {
      const { resourceMap } = this;
      const resourceMapNode = RDFGraph.createNamedNode(
        resourceMap.resourceMapUri,
      );
      const aggregationNode = RDFGraph.createNamedNode(
        resourceMap.aggregationUri,
      );

      // All imported PID claims were compared before this point. Only now may
      // equivalent forms be combined or a provably missing identifier added.
      canonicalizeIdentifier(resourceMap, resourceMapNode, identity.root.pid);
      identity.members.forEach(({ node, pid }) => {
        canonicalizeIdentifier(resourceMap, node, pid);
      });

      if (recoveredFromInverse) {
        resourceMap.graph.addStatementIfMissing({
          subject: resourceMapNode,
          predicate: NS.ORE("describes"),
          object: aggregationNode,
        });
        // Ensure recovery did not create or expose a second `ore:describes`
        // pair.
        ResourceMapNormalization.selectImportedRoot(
          resourceMap.graph,
          resourceMap.resourceMapPid,
          resourceMap,
        );
      }

      const rawForwardMemberKeys = new Set(
        identity.members
          .filter(({ forward }) => forward)
          .map(({ node }) => RDFGraph.buildTermKey(node)),
      );
      repairMalformedMembershipArtifact(resourceMap, rawForwardMemberKeys);
      const replacements = this.normalizeEndpointUrls();
      // Merging a member with another node can bring in documentation links.
      // Add missing inverse links using the converted member URLs.
      repairDocumentationReciprocity(
        resourceMap,
        new Set(
          identity.members.map(({ node }) =>
            RDFGraph.buildTermKey(
              RDFGraph.createNamedNode(
                replacements.get(node.value) || node.value,
              ),
            ),
          ),
        ),
      );
      resourceMap.graph.dedupeStatements();
    }

    /**
     * Use configured CN resolve URLs for identified MN/CN nodes. For example,
     * `/object/urn%3Auuid%3Aabc` becomes `/resolve/urn:uuid:abc`; the PID stays
     * `urn:uuid:abc`. Include nodes outside the package and merge matching PIDs.
     * @param {NamedNode[]} [nodes] Limit conversion to these nodes. By default,
     * inspect every node with an identifier statement
     * @returns {Map<string, string>} Old node URLs mapped to resolver URLs
     * @since 2.41.0
     */
    normalizeEndpointUrls(nodes) {
      const { resourceMap } = this;
      const replacements = new Map();
      const identifiersToNormalize = new Map();

      const candidates =
        nodes ||
        resourceMap.graph
          .findStatements({ predicate: NS.DCTERMS("identifier") })
          .map(({ subject }) => subject);
      // Check each source and its destination before changing identifiers.
      RDFGraph.dedupeTerms(candidates).forEach((subject) => {
        const pid = this.getEndpointPidForNormalization(subject);
        if (!pid) return;
        identifiersToNormalize.set(subject.value, pid);

        // Moving MN /object/data.1 to CN /resolve/data.1 can combine two nodes.
        // Skip if the CN node's identifier says different.1.
        const resolveUri = resourceMap.pidToUri(pid);
        const destination = RDFGraph.createNamedNode(resolveUri);
        if (this.getEndpointPidForNormalization(destination) === pid) {
          replacements.set(subject.value, resolveUri);
          identifiersToNormalize.set(resolveUri, pid);
        }
      });

      // Replace matching URL identifiers with PID text, e.g. data.1, before merging.
      // This keeps one identifier after merging. The identifier can still be
      // corrected when a destination conflict prevents renaming the URI.
      identifiersToNormalize.forEach((pid, uri) => {
        canonicalizeIdentifier(resourceMap, RDFGraph.createNamedNode(uri), pid);
      });
      // Update rdf:about and rdf:resource links together. Keep prov:atLocation
      // link targets: recorded file locations may still use the old MN URL.
      resourceMap.graph.replaceNodeValues(replacements, {
        preserveObjectPredicates: [NS.PROV("atLocation").value],
      });
      return replacements;
    }

    /**
     * Read a configured URL's PID when all its identifiers match.
     * A destination node may have no identifiers.
     * @param {NamedNode|BlankNode} subject RDF node to check
     * @returns {string|null} PID if the node can be used, otherwise null
     * @since 2.41.0
     */
    getEndpointPidForNormalization(subject) {
      const { resourceMap } = this;
      // Keep map/aggregation URLs; setResourceMapPid() updates them during save.
      if (
        !RDFGraph.isNamedNode(subject) ||
        subject.value === resourceMap.resourceMapUri ||
        subject.value === resourceMap.aggregationUri
      ) {
        return null;
      }
      // Use only configured service paths. Elsewhere, /object/data.1 may be
      // part of a full URL identifier rather than an endpoint for PID data.1.
      const [pid] = ResourceMapCommon.configuredEndpointPids(
        subject.value,
        resourceMap,
      );
      if (!pid) {
        return null;
      }

      // Require every identifier to claim this PID; resource-valued ones fail.
      const identifierStatements = resourceMap.graph.findStatements({
        subject,
        predicate: NS.DCTERMS("identifier"),
      });
      // A destination may have no identifier; the checked source identifies
      // the merged node.
      const identifiersMatchPid = identifierStatements.every(
        ({ object: identifier }) =>
          identifierLiteralPid(identifier, pid, resourceMap) === pid,
      );
      return identifiersMatchPid ? pid : null;
    }

    /**
     * Ensure a valid Resource Map has the required map, aggregation, and
     * membership statements in both directions for its current exact members.
     * @param {Array<{pid:string, uri:string}>} memberDescriptors Exact members
     */
    synchronizeCoreGraph(memberDescriptors) {
      const { resourceMap } = this;
      const resourceMapNode = RDFGraph.createNamedNode(
        resourceMap.resourceMapUri,
      );
      const aggregationNode = RDFGraph.createNamedNode(
        resourceMap.aggregationUri,
      );

      resourceMap.ensureIdentifierForUri(
        resourceMap.resourceMapUri,
        resourceMap.resourceMapPid,
      );
      resourceMap.graph.addStatementIfMissing({
        subject: resourceMapNode,
        predicate: NS.RDF("type"),
        object: NS.ORE("ResourceMap"),
      });
      resourceMap.graph.addStatementIfMissing({
        subject: aggregationNode,
        predicate: NS.RDF("type"),
        object: NS.ORE("Aggregation"),
      });
      resourceMap.graph.addStatementIfMissing({
        subject: resourceMapNode,
        predicate: NS.ORE("describes"),
        object: aggregationNode,
      });
      resourceMap.graph.addStatementIfMissing({
        subject: aggregationNode,
        predicate: NS.ORE("isDescribedBy"),
        object: resourceMapNode,
      });

      memberDescriptors.forEach(({ pid, uri }) => {
        // Keep each member's current RDF URI. Use the configured Coordinating
        // Node only when a new member has no RDF node yet.
        resourceMap.structureMutation.addAggregationTriples(pid, uri);
      });
    }
  }

  return ResourceMapNormalization;
});