stringify.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * Portions Copyright (C) Philipp Kewisch */

import design from "./design.js";
import { foldline } from "./helpers.js";

/**
 * This lets typescript resolve our custom types in the
 * generated d.ts files (jsdoc typedefs are converted to typescript types).
 * Ignore prevents the typedefs from being documented more than once.
 *
 * @ignore
 * @typedef {import("./types.js").designSet} designSet
 * Imports the 'designSet' type from the "types.js" module
 */

const LINE_ENDING = '\r\n';
const DEFAULT_VALUE_TYPE = 'unknown';
const RFC6868_REPLACE_MAP = { '"': "^'", "\n": "^n", "^": "^^" };

/**
 * Convert a full jCal/jCard array into a iCalendar/vCard string.
 *
 * @function ICAL.stringify
 * @variation function
 * @param {Array} jCal    The jCal/jCard document
 * @return {String}       The stringified iCalendar/vCard document
 */
export default function stringify(jCal) {
  if (typeof jCal[0] == "string") {
    // This is a single component
    jCal = [jCal];
  }

  let i = 0;
  let len = jCal.length;
  let result = '';

  for (; i < len; i++) {
    result += stringify.component(jCal[i]) + LINE_ENDING;
  }

  return result;
}

/**
 * Converts an jCal component array into a ICAL string.
 * Recursive will resolve sub-components.
 *
 * Exact component/property order is not saved all
 * properties will come before subcomponents.
 *
 * @function ICAL.stringify.component
 * @param {Array} component
 *        jCal/jCard fragment of a component
 * @param {designSet} designSet
 *        The design data to use for this component
 * @return {String}       The iCalendar/vCard string
 */
stringify.component = function(component, designSet) {
  let name = component[0].toUpperCase();
  let result = 'BEGIN:' + name + LINE_ENDING;

  let props = component[1];
  let propIdx = 0;
  let propLen = props.length;

  let designSetName = component[0];
  // rfc6350 requires that in vCard 4.0 the first component is the VERSION
  // component with as value 4.0, note that 3.0 does not have this requirement.
  if (designSetName === 'vcard' && component[1].length > 0 &&
          !(component[1][0][0] === "version" && component[1][0][3] === "4.0")) {
    designSetName = "vcard3";
  }
  designSet = designSet || design.getDesignSet(designSetName);

  for (; propIdx < propLen; propIdx++) {
    result += stringify.property(props[propIdx], designSet) + LINE_ENDING;
  }

  // Ignore subcomponents if none exist, e.g. in vCard.
  let comps = component[2] || [];
  let compIdx = 0;
  let compLen = comps.length;

  for (; compIdx < compLen; compIdx++) {
    result += stringify.component(comps[compIdx], designSet) + LINE_ENDING;
  }

  result += 'END:' + name;
  return result;
};

/**
 * Converts a single jCal/jCard property to a iCalendar/vCard string.
 *
 * @function ICAL.stringify.property
 * @param {Array} property
 *        jCal/jCard property array
 * @param {designSet} designSet
 *        The design data to use for this property
 * @param {Boolean} noFold
 *        If true, the line is not folded
 * @return {String}       The iCalendar/vCard string
 */
