Entry.js

import { Graph } from '@entryscape/rdfjson';
import factory from './factory.js';
import types from './types.js';

/**
 * Entries are at the center of this API. Entries holds together metadata, external metadata,
 * resources, access control, and provenance. Hence, entries appear in the majority of methods,
 * either directly or in callbacks via promises. Each entry has a simple identifier within a
 * context and a globally unique URI that can be used to load, store and index the entry.
 *
 * Many of the methods in this class are convenience methods that allows the developer to interact
 * with the information retrieved from the repository without digging through the RDF graphs.
 * For instance, all methods starting with _can_ or _is_ are convenience methods for working
 * with access control or the type information available in the associated
 * The same is true for the majority of the get methods,
 * only those that have corresponding set methods are really unique for this class.
 *
 * @link EntryInfo
 * @exports store/Entry
 */
export default class Entry {
  /**
   * @param {Context} context container for this entry
   * @param {EntryInfo} entryInfo defines the basics of this entry
   */
  constructor(context, entryInfo) {
    this._context = context;
    this._entryInfo = entryInfo;
    this._entryInfo._entry = this;
  }

  /**
   * @returns {EntryStore}
   */
  getEntryStore() {
    return this._context.getEntryStore();
  }

  /**
   * @returns {EntryInfo}
   */
  getEntryInfo() {
    return this._entryInfo;
  }

  /**
   * Convenience method, same as calling entry.getEntryInfo().getEntryURI()
   * @return {string} the entry uri.
   * @see EntryInfo#getEntryURI
   */
  getURI() {
    return this._entryInfo.getEntryURI();
  }

  /**
   * Convenience method, same as calling entry.getEntryInfo().getId()
   * @returns {string} the id of the entry
   * @see EntryInfo#getId
   */
  getId() {
    return this._entryInfo.getId();
  }

  /**
   * Convenience method, same as calling entry.getEntryInfo().getResourceURI()
   * @returns {string} a URI to the resource of this entry.
   */
  getResourceURI() {
    return this._entryInfo.getResourceURI();
  }

  /**
   * @returns {Context}
   */
  getContext() {
    return this._context;
  }

  /**
   * Provides all metadata, different behaviour depending on entry type:
   * * local - local metadata, i.e. getMetadata()
   * * link - local metadata, i.e. getMetadata()
   * * reference - cached external metadata, i.e. getCachedExternalMetadata()
   * * linkReference - new graph which is a combination of cached external metadata and local metadata
   *
   * @return {rdfjson/Graph}
   */
  getAllMetadata() {
    if (this.isReference()) {
      return this.getCachedExternalMetadata();
    } else if (this.isLinkReference()) {
      const graph = new Graph();
      graph.addAll(this.getMetadata());
      graph.addAll(this.getCachedExternalMetadata(), 'external');
      return graph;
    }
    return this.getMetadata();
  }

  /**
   * Provides an RDF graph as an {@link rdfjson/Graph} instance.
   * @return {rdfjson/Graph} a RDF graph with metadata, typically containing statements about
   * the resourceURI. The returned graph may be empty but never null or undefined.
   */
  getMetadata() {
    if (this._metadata == null) {
      this._metadata = new Graph();
    }
    return this._metadata;
  }

  /**
   * Sets a new metadata graph for this entry without pushing it to the repository.
   * In many cases this method is not needed since you can get the metadata graph,
   * modify it and then commit the changes directly.
   *
   * However, in some cases you need to set a new metadata graph, e.g.
   * you want to overwrite the metadata with a new graph retrieved from another source or the
   * entry have been refreshed with new information and you want to commit the merged results.
   * In these cases you need to discard the current metadata graph with help of this method.
   *
   * @param {rdfjson/Graph} graph is an RDF graph with metadata, if it is not provided the current
   * metadata graph is saved (there is currently no check whether it has been modified or not).
   * @return Entry - to allow chaining with other methods, e.g. with commitMetadata.
   */
  setMetadata(graph) {
    this._metadata = graph;
    return this;
  }

