qik.content.js

///////////////////////////////////////////////////

/**
 * Creates a new QikContent instance.
 * This module provides a number of helper functions for creating and modifying content via the REST API
 * @alias content
 * @constructor
 * @hideconstructor
 * @param {QikCore} qik A reference to the parent instance of the QikCore module. This module is usually created by a QikCore instance that passes itself in as the first argument.
 */
export default function (qik) {
  if (!qik.api) {
    throw new Error(`Can't Instantiate QikContent before QikAPI exists`);
  }

  ///////////////////////////////////////////////////

  var service = {
    debug: false,
    loadingGlossary: false,
  };

  ///////////////////////////////////////////////////////////////////////////////

  const variables = {};
  let inflightVariablesRequest;

  /**
   *
   * Retrieves all global variables for the current user. This is often used when running custom code in an action.
   * @alias content.variables
   * @param  {Array} keys Provide specific keys of variables you want to retrieve
   * @param  {Object} options Additional options when making the request
   * @param  {Boolean} options.reload Force variables to reload and not be cached. If false will retrieve any variables that are already known from the in memory cache.
   * @example
   *
   * const { OAUTH_CLIENT_ID, OAUTH_KEY } = await sdk.content.variables();
   */

  service.variables = async function (keys, options) {
    options = options || {};
    keys = keys || [];

    var reload = options.refresh || options.reload || !variables.data;
    if (reload) {
      if (!inflightVariablesRequest) {
        inflightVariablesRequest = qik.api.post(`/variables`, { keys });
        inflightVariablesRequest.then(resolveRequest, resolveRequest);
        function resolveRequest() {
          inflightVariablesRequest = null;
        }
      }

      const { data } = await inflightVariablesRequest;
      variables.data = data;
      qik.dispatch("variables", data);
    }

    return variables.data;
  };

  ///////////////////////////////////////////////////

  const glossaryCache = qik.cache.get("user:glossary");

  let inflightGlossaryRequest;

  /**
   * Yield to the event loop to keep the UI responsive
   * @private
   */
  function yieldToMain() {
    return new Promise((resolve) => {
      if (typeof requestIdleCallback === "function") {
        requestIdleCallback(resolve, { timeout: 50 });
      } else {
        setTimeout(resolve, 0);
      }
    });
  }

  /**
   * Inflate compressed glossary data from the /glossary/compressed endpoint
   * Processes in chunks to avoid blocking the main thread
   * @private
   */
  async function inflateGlossary({ _s: strings, _k: keyMap, types }) {
    function inflate(val) {
      if (typeof val === "string" && val[0] === "~") {
        return strings[parseInt(val.slice(1))];
      }
      if (Array.isArray(val)) return val.map(inflate);
      if (val && typeof val === "object") {
        const result = {};
        for (const [k, v] of Object.entries(val)) {
          result[keyMap[k] || k] = inflate(v);
        }
        return result;
      }
      return val;
    }

    const CHUNK_SIZE = 50;
    const results = [];

    for (let i = 0; i < types.length; i += CHUNK_SIZE) {
      const chunk = types.slice(i, i + CHUNK_SIZE);
      results.push(...chunk.map(inflate));

      // Yield to main thread between chunks
      if (i + CHUNK_SIZE < types.length) {
        await yieldToMain();
      }
    }

    return results;
  }

  /**
     *
     * Retrieves the glossary of all content types visible to the requesting user.
     By default this will include all fields, validation, expressions and other configuration
     * @alias content.glossary
     * @param  {Object} options Additional options
     * @param  {Boolean} options.hash Whether to return the data as a keyed object
     allowing for fast selection of specific content types, by default will return as an array
     * @param  {Boolean} options.reload Force glossary to reload and not be cached.
      If false will retrieve content type data from the in memory cache
     * @param  {Boolean} options.uncompressed Use the uncompressed endpoint
     * @example
     *
     * const { article, profile } = await sdk.content.glossary({hash:true});
     * // Use compressed endpoint for faster loading
     * const glossary = await sdk.content.glossary({compressed:true});
     */
  service.glossary = async function (options) {
    options = options || {};

    // Check if we already have the glossary cached
    let cached = glossaryCache.get("glossary");
    const uncompressed = !!options.uncompressed;
    let compressed = !uncompressed;
    var reload = options.refresh || options.reload || !cached;

    // If we are forcing a reload
    if (reload) {
      if (!inflightGlossaryRequest) {
        service.loadingGlossary = true;

        // Use compressed endpoint if requested
        const endpoint = compressed ? `/glossary/compressed` : `/glossary`;
        inflightGlossaryRequest = qik.api.get(endpoint, { cache: false });
        inflightGlossaryRequest.then(resolveRequest, resolveRequest);
        function resolveRequest() {
          inflightGlossaryRequest = null;
          service.loadingGlossary = false;
        }
      }

      // Now we have the glossary
      const { data } = await inflightGlossaryRequest;

      // Inflate if compressed format (async to avoid blocking UI)
      const glossaryData =
        data._s && data._k && data.types ? await inflateGlossary(data) : data;

      // Cache it for later
      glossaryCache.set("glossary", glossaryData);
      cached = glossaryData;
      qik.dispatch("glossary", glossaryData);
    }

    if (options.hash) {
      var result = qik.utils.hash(cached, "key");
      return result;
    }

    if (options.hex) {
      var result = qik.utils.hash(cached, "hex");
      return result;
    }

    return cached;
  };

  ///////////////////////////////////////////////////

  const scopeGlossary = {};

  let inflightScopeGlossaryRequest;

  /**
     *
     * Retrieves the scope glossary of all scopes the user can know about. This helps to convert a scope id into a human readable title.
     * @alias content.scopeGlossary
     * @param  {Object} options Additional options
     * @param  {Boolean} options.hash Whether to return the data as a keyed object with each scopes _id as the key
     allowing for fast selection of specific scopes, by default will return a structured tree
     * @param  {Boolean} options.reload Force the glossary to reload and not be cached.
      If false will retrieve content type data from the in memory cache
     * @example
     *
     * const scopes = await sdk.content.scopeGlossary();
     */

  service.scopeGlossary = async function (options) {
    options = options || {};

    var reload = options.refresh || options.reload || !scopeGlossary.data;

    if (reload) {
      if (!inflightScopeGlossaryRequest) {
        inflightScopeGlossaryRequest = qik.api.get(`/scope/glossary`, {
          cache: false,
        });
        inflightScopeGlossaryRequest.then(resolveRequest, resolveRequest);
        function resolveRequest() {
          inflightScopeGlossaryRequest = null;
        }
      }

      const { data } = await inflightScopeGlossaryRequest;
      scopeGlossary.data = data;
    }

    if (options.hash) {
      var result = qik.utils.hash(scopeGlossary.data, "_id");
      return result;
    }

    return scopeGlossary.data;
  };

  ///////////////////////////////////////////////////

  /**
   *
   * Retrieves all available filter comparators for each data type
   * @alias content.comparators
   * @param  {Object} options Additional options
   * @param  {Boolean} options.reload Ignore any locally cached data
   * @example
   * const {hash, available, types} = await sdk.content.comparators();
   * console.log(available) // {boolean:[{title:'Is equal to', operator:'equal'...}]}
   * console.log(hash) // {equal:[{title:'Is equal to', operator:'equal'...}]}
   */

  const comparators = {};

  service.comparators = async function (options) {
    options = options || {};

    var reload = options.reload || options.refresh || !comparators.data;

    if (reload) {
      const { data } = await qik.api.get(`/system/comparators`);

      /////////////////////////////

      data.available = {};

      Object.entries(data.types).forEach(([key, value]) => {
        data.available[key] = value.map(function (comparatorKey) {
          return data.hash[comparatorKey];
        });
      });

      /////////////////////////////

      comparators.data = data;
    }

    // if (options.hash) {
    //     var result = qik.utils.hash(comparators.data, 'key');
    //     return result;
    // }

    return comparators.data;
  };

  ///////////////////////////////////////////////////

  function getLimits(fieldDefinition) {
    var { minimum, maximum } = fieldDefinition;
    minimum = qik.utils.parseInt(minimum);
    minimum = Math.max(minimum, 0);

    maximum = qik.utils.parseInt(maximum);
    maximum = Math.max(maximum, 0); // cant be less than 0
    if (maximum == 0) {
      //Unlimited maximum
    } else {
      maximum = Math.max(maximum, minimum); // cant be less than the minimum
    }
    return { minimum, maximum };
  }

  service.meetsValidationRequirements = function (
    input,
    fieldType,
    validationCriteria,
  ) {
    if (validationCriteria.minLength) {
      var stringLength = String(input).length;
      if (stringLength < validationCriteria.minLength) {
        return `Must be at least ${validationCriteria.minLength} characters`;
      }
    }

    if (validationCriteria.maxLength) {
      var stringLength = String(input).length;
      if (stringLength > validationCriteria.maxLength) {
        return `Can be no more than ${validationCriteria.maxLength} characters`;
      }
    }

    if (validationCriteria.minValue) {
      if (input !== undefined) {
        var number = Number(input);
        if (number < validationCriteria.minValue) {
          return `Must be at least ${validationCriteria.minValue}`;
        }
      }
    }

    if (validationCriteria.maxValue) {
      if (input !== undefined) {
        var number = Number(input);
        if (number > validationCriteria.maxValue) {
          return `Can be no greater than ${validationCriteria.maxValue}`;
        }
      }
    }
  };

  /**
   * Checks if a certain input validates against a field definition
   * @alias content.validateField
   * @param  {Any} input The input to validate
   * @param  {Object} fieldDefinition The field to validate against
   * @param  {Object} options Additional options when calling the function
   * @example
   * const validationResult = await sdk.content.validateField('Johnny Bobbins', {title:'Name', key:'firstName', type:'string', minimum:1, maximum:1, ...});
   * console.log(validationResult)
   * // Results in { valid:true }
   *
   * const validationResult = await sdk.content.validateField('Johnny Bobbins', {title:'Number', key:'number', type:'integer', minimum:1, maximum:1, ...});
   * console.log(validationResult)
   * // Results in { valid:false, status:400, message:'Invalid number input for field' }
   */

  service.validateField = function (input, fieldDefinition, options) {
    options = options || {};

    var fieldType = fieldDefinition.type || "string";
    var { minimum, maximum } = getLimits(fieldDefinition);

    var isObject = fieldDefinition.type == "group" && fieldDefinition.asObject;
    var singleValue = isObject
      ? minimum === maximum && maximum === 1
      : maximum === 1;
    var multiValue = !singleValue;

    //////////////////

    var isNumeric;
    switch (fieldType) {
      case "integer":
      case "decimal":
      case "number":
      case "float":
        isNumeric = true;
        break;
    }

    //////////////////

    var inputWasProvided = qik.utils.exists(input);

    // A required boolean field means "must be true" — false counts as missing.
    if (fieldDefinition.type === "boolean" && input !== true) {
      inputWasProvided = false;
    }

    //If an answer is required
    if (minimum) {
      //but none was provided
      if (!inputWasProvided) {
        //Throw an error
        return {
          valid: false,
          message: `${fieldDefinition.title} is a required field`,
          status: 400,
        };
      }
    } else {
      //No answer is needed and none was provided
      if (!inputWasProvided) {
        return {
          valid: true,
        };
      }
    }

    //////////////////

    //If we are requiring multiple values
    if (multiValue) {
      //But the input is not an array
      if (!Array.isArray(input)) {
        if (minimum) {
          return {
            valid: false,
            message: `${fieldDefinition.title} requires at least ${minimum} values`,
            status: 400,
          };
        } else {
          console.log("NOT PROVIDED AS ARRAY", fieldDefinition.title, input);
          return {
            valid: false,
            message: `${fieldDefinition.title} must be provided as an array`,
            status: 400,
          };
        }
      }

      ////////////////////////////

      var compacted = input;

      compacted = compacted.filter(function (v) {
        //Check if we care about this value
        var empty = v === undefined || v === null || v === "";
        return !empty;
      });

      // console.log('COMPACTED', input.length, compacted.length);

      ////////////////////////////

      //We need an exact number of answers
      if (minimum == maximum) {
        //But we don't have the number of answers needed
        if (minimum && compacted.length != minimum) {
          return {
            valid: false,
            message: `${fieldDefinition.title} requires exactly ${maximum} values`,
            status: 400,
          };
        }
      }

      //We have too many answers
      if (maximum && compacted.length > maximum) {
        return {
          valid: false,
          message: `${fieldDefinition.title} requires less than ${maximum + 1} values`,
          status: 400,
        };
      }

      //We don't have enough answers
      if (compacted.length < minimum) {
        // console.log(fieldDefinition.title, 'INPUT', input.length, minimum, input, compacted)

        return {
          valid: false,
          message: `${fieldDefinition.title} requires at least ${minimum} values`,
          status: 400,
        };
      }

      ////////////////////////////

      var foundBadEntry;

      //Find any bad values
      var badEntry = compacted.find(function (val) {
        var valueFieldType = fieldType;

        if (fieldDefinition.type === "group") {
          valueFieldType = "object";
        }

        if (
          fieldDefinition.type === "reference" &&
          fieldDefinition.widget === "form"
        ) {
          valueFieldType = "object";
        }

        var isValid = qik.utils.isValidValue(
          val,
          valueFieldType,
          options.strict,
        );

        ////////////////////////////

        if (!isValid) {
          foundBadEntry = true;
          return true;
        }

        ////////////////////////////

        //Is there additional validation requirements
        if (fieldDefinition.validation) {
          var additionalValidationErrors = service.meetsValidationRequirements(
            val,
            fieldType,
            fieldDefinition.validation,
          );
          if (additionalValidationErrors) {
            foundBadEntry = true;
            return true;
          }
        }
      });

      if (foundBadEntry) {
        let badValueMessage = `Invalid input for ${fieldDefinition.title}`;
        return {
          valid: false,
          message: badValueMessage,
          status: 400,
        };
      }
    } else {
      var dataType = fieldType;
      var widgetType = fieldDefinition.widget;
      if (dataType === "group") {
        dataType = "object";
      }

      if (dataType === "reference" && widgetType === "form") {
        dataType = "object";
      }

      //////////////////

      var cleanedValue = service.getCleanedValue(input, dataType, options);

      //////////////////

      var isValidValue = qik.utils.isValidValue(
        cleanedValue,
        dataType,
        options.strict,
      );

      //////////////////

      //Invalid input
      if (!isValidValue) {
        return {
          valid: false,
          message: `Single value '${input}' is not a valid ${dataType} for ${fieldDefinition.title}`,
          criteria: {
            isValidValue,
            cleanedValue,
            fieldType,
            options,
          },
          status: 400,
        };
      }

      //Is there additional validation requirements
      let hasAdditionalValidation = false;

      const validation = {};
      if (String(fieldDefinition.minValue)) {
        validation.minValue = parseInt(fieldDefinition.minValue);
        hasAdditionalValidation = true;
      }
      if (String(fieldDefinition.maxValue)) {
        validation.maxValue = parseInt(fieldDefinition.maxValue);
        hasAdditionalValidation = true;
      }
      if (String(fieldDefinition.minLength)) {
        validation.minLength = parseInt(fieldDefinition.minLength);
        hasAdditionalValidation = true;
      }
      if (String(fieldDefinition.maxLength)) {
        validation.maxLength = parseInt(fieldDefinition.maxLength);
        hasAdditionalValidation = true;
      }

      if (hasAdditionalValidation) {
        const validationCriteria = Object.assign(
          {},
          validation,
          fieldDefinition.validation || {},
        );
        var additionalValidationError = service.meetsValidationRequirements(
          input,
          fieldType,
          validationCriteria,
        );
        if (additionalValidationError) {
          return {
            valid: false,
            message: additionalValidationError,
            status: 400,
          };
        }
      }
    }

    //////////////////

    return {
      valid: true,
    };
  };

  service.getCleanedValue = function (input, dataType, options) {
    switch (dataType) {
      case "number":
      case "float":
      case "decimal":
        if (!qik.utils.exists(input)) {
          return undefined;
        } else {
          return Number(input);
        }
        break;
      case "integer":
        if (!qik.utils.exists(input)) {
          return undefined;
        } else {
          return parseInt(input);
        }
        break;
      case "boolean":
        if (!qik.utils.exists(input)) {
          return undefined;
        }
        return qik.utils.parseBoolean(input);
        break;
      case "email":
        return options.strict ? input : String(input).toLowerCase();
        break;
      case "reference":
        return options.strict ? input : qik.utils.id(input);
        break;
      default:
        return options.strict
          ? input
          : qik.utils.cleanValue(input, dataType, options);
        break;
    }

    return input;
  };

  ///////////////////////////////////////////////////

  /**
   *
   * Retrieves a list of records matching the provided criteria
   * @alias content.list
   * @param  {String} type The type or definition of records we want to retrieve
   * @param  {Object} options The options for our query
   * @param  {String} options.search Freeform text keywords
   * @param  {Object} options.sort How to sort the results
   * @param  {String} options.sort.key Which key to sort on
   * @param  {String} options.sort.direction Which direction to sort on
   * @param  {Object} options.sort.type What type of data is being sorted
   * @param  {Object} options.page Page configuration
   * @param  {Number} options.page.size Page size
   * @param  {Number} options.page.index Page index
   * @param  {Object} options.filter How to filter the results
   * @example
   *
   *
   * sdk.content.list('profile', {
   *     search:'Jim',
   *     page:{
   *         size:50,
   *         index:2,
   *     },
   *     sort:{
   *         key:'age',
   *         direction:'asc',
   *         type:'integer',
   *     },
   *     filter:{
   *         operator:'and',
   *         filters:[{
   *             key:'age',
   *             comparator:'>',
   *             value:5,
   *         }],
   *     },
   * })
   */

  service.list = async function (type, options, advanced) {
    if (!advanced) {
      advanced = {};
    }

    const remoteURL = advanced.remoteURL || `/content/${type}/list`;

    if (advanced.cancellable) {
      if (!advanced.config) {
        advanced.config = {};
      }

      //Create a cancel token
      const CancelToken = qik.api.CancelToken;
      const source = CancelToken.source();
      advanced.config.cancelToken = source.token;

      const promise = qik.api.post(remoteURL, options, advanced.config);
      return {
        promise,
        cancel(message) {
          source.cancel(message || "Operation canceled by the user.");
        },
      };
    } else {
      const { data } = await qik.api.post(remoteURL, options, advanced.config);
      return data;
    }
  };

  ///////////////////////////////////////////////////

  /**
   *
   * Create an item
   * @alias content.create
   * @param  {String} type The type or definition of the record we want to create
   * @param  {Object} input The data for our new record
   * @example
   *
   *
   * const result = await sdk.content.create('profile', {
   *     firstName:'Mickey',
   *     lastName:'Mouse',
   *     gender:'male',
   *     meta:{
   *       scopes:['61eca4746971e75c1fc670cf'],
   *     }
   * })
   */

  service.create = async function (type, input) {
    const dataModel = { ...input };

    // Sanitize down to just ids before we send
    if (dataModel.meta?.scopes) {
      dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
    }

    const { data } = await qik.api.post(`/content/${type}/create`, dataModel);
    return data;
  };

  /**
   *
   * Update an item, Only fields the user has permission to view will be returned
   * @alias content.update
   * @param  {String} id The id of the record we want to update
   * @param  {Object} input The data to update
   * @example
   *
   * const result = await sdk.content.update('61eca4746971e75c1fc670cd', {
   *     firstName:'Minnie',
   *     lastName:'Mouse',
   *     gender:'female',
   *     meta:{
   *       scopes:['61eca4746971e75c1fc670cd'],
   *     }
   * })
   */
  service.update = async function (id, input) {
    id = qik.utils.id(id);

    const dataModel = { ...input };

    // Sanitize down to just ids before we send
    if (dataModel.meta?.scopes) {
      dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
    }

    const { data } = await qik.api.put(`/content/${id}`, dataModel);
    return data;
  };

  /**
   *
   * Partially update and patch an item, Only fields the user has permission to edit will be updated
   * @alias content.patch
   * @param  {String} id The id of the record we want to update
   * @param  {Object} input The data to update, this will be merged with existing data
   * @example
   *
   * const result = await sdk.content.patch('61eca4746971e75c1fc670cd', {
   *     firstName:'Mickey',
   *     gender:'male',
   * })
   */
  service.patch = async function (id, input) {
    id = qik.utils.id(id);

    const dataModel = { ...input };

    // Sanitize down to just ids before we send
    if (dataModel.meta?.scopes) {
      dataModel.meta.scopes = qik.utils.ids(dataModel.meta.scopes);
    }

    const { data } = await qik.api.patch(`/content/${id}`, dataModel);
    return data;
  };

  /**
   *
   * Get an item from the database, Only fields the user has permission to view will be returned
   * @alias content.get
   * @param  {String} id The id of the record we want to update
   * @example
   *
   * const result = await sdk.content.get('61eca4746971e75c1fc670cd')
   */
  service.get = async function (id, params) {
    id = qik.utils.id(id);
    params = params || {};

    const { data } = await qik.api.get(`/content/${id}`, params);
    return data;
  };

  service.getFromID = async function (id) {
    return service.get(id);
  };

  /**
   *
   * Delete an item from the database
   * @alias content.delete
   * @param  {String} id The id of the record we want to delete
   * @example
   * const result = await sdk.content.delete('61eca4746971e75c1fc670cd')
   */
  service.delete = async function (id, input) {
    id = qik.utils.id(id);
    const { data } = await qik.api.delete(`/content/${id}`, input);
    return data;
  };

  /**
   *
   * Restore a deleted item from the database
   * @alias content.restore
   * @param  {String} id The id of the record we want to restore
   * @example
   * const result = await sdk.content.restore('61eca4746971e75c1fc670cd')
   */
  service.restore = async function (id, input) {
    id = qik.utils.id(id);
    const { data } = await qik.api.get(`/content/${id}/restore`, input);
    return data;
  };

  /**
   *
   * Retrieve an item from the database by providing it's 'slug'
   * @alias content.getFromSlug
   * @param  {String} slug The slug id of the record we want to retrieve
   * it must be provided as either `(type):(slug)`` or `(definition):(slug)`
   * (if unsure use the `meta.abs` absolute slug property of the item you are wanting to retrieve).
   * @example
   * const result = await sdk.content.getFromSlug('article:how-to-get-started')
   * const result = await sdk.content.getFromSlug('car:toyota-landcruiser')
   * const result = await sdk.content.getFromSlug('article:toyota-landcruiser')
   */
  service.getFromSlug = async function (slug) {
    const { data } = await qik.api.get(`/content/slug/${slug}`);
    return data;
  };

  // service.getFromSlug = async function(type, slug) {
  //     const { data } = await qik.api.get(`/content/${type}/slug/${slug}`);
  //     return data;
  // }

  // service.getFromExternalID = async function(type, externalID) {
  //     const { data } = await qik.api.get(`/content/${type}/external/${externalID}`);
  //     return data;
  // }

  return service;
}