department-of-veterans-affairs/vets-website

View on GitHub
src/applications/vaos/utils/calendar.js

Summary

Maintainability
C
1 day
Test Coverage
import moment from 'moment';
import guid from 'simple-guid';

/*
 * ICS files have a 75 character line limit. Longer fields need to be broken
 * into 75 character chunks with a CRLF in between. They also apparenly need to have a tab
 * character at the start of each new line, which is why I set the limit to 74
 * 
 * Additionally, any actual line breaks in the text need to be escaped
 */
export const ICS_LINE_LIMIT = 74;

/**
 * @summary Function that returns a collection of ICS key/value pairs.
 * @description Only a subset of the ICS object specification is used.
 * The following properties/keys are allowed:
 *
 * <ul>
 * <li>BEGIN</li>
 * <li>VERSION</li>
 * <li>PRODID</li>
 * <li>BEGIN</li>
 * <li>UID</li>
 * <li>SUMMARY</li>
 * <li>DESCRIPTION</li>
 * <li>LOCATION</li>
 * <li>DTSTAMP</li>
 * <li>DTSTART</li>
 * <li>DTEND</li>
 * <li>END</li>
 * <li>END</li>
 * </ul>
 *
 * Values are retreived from the returned collection using the defined keys. Values returned from
 * the collection can be of type string or array for keys with multiply values. The following keys have
 * multiple values:
 *
 * <ul>
 * <li>BEGIN</li>
 * <li>END</li>
 * </ul>
 *
 * The following key:
 * <ul>
 * <li>FORMATTED_TEXT</li>
 * </ul>
 *
 * is a special key used to access the formatted description text.
 *
 * @export
 * @param {string} buffer - ICS calendar string
 * @returns Returns a collection of ICS key/value pairs.
 */
export function getICSTokens(buffer) {
  const map = new Map();
  let tokens = buffer.split('\r\n');

  // Split tokens into key/value pairs using lookbehind since it is possible
  // for other text to contain the split character. The regex looks for a ':'
  // preceeded by on the keywords.
  //
  // NOTE: Look behind is not support by Safari browser. Uncomment if the feature
  // is supported in the future.
  //
  // tokens = tokens.map(t =>
  //   t.split(
  //     /(?<=BEGIN):|(?<=VERSION):|(?<=PRODID):|(?<=UID):|(?<=SUMMARY):|(?<=DESCRIPTION):|(?<=LOCATION):|(?<=DTSTAMP):|(?<=DTSTART):|(?<=DTEND):|(?<=END):/g,
  //   ),
  // );
  tokens = tokens.reduce((acc, token) => {
    const results = [
      'BEGIN',
      'VERSION',
      'PRODID',
      'UID',
      'SUMMARY',
      'DESCRIPTION',
      'LOCATION',
      'DTSTAMP',
      'DTSTART',
      'DTEND',
      'END',
      '\t',
    ]
      .map(key => {
        if (key === '\t') {
          const match = token.match(new RegExp(`(^\t)(.*)$`));
          return match ? [match[0], match[0]] : null;
        }
        const match = token.match(new RegExp(`(^${key}:)(.*)$`));
        return match ? [key, match[2]] : null;
      })
      .filter(Boolean);
    return acc.concat(results);
  }, []);

  tokens.forEach(token => {
    // Store duplicate keys values in an array...
    if (map.has(token[0])) {
      const value = map.get(token[0]);
      if (Array.isArray(value)) {
        value.push(token[1]);
      } else {
        map.set(token[0], [value, token[1]]);
      }
    } else if (token[0].startsWith('\t')) {
      if (map.has('DESCRIPTION')) {
        // Prepend to exisiting text
        map.set('DESCRIPTION', `${map.get('DESCRIPTION')}${token[0]}`);
      } else {
        map.set('DESCRIPTION', token[0]);
      }
    } else {
      map.set(token[0], token[1]);
    }
  });
  return map;
}

export function formatDescription(description, location = '') {
  if (!description || !description.text) {
    return 'DESCRIPTION:';
  }

  const chunked = [];
  if (typeof description === 'object') {
    let text = `DESCRIPTION:${description.text}`;
    text = text.replace(/\r/g, '').replace(/\n/g, '\\n');

    while (text.length > ICS_LINE_LIMIT) {
      chunked.push(`${text}\\n`.substring(0, ICS_LINE_LIMIT));
      text = text.substring(ICS_LINE_LIMIT);
    }

    // Add last line of description text
    if (text) {
      chunked.push(text);
    }

    if (description.providerName) {
      chunked.push(`\\n\\n${description.providerName}`);
    }

    if (location) {
      const loc = description.providerName
        ? `\\n${location}`
        : `\\n\\n${location}`;
      const index = loc.indexOf(',');

      if (index !== -1) {
        chunked.push(`${loc.substring(0, index)}\\n`);
        chunked.push(`${loc.substring(index + 1).trimStart()}\\n`);
      } else {
        chunked.push(`${loc}\\n`);
      }
    }

    const phone = description.phone?.replace(/\r/g, '').replace(/\n/g, '\\n');
    if (phone && phone !== 'undefined') {
      chunked.push(`${phone}\\n`);
    }

    if (description.additionalText) {
      description.additionalText.forEach(val => {
        let line = `\\n${val}`;
        while (line.length > ICS_LINE_LIMIT) {
          chunked.push(`${line}\\n`.substring(0, ICS_LINE_LIMIT));
          line = line.substring(ICS_LINE_LIMIT);
        }
        chunked.push(`${line}\\n`);
      });
    }
  }

  return chunked.join('\r\n\t').replace(/,/g, '\\,');
}

export function generateICS(
  summary,
  description,
  location,
  startDateTime,
  endDateTime,
) {
  const startDate = moment(startDateTime)
    .utc()
    .format('YYYYMMDDTHHmmss[Z]');
  const endDate = moment(endDateTime)
    .utc()
    .format('YYYYMMDDTHHmmss[Z]');

  let loc = '';
  if (location) {
    loc = location.replace(/,/g, '\\,');
  }
  return [
    `BEGIN:VCALENDAR`,
    `VERSION:2.0`,
    `PRODID:VA`,
    `BEGIN:VEVENT`,
    `UID:${guid()}`,
    `SUMMARY:${summary}`,
    `${formatDescription(description, location)}`,
    `LOCATION:${summary.startsWith('Phone') ? 'Phone call' : loc}`,
    `DTSTAMP:${startDate}`,
    `DTSTART:${startDate}`,
    `DTEND:${endDate}`,
    `END:VEVENT`,
    `END:VCALENDAR`,
  ].join('\r\n');
}