  /**
   * Will push the metadata for this entry to the repository.
   * If metadata has been set for an entry with EntryType 'reference'
   * the entry type will change to 'linkreference' upon a successful commit.
   * @params {boolean} [ignoreIfUnmodifiedSinceCheck=false] if explicitly set to true no check is done
   * if information is stale, also it will not automatically refresh with the latest date
   * @return {Promise.<Entry>} a promise that on success will contain the current updated entry.
   */
  async commitMetadata(ignoreIfUnmodifiedSinceCheck = false) {
    const es = this.getEntryStore();
    if (this.isReference()) {
      return Promise.reject(`Entry "${this.getURI()}" is a reference and have no local metadata that can be saved.`);
    } else if (!this.canWriteMetadata()) {
      return Promise.reject(`You do not have sufficient access rights to save metadata on entry "${this.getURI()}".`);
    } else if (this.needRefresh()) {
      return Promise.reject(`The entry "${this.getURI()}" need to be refreshed before its local metadata can be saved.\n` +
        'This message indicates that the client is written poorly, this case should have been taken into account.');
    } else if (this._metadata == null) {
      return Promise.reject(`The entry "${this.getURI()}" should allow local metadata to be saved, but there is no local metadata.\nThis message is a bug in the storejs API.`);
    } else {
      const promise = es.getREST().put(this.getEntryInfo().getMetadataURI(), JSON.stringify(this._metadata.exportRDFJSON()),
        ignoreIfUnmodifiedSinceCheck ? undefined : this.getEntryInfo().getModificationDate());
      es.handleAsync(promise, 'commitMetadata');
      const response = await promise;
      this.getEntryInfo().setModificationDate(response.header['last-modified']);
    }

    return Promise.resolve(this);
  }

  /**
   * Same as entry.getMetadata().add(entry.getResourceURI(), predicate, o)
   * but instead of returning the created statement it returns the entry itself,
   * allowing chained method calls.
   *
   * @param {string} predicate the predicate
   * @param {object} object the object
   * @returns {Entry}
   */
  add(predicate, object) {
    this.getMetadata().add(this.getResourceURI(), predicate, object);
    return this;
  }

  /**
   * Same as entry.getMetadata().addL(entry.getResourceURI(), predicate, literal, lang)
   * but instead of returning the created statement it returns the entry itself,
   * allowing chained method calls.
   *
   * @param {string} predicate the predicate
   * @param {string} literal the literal value
   * @param {string} language an optional language
   * @returns {Entry}
   */
  addL(predicate, literal, language) {
    this.getMetadata().addL(this.getResourceURI(), predicate, literal, language);
    return this;
  }

  /**
   * Same as entry.getMetadata().addD(entry.getResourceURI(), predicate, literal, lang)
   * but instead of returning the created statement it returns the entry itself,
   * allowing chained method calls.
   *
   * @param {string} predicate the predicate
   * @param {string} literal the literal value
   * @param {string} datatype the datatype (should be a string)
   * @returns {Entry}
   */
  addD(predicate, literal, datatype) {
    this.getMetadata().addD(this.getResourceURI(), predicate, literal, datatype);
    return this;
  }

  /**
   * Cached external metadata can only be provided for entries with entry type
   * reference or link reference.
   *
   * @return {rdfjson/Graph} - a RDF graph with cached external metadata, typically containing
   * statements about the resourceURI. The returned graph may be empty but never null
   * or undefined.
   */
  getCachedExternalMetadata() {
    if (this._cachedExternalMetadata == null) {
      this._cachedExternalMetadata = new Graph();
    }

    return this._cachedExternalMetadata;
  }

  getInferredMetadata() {
    return this._inferredMetadata;
  }