stringify.property = function(property, designSet, noFold) {
  let name = property[0].toUpperCase();
  let jsName = property[0];
  let params = property[1];

  if (!designSet) {
    designSet = design.defaultSet;
  }

  let groupName = params.group;
  let line;
  if (designSet.propertyGroups && groupName) {
    line = groupName.toUpperCase() + "." + name;
  } else {
    line = name;
  }

  for (let [paramName, value] of Object.entries(params)) {
    if (designSet.propertyGroups && paramName == 'group') {
      continue;
    }

    let paramDesign = designSet.param[paramName];
    let multiValue = paramDesign && paramDesign.multiValue;
    if (multiValue && Array.isArray(value)) {
      value = value.map(function(val) {
        val = stringify._rfc6868Unescape(val);
        val = stringify.paramPropertyValue(val, paramDesign.multiValueSeparateDQuote);
        return val;
      });
      value = stringify.multiValue(value, multiValue, "unknown", null, designSet);
    } else {
      value = stringify._rfc6868Unescape(value);
      value = stringify.paramPropertyValue(value);
    }

    line += ';' + paramName.toUpperCase() + '=' + value;
  }

  if (property.length === 3) {
    // If there are no values, we must assume a blank value
    return line + ':';
  }

  let valueType = property[2];

  let propDetails;
  let multiValue = false;
  let structuredValue = false;
  let isDefault = false;

  if (jsName in designSet.property) {
    propDetails = designSet.property[jsName];

    if ('multiValue' in propDetails) {
      multiValue = propDetails.multiValue;
    }

    if (('structuredValue' in propDetails) && Array.isArray(property[3])) {
      structuredValue = propDetails.structuredValue;
    }

    if ('defaultType' in propDetails) {
      if (valueType === propDetails.defaultType) {
        isDefault = true;
      }
    } else {
      if (valueType === DEFAULT_VALUE_TYPE) {
        isDefault = true;
      }
    }
  } else {
    if (valueType === DEFAULT_VALUE_TYPE) {
      isDefault = true;
    }
  }

  // push the VALUE property if type is not the default
  // for the current property.
  if (!isDefault) {
    // value will never contain ;/:/, so we don't escape it here.
    line += ';VALUE=' + valueType.toUpperCase();
  }

  line += ':';

  if (multiValue && structuredValue) {
    line += stringify.multiValue(
      property[3], structuredValue, valueType, multiValue, designSet, structuredValue
    );
  } else if (multiValue) {
    line += stringify.multiValue(
      property.slice(3), multiValue, valueType, null, designSet, false
    );
  } else if (structuredValue) {
    line += stringify.multiValue(
      property[3], structuredValue, valueType, null, designSet, structuredValue
    );
  } else {
    line += stringify.value(property[3], valueType, designSet, false);
  }

  return noFold ? line : foldline(line);
};

/**
 * Handles escaping of property values that may contain:
 *
 *    COLON (:), SEMICOLON (;), or COMMA (,)
 *
 * If any of the above are present the result is wrapped
 * in double quotes.
 *
 * @function ICAL.stringify.paramPropertyValue
 * @param {String} value      Raw property value
 * @param {boolean} force     If value should be escaped even when unnecessary
 * @return {String}           Given or escaped value when needed
 */
stringify.paramPropertyValue = function(value, force) {
  if (!force &&
      (value.indexOf(',') === -1) &&
      (value.indexOf(':') === -1) &&
      (value.indexOf(';') === -1)) {

    return value;
  }

  return '"' + value + '"';
};

/**
 * Converts an array of ical values into a single
 * string based on a type and a delimiter value (like ",").
 *
 * @function ICAL.stringify.multiValue
 * @param {Array} values      List of values to convert
 * @param {String} delim      Used to join the values (",", ";", ":")
 * @param {String} type       Lowecase ical value type
 *        (like boolean, date-time, etc..)
 * @param {?String} innerMulti If set, each value will again be processed
 *        Used for structured values
 * @param {designSet} designSet
 *        The design data to use for this property
 *
 * @return {String}           iCalendar/vCard string for value
 */
stringify.multiValue = function(values, delim, type, innerMulti, designSet, structuredValue) {
  let result = '';
  let len = values.length;
  let i = 0;

  for (; i < len; i++) {
    if (innerMulti && Array.isArray(values[i])) {
      result += stringify.multiValue(values[i], innerMulti, type, null, designSet, structuredValue);
    } else {
      result += stringify.value(values[i], type, designSet, structuredValue);
    }

    if (i !== (len - 1)) {
      result += delim;
    }
  }

  return result;
};

/**
 * Processes a single ical value runs the associated "toICAL" method from the
 * design value type if available to convert the value.
 *
 * @function ICAL.stringify.value
 * @param {String|Number} value       A formatted value
 * @param {String} type               Lowercase iCalendar/vCard value type
 *  (like boolean, date-time, etc..)
 * @return {String}                   iCalendar/vCard value for single value
 */
stringify.value = function(value, type, designSet, structuredValue) {
  if (type in designSet.value && 'toICAL' in designSet.value[type]) {
    return designSet.value[type].toICAL(value, structuredValue);
  }
  return value;
};

/**
 * Internal helper for rfc6868. Exposing this on ICAL.stringify so that
 * hackers can disable the rfc6868 parsing if the really need to.
 *
 * @param {String} val        The value to unescape
 * @return {String}           The escaped value
 */
stringify._rfc6868Unescape = function(val) {
  return val.replace(/[\n^"]/g, function(x) {
    return RFC6868_REPLACE_MAP[x];
  });
};