  /**
   * Sets a new cached external metadata graph for this entry without pushing
   * it to the repository.
   *
   * @param {rdfjson/Graph} graph is an RDF graph with metadata.
   * @return Entry - to allow chaining with other methods,
   * e.g. with commitCachedExternalMetadata.
   */
  setCachedExternalMetadata(graph) {
    if (graph) {
      this._cachedExternalMetadata = graph;
    }

    return this;
  }

  /**
   * Pushes the current cached external metadata graph for this entry to the repository.
   *
   * @return {Promise.<Entry>} a promise that on success will contain the current updated entry.
   */
  async commitCachedExternalMetadata(ignoreIfUnmodifiedSinceCheck) {
    const es = this.getEntryStore();
    const promise = es.getREST().put(this.getEntryInfo().getCachedExternalMetadataURI(),
      JSON.stringify(this._cachedExternalMetadata.exportRDFJSON()),
      ignoreIfUnmodifiedSinceCheck ? undefined : this.getEntryInfo().getModificationDate());
    es.handleAsync(promise, 'commitCachedExternalMetadata');
    const response = await promise;
    this.getEntryInfo().setModificationDate(response.header['last-modified']);
    return Promise.resolve(this);
  }

  /**
   * @todo remains to be supported in repository
   * @returns {rdfjson/Graph}
   */
  getExtractedMetadata() {
    if (this._extractedMetadata == null) {
      this._extractedMetadata = new Graph();
    }
    return this._extractedMetadata;
  }

  /**
   * Provides the resource for this entry if it exists in a promise,
   * e.g. if the graph-type is not none.
   * It is also possible to request the resource directly, i.e. get the resource rather
   * than a promise. This is achieved by specifying the "direct" parameter as true.
   * This always work for Lists, Groups, and Context resources.
   * For all other resources it will work if the resource, e.g. a Graph,
   * a String etc. is already loaded. If it is not loaded null will be returned.
   *
   * @returns {Resource | Promise.<Resource>}
   */
  getResource(direct = false) {
    if (direct) {
      return this._resource;
    }
    const es = this.getEntryStore();
    let promise;
    if (this._resource) {
      promise = Promise.resolve(this._resource);
    } else {
      const format = this.isString() ? 'text' : null;
      promise = es.getREST().get(this.getResourceURI(), format).then((data) => {
        factory.updateOrCreateResource(this, { resource: data }, true);
        return this._resource;
      });
    }
    return es.handleAsync(promise, 'getResource');
  }

  /**
   * @returns {rdfjson/Graph}
   */
  getReferrersGraph() {
    return this._relation;
  }

  /**
   * a list of URIs that has referred to this Entry using various properties.
   *
   * @param {string} prop
   * @returns {string[]}
   */
  getReferrers(prop) {
    return this._relation.find(null, prop, null).map(stmt => stmt.getSubject());
  }

  /**
   * a list of entry URIs corresponding to list entries where this entry is contained.
   * @returns {string[]}
   */
  getParentLists() {
    const listResourceURIArr = this.getReferrers('http://entrystore.org/terms/hasListMember');
    return listResourceURIArr.map(resURI =>
      factory.getEntryURIFromURI(this.getEntryStore(), resURI), this);
  }

  /**
   * a list of entry URIs corresponding to groups where this user entry is member.
   * @returns {string[]}
   */
  getParentGroups() {
    const groupResourceURIArr = this.getReferrers('http://entrystore.org/terms/hasGroupMember');
    return groupResourceURIArr.map(resURI =>
      factory.getEntryURIFromURI(this.getEntryStore(), resURI), this);
  }

  /**
   * Is the resource of this entry of the GraphType list?
   * @returns {boolean}
   */
  isList() {
    return this.getEntryInfo().getGraphType() === types.GT_LIST;
  }

  /**
   * Is the resource of this entry of the Graphtype resultlist?
   * @returns {boolean}
   */
  isResultList() {
    return this.getEntryInfo().getGraphType() === types.GT_RESULTLIST;
  }

  /**
   * Is the resource of this entry of the GraphType context?
   * @returns {boolean}
   */
  isContext() {
    return this.getEntryInfo().getGraphType() === types.GT_CONTEXT;
  }

  /**
   * Is the resource of this entry of the GraphType systemcontext?
   * @returns {boolean}
   */
  isSystemContext() {
    return this.getEntryInfo().getGraphType() === types.GT_SYSTEMCONTEXT;
  }

  /**
   * Is the resource of this entry of the GraphType user?
   * @returns {boolean}
   */
  isUser() {
    return this.getEntryInfo().getGraphType() === types.GT_USER;
  }

  /**
   * Is the resource of this entry of the GraphType group?
   * @returns {boolean}
   */
  isGroup() {
    return this.getEntryInfo().getGraphType() === types.GT_GROUP;
  }

  /**
   * Is the resource of this entry of the GraphType graph?
   * @returns {boolean}
   */
  isGraph() {
    return this.getEntryInfo().getGraphType() === types.GT_GRAPH;
  }

  /**
   * Is the resource of this entry of the GraphType pipeline?
   * @returns {boolean}
   */
  isPipeline() {
    return this.getEntryInfo().getGraphType() === types.GT_PIPELINE;
  }

  /**
   * Is the resource of this entry of the GraphType pipelineresult?
   * @returns {boolean}
   */
  isPipelineResult() {
    return this.getEntryInfo().getGraphType() === types.GT_PIPELINERESULT;
  }

  /**
   * Is the resource of this entry of the GraphType string?
   * @returns {boolean}
   */
  isString() {
    return this.getEntryInfo().getGraphType() === types.GT_STRING;
  }

  /**
   * Is the resource of this entry of the GraphType none?
   * @returns {boolean}
   */
  isNone() {
    return this.getEntryInfo().getGraphType() === types.GT_NONE;
  }

  /**
   * Is this entry of the EntryType link?
   * @returns {boolean}
   */
  isLink() {
    return this.getEntryInfo().getEntryType() === types.ET_LINK;
  }

  /**
   * Is this entry of the EntryType reference?
   * @returns {boolean}
   */
  isReference() {
    return this.getEntryInfo().getEntryType() === types.ET_REF;
  }

  /**
   * Is this entry of the EntryType linkreference?
   * @returns {boolean}
   */
  isLinkReference() {
    return this.getEntryInfo().getEntryType() === types.ET_LINKREF;
  }

  /**
   * Is the entry of the EntryType link, linkreference or reference?
   * That is, the resource can be controlled via {@link EntryInfo#setResourceURI}.
   *
   * @returns {boolean} true if entrytype is NOT local.
   */
  isExternal() {
    return this.getEntryInfo().getEntryType() !== types.ET_LOCAL;
  }

  /**
   * Is the EntryType local, i.e. the resources URI is maintained
   * automatically by the repository for this entry.
   * Opposite to {@link Entry#isLinkLike}.
   *
   * @returns {boolean}
   */
  isLocal() {
    return this.getEntryInfo().getEntryType() === types.ET_LOCAL;
  }

  /**
   * Is the entry a local link/linkreference/reference to another entry in the repository.
   * That is, true if the entry is a link, linkreference or reference AND the resource URI
   * belongs to another entry in the same repository.
   *
   * @returns {boolean}
   */
  isLinkToEntry() {
    const base = this.getEntryStore().getBaseURI();
    return this.isExternal() && this.getResourceURI().substr(0, base.length) === base;
  }

  /**
   * Is the entry is a link to another entry (as either a link, linkreference or reference) the
   * linked to entry is returned in a promise.
   *
   * @returns {Promise.<Entry>|undefined} undefined only if the entry does not link to another entry.
   */
  getLinkedEntry() {
    if (this.isLinkToEntry()) {
      // In case the link is to the resource URI rather than the entry URI, we extract
      // the entry id and context id and rebuild the entry URI.
      const es = this.getEntryStore();
      const resourceURI = this.getResourceURI();
      const entryId = es.getEntryId(resourceURI);
      const contextId = es.getContextId(resourceURI);
      const entryURI = es.getEntryURI(contextId, entryId);
      return es.handleAsync(this.getEntryStore().getEntry(entryURI), 'getLinkedEntry');
    }

    return undefined;
  }

  /**
   * Is the entry an information resource?
   * @returns {boolean}
   */
  isInformationResource() {
    return this.getEntryInfo().getResourceType() === types.RT_INFORMATIONRESOURCE;
  }

  /**
   * Is the entry a named resource?
   * @returns {boolean}
   */
  isNamedResource() {
    return this.getEntryInfo().getResourceType() === types.RT_NAMEDRESOURCE;
  }

  /**
   * Is the current user an owner of this entry?
   * @returns {boolean}
   */
  canAdministerEntry() {
    return this._rights.administer || false;
  }

  /**
   * Is the current user authorized to read the resource of this entry?
   * @returns {boolean}
   */
  canReadResource() {
    return this._rights.administer || this._rights.readresource
      || this._rights.writeresource || false;
  }

  /**
   * Is the current user authorized to write the resource of this entry?
   * @returns {boolean}
   */
  canWriteResource() {
    return this._rights.administer || this._rights.writeresource || false;
  }

  /**
   * Is the current user authorized to read the metadata of this entry?
   * @returns {boolean}
   */
  canReadMetadata() {
    return this._rights.administer || this._rights.readmetadata
      || this._rights.writemetadata || false;
  }

  /**
   * Is the current user authorized to write the metadata of this entry?
   * @returns {boolean}
   */
  canWriteMetadata() {
    return this._rights.administer || this._rights.writemetadata || false;
  }

  /**
   * Whether this entry is available publically or not.
   * To make sure this method returns a boolean make sure the contexts entry is loaded, e.g. via:
   * entry.getContext().getEntry().then(function() {
   *    if (entry.isPublic()) {...} //Or whatever you need to do with the isPublic method.
   * }
   *
   * @returns {boolean|undefined} undefined only if the entry has no ACL and the contexts entry
   * which specifies the default access is not cached, otherwise a boolean is returned.
   */
  isPublic() {
    const guestPrincipal = this.getEntryStore().getResourceURI('_principals', '_guest');
    let acl = this.getEntryInfo().getACL();
    if (acl.contextOverride) {
      return ['rwrite', 'rread', 'mwrite', 'mread'].some(key => acl[key].indexOf(guestPrincipal) !== -1);
    }
    const ce = this.getContext().getEntry(true);
    if (ce == null) {
      return undefined;
    }
    acl = ce.getEntryInfo().getACL();
    return ['rwrite', 'rread'].some(key => acl[key].indexOf(guestPrincipal) !== -1);
  }

  /**
   * Whether this entry is available to the specified user.
   * To make sure this method returns a boolean and not undefined,
   * make sure that the contexts entry is loaded, e.g. via:
   *
   * entry.getContext().getEntry().then(function() {
   *    //And then do you check, e.g.:
   *    entry.getEntryStore().getUserEntry().then(function(currentUserEntry) {
   *       if (entry.isPrivateTo(currentUserEntry) {...}
   *    })
   * }
   *
   * @returns {boolean|undefined} undefined if the contexts entry which
   * specifies the default access is not cached, otherwise a boolean is returned.
   */
  isPrivateTo(userEntry) {
    const userPrincipal = userEntry.getResourceURI();
    const acl = this.getEntryInfo().getACL();
    const ce = this.getContext().getEntry(true);
    if (ce == null) {
      return undefined;
    }
    const cacl = ce.getEntryInfo().getACL();
    if (cacl.admin.length !== 1 || acl.admin[0] !== userPrincipal) {
      return false;
    }
    if (acl.contextOverride) {
      return acl.admin.length === 1 && acl.admin[0] === userPrincipal;
    }
    return true;
  }

  /**
   * Deletes this entry without any option to recover it.
   * @param {boolean} recursive if true and the entry is a list it will delete the entire tree of
   * lists and all entries that is only contained in the current list or any of its child lists.
   * @return {Promise} which on success indicates that the deletion has succeeded.
   */
  async del(recursive = false) {
    const es = this.getEntryStore();
    const uri = `${this.getURI()}${recursive ? '?recursive=true' : ''}`;
    await es.handleAsync(es.getREST().del(uri), 'delEntry');
    es.getCache().unCache(this);
  }

  /**
   * That an entry needs to be refreshed typically means that it contains stale data
   * (with respect to what is available in the store).
   * The entry should be refresh before it is further used.
   *
   * @param {boolean=} silently the cache will send out a stale message (to all registered
   * listeners of the cache) for this entry if the value is false or undefined.
   * @see store.Entry#refresh.
   */
  setRefreshNeeded(silently = true) {
    this.getEntryStore().getCache().setRefreshNeeded(this, silently);
  }

  /**
   * Tells whether an entry needs to be refreshed.
   *
   * @return {boolean} true if the entry need to be refreshed before used.
   * @see Entry#refresh.
   */
  needRefresh() {
    return this.getEntryStore().getCache().needRefresh(this);
  }

  /**
   * Refreshes an entry if needed, that is, if it has been marked as invalid.
   * @param {boolean=} silently the cache will send out a refresh message for this entry
   * if a refresh was needed AND if the value of silently is false or undefined. If force is true
   * it will send out a refresh message anyhow.
   * @param {boolean=} [force=false] If true the entry will be refreshed independent if it was marked in need
   * of a refresh or not.
   */
  refresh(silently = true, force = false) {
    const es = this.getEntryStore();
    let p;
    if (force === true || es.getCache().needRefresh(this)) {
      const entryURI = this.getURI();
      p = es.getREST().get(factory.getEntryLoadURI(entryURI)).then((data) => {
        factory.update(this, data);
        es.getCache().cache(this, silently);
        return this;
      });
    } else {
      p = Promise.resolve(this);
    }
    return es.handleAsync(p, 'refresh');
  }
  /**
   *
   * Retrieves a projection, a plain object with simple attribute value pairs given mapping.
   * The subject will always be the resource uri of the entry.
   * The mapping is an object where the same attributes appear but with the predicates are values.
   * Hence, each attribute gives rise to a search for all statements with the given subject and
   * the predicate specified by the attribute.
   * The result object will contain the mapping attributes with values from the the first
   * matched statements object value if there are any.
   * To access additional information like multiple statement or the statements
   * (type, language, datatype) a "*" prepended version of each attribute can be provided that
   * contains a list of matching Statements if so indicated by the multipleValueStyle parameter.
   *
   * @param {Object} mappings the mapping configuration
   * @param {String} multipleValueStyle if provided an array is provided for that property
   * prefixed with "*", the array should be indicated to be either
   * "statements", "values" or "objects".
   * @returns {Object}
   * @see rdfjson/Graph
   * @example
   * var proj = entry.projection({
   *     "title":       "http://purl.org/dc/terms/title",
   *     "description": "http://purl.org/dc/terms/description"
   * });
   * // The object proj now has the attributes title, *title, description, and *description.
   *
   * // Accessing the title of http://example.com
   * console.log(proj.title);
   *
   * // To get hold of additional information available in the statement,
   * // for instance the language of a literal:
   * console.log(proj["*title"][0].getLanguage())
   *
   */
  projection(mappings = {}, multipleValueStyle = 'none') {
    return this._metadata.projection(this.getResourceURI(), mappings, multipleValueStyle);
  }
};