drafty.js

/**
 * @copyright 2015-2022 Tinode LLC.
 * @summary Minimally rich text representation and formatting for Tinode.
 * @license Apache 2.0
 *
 * @file Basic parser and formatter for very simple text markup. Mostly targeted at
 * mobile use cases similar to Telegram, WhatsApp, and FB Messenger.
 *
 * <p>Supports conversion of user keyboard input to formatted text:</p>
 * <ul>
 *   <li>*abc* &rarr; <b>abc</b></li>
 *   <li>_abc_ &rarr; <i>abc</i></li>
 *   <li>~abc~ &rarr; <del>abc</del></li>
 *   <li>`abc` &rarr; <tt>abc</tt></li>
 * </ul>
 * Also supports forms and buttons.
 *
 * Nested formatting is supported, e.g. *abc _def_* -> <b>abc <i>def</i></b>
 * URLs, @mentions, and #hashtags are extracted and converted into links.
 * Forms and buttons can be added procedurally.
 * JSON data representation is inspired by Draft.js raw formatting.
 *
 *
 * @example
 * Text:
 * <pre>
 *     this is *bold*, `code` and _italic_, ~strike~
 *     combined *bold and _italic_*
 *     an url: https://www.example.com/abc#fragment and another _www.tinode.co_
 *     this is a @mention and a #hashtag in a string
 *     second #hashtag
 * </pre>
 *
 *  Sample JSON representation of the text above:
 *  {
 *     "txt": "this is bold, code and italic, strike combined bold and italic an url: https://www.example.com/abc#fragment " +
 *             "and another www.tinode.co this is a @mention and a #hashtag in a string second #hashtag",
 *     "fmt": [
 *         { "at":8, "len":4,"tp":"ST" },{ "at":14, "len":4, "tp":"CO" },{ "at":23, "len":6, "tp":"EM"},
 *         { "at":31, "len":6, "tp":"DL" },{ "tp":"BR", "len":1, "at":37 },{ "at":56, "len":6, "tp":"EM" },
 *         { "at":47, "len":15, "tp":"ST" },{ "tp":"BR", "len":1, "at":62 },{ "at":120, "len":13, "tp":"EM" },
 *         { "at":71, "len":36, "key":0 },{ "at":120, "len":13, "key":1 },{ "tp":"BR", "len":1, "at":133 },
 *         { "at":144, "len":8, "key":2 },{ "at":159, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":179 },
 *         { "at":187, "len":8, "key":3 },{ "tp":"BR", "len":1, "at":195 }
 *     ],
 *     "ent": [
 *         { "tp":"LN", "data":{ "url":"https://www.example.com/abc#fragment" } },
 *         { "tp":"LN", "data":{ "url":"http://www.tinode.co" } },
 *         { "tp":"MN", "data":{ "val":"mention" } },
 *         { "tp":"HT", "data":{ "val":"hashtag" } }
 *     ]
 *  }
 */

'use strict';

// NOTE TO DEVELOPERS:
// Localizable strings should be double quoted "строка на другом языке",
// non-localizable strings should be single quoted 'non-localized'.

const MAX_FORM_ELEMENTS = 8;
const MAX_PREVIEW_ATTACHMENTS = 3;
const MAX_PREVIEW_DATA_SIZE = 64;
const JSON_MIME_TYPE = 'application/json';
const DRAFTY_MIME_TYPE = 'text/x-drafty';
const ALLOWED_ENT_FIELDS = ['act', 'height', 'duration', 'incoming', 'mime', 'name', 'premime', 'preref', 'preview',
  'ref', 'size', 'state', 'url', 'val', 'width'
];

// Regular expressions for parsing inline formats. Javascript does not support lookbehind,
// so it's a bit messy.
const INLINE_STYLES = [
  // Strong = bold, *bold text*
  {
    name: 'ST',
    start: /(?:^|[\W_])(\*)[^\s*]/,
    end: /[^\s*](\*)(?=$|[\W_])/
  },
  // Emphesized = italic, _italic text_
  {
    name: 'EM',
    start: /(?:^|\W)(_)[^\s_]/,
    end: /[^\s_](_)(?=$|\W)/
  },
  // Deleted, ~strike this though~
  {
    name: 'DL',
    start: /(?:^|[\W_])(~)[^\s~]/,
    end: /[^\s~](~)(?=$|[\W_])/
  },
  // Code block `this is monospace`
  {
    name: 'CO',
    start: /(?:^|\W)(`)[^`]/,
    end: /[^`](`)(?=$|\W)/
  }
];

// Relative weights of formatting spans. Greater index in array means greater weight.
const FMT_WEIGHT = ['QQ'];

// RegExps for entity extraction (RF = reference)
const ENTITY_TYPES = [
  // URLs
  {
    name: 'LN',
    dataName: 'url',
    pack: function(val) {
      // Check if the protocol is specified, if not use http
      if (!/^[a-z]+:\/\//i.test(val)) {
        val = 'http://' + val;
      }
      return {
        url: val
      };
    },
    re: /(?:(?:https?|ftp):\/\/|www\.|ftp\.)[-A-Z0-9+&@#\/%=~_|$?!:,.]*[A-Z0-9+&@#\/%=~_|$]/ig
  },
  // Mentions @user (must be 2 or more characters)
  {
    name: 'MN',
    dataName: 'val',
    pack: function(val) {
      return {
        val: val.slice(1)
      };
    },
    re: /\B@([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
  },
  // Hashtags #hashtag, like metion 2 or more characters.
  {
    name: 'HT',
    dataName: 'val',
    pack: function(val) {
      return {
        val: val.slice(1)
      };
    },
    re: /\B#([\p{L}\p{N}][._\p{L}\p{N}]*[\p{L}\p{N}])/ug
  }
];

// HTML tag name suggestions
const FORMAT_TAGS = {
  AU: {
    html_tag: 'audio',
    md_tag: undefined,
    isVoid: false
  },
  BN: {
    html_tag: 'button',
    md_tag: undefined,
    isVoid: false
  },
  BR: {
    html_tag: 'br',
    md_tag: '\n',
    isVoid: true
  },
  CO: {
    html_tag: 'tt',
    md_tag: '`',
    isVoid: false
  },
  DL: {
    html_tag: 'del',
    md_tag: '~',
    isVoid: false
  },
  EM: {
    html_tag: 'i',
    md_tag: '_',
    isVoid: false
  },
  EX: {
    html_tag: '',
    md_tag: undefined,
    isVoid: true
  },
  FM: {
    html_tag: 'div',
    md_tag: undefined,
    isVoid: false
  },
  HD: {
    html_tag: '',
    md_tag: undefined,
    isVoid: false
  },
  HL: {
    html_tag: 'span',
    md_tag: undefined,
    isVoid: false
  },
  HT: {
    html_tag: 'a',
    md_tag: undefined,
    isVoid: false
  },
  IM: {
    html_tag: 'img',
    md_tag: undefined,
    isVoid: false
  },
  LN: {
    html_tag: 'a',
    md_tag: undefined,
    isVoid: false
  },
  MN: {
    html_tag: 'a',
    md_tag: undefined,
    isVoid: false
  },
  RW: {
    html_tag: 'div',
    md_tag: undefined,
    isVoid: false,
  },
  QQ: {
    html_tag: 'div',
    md_tag: undefined,
    isVoid: false
  },
  ST: {
    html_tag: 'b',
    md_tag: '*',
    isVoid: false
  },
  VC: {
    html_tag: 'div',
    md_tag: undefined,
    isVoid: false
  },
  VD: {
    html_tag: 'video',
    md_tag: undefined,
    isVoid: false
  }
};

// Convert base64-encoded string into Blob.
function base64toObjectUrl(b64, contentType, logger) {
  if (!b64) {
    return null;
  }

  try {
    const bin = atob(b64);
    const length = bin.length;
    const buf = new ArrayBuffer(length);
    const arr = new Uint8Array(buf);
    for (let i = 0; i < length; i++) {
      arr[i] = bin.charCodeAt(i);
    }

    return URL.createObjectURL(new Blob([buf], {
      type: contentType
    }));
  } catch (err) {
    if (logger) {
      logger("Drafty: failed to convert object.", err.message);
    }
  }

  return null;
}

function base64toDataUrl(b64, contentType) {
  if (!b64) {
    return null;
  }
  contentType = contentType || 'image/jpeg';
  return 'data:' + contentType + ';base64,' + b64;
}

// Helpers for converting Drafty to HTML.
const DECORATORS = {
  // Visial styles
  ST: {
    open: _ => '<b>',
    close: _ => '</b>'
  },
  EM: {
    open: _ => '<i>',
    close: _ => '</i>'
  },
  DL: {
    open: _ => '<del>',
    close: _ => '</del>'
  },
  CO: {
    open: _ => '<tt>',
    close: _ => '</tt>'
  },
  // Line break
  BR: {
    open: _ => '<br/>',
    close: _ => ''
  },
  // Hidden element
  HD: {
    open: _ => '',
    close: _ => ''
  },
  // Highlighted element.
  HL: {
    open: _ => '<span style="color:teal">',
    close: _ => '</span>'
  },
  // Link (URL)
  LN: {
    open: (data) => {
      return '<a href="' + data.url + '">';
    },
    close: _ => '</a>',
    props: (data) => {
      return data ? {
        href: data.url,
        target: '_blank'
      } : null;
    },
  },
  // Mention
  MN: {
    open: (data) => {
      return '<a href="#' + data.val + '">';
    },
    close: _ => '</a>',
    props: (data) => {
      return data ? {
        id: data.val
      } : null;
    },
  },
  // Hashtag
  HT: {
    open: (data) => {
      return '<a href="#' + data.val + '">';
    },
    close: _ => '</a>',
    props: (data) => {
      return data ? {
        id: data.val
      } : null;
    },
  },
  // Button
  BN: {
    open: _ => '<button>',
    close: _ => '</button>',
    props: (data) => {
      return data ? {
        'data-act': data.act,
        'data-val': data.val,
        'data-name': data.name,
        'data-ref': data.ref
      } : null;
    },
  },
  // Audio recording
  AU: {
    open: (data) => {
      const url = data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger);
      return '<audio controls src="' + url + '">';
    },
    close: _ => '</audio>',
    props: (data) => {
      if (!data) return null;
      return {
        // Embedded data or external link.
        src: data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
        'data-preload': data.ref ? 'metadata' : 'auto',
        'data-duration': data.duration,
        'data-name': data.name,
        'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
        'data-mime': data.mime,
      };
    }
  },
  // Image
  IM: {
    open: data => {
      // Don't use data.ref for preview: it's a security risk.
      const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
      const previewUrl = base64toObjectUrl(data.val, data.mime, Drafty.logger);
      const downloadUrl = data.ref || previewUrl;
      return (data.name ? '<a href="' + downloadUrl + '" download="' + data.name + '">' : '') +
        '<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
        (data.width ? ' width="' + data.width + '"' : '') +
        (data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
    },
    close: data => {
      return (data.name ? '</a>' : '');
    },
    props: data => {
      if (!data) return null;
      return {
        // Temporary preview, or permanent preview, or external link.
        src: base64toDataUrl(data._tempPreview, data.mime) ||
          data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
        title: data.name,
        alt: data.name,
        'data-width': data.width,
        'data-height': data.height,
        'data-name': data.name,
        'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
        'data-mime': data.mime,
      };
    },
  },
  // Form - structured layout of elements.
  FM: {
    open: _ => '<div>',
    close: _ => '</div>'
  },
  // Row: logic grouping of elements
  RW: {
    open: _ => '<div>',
    close: _ => '</div>'
  },
  // Quoted block.
  QQ: {
    open: _ => '<div>',
    close: _ => '</div>',
    props: (data) => {
      return data ? {} : null;
    },
  },
  // Video call
  VC: {
    open: _ => '<div>',
    close: _ => '</div>',
    props: data => {
      if (!data) return {};
      return {
        'data-duration': data.duration,
        'data-state': data.state,
      };
    }
  },
  // Video.
  VD: {
    open: data => {
      const tmpPreviewUrl = base64toDataUrl(data._tempPreview, data.mime);
      const previewUrl = data.ref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
      return '<img src="' + (tmpPreviewUrl || previewUrl) + '"' +
        (data.width ? ' width="' + data.width + '"' : '') +
        (data.height ? ' height="' + data.height + '"' : '') + ' border="0" />';
    },
    close: _ => '',
    props: data => {
      if (!data) return null;
      const poster = data.preref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger);
      return {
        // Embedded data or external link.
        src: poster,
        'data-src': data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger),
        'data-width': data.width,
        'data-height': data.height,
        'data-preload': data.ref ? 'metadata' : 'auto',
        'data-preview': poster,
        'data-duration': data.duration | 0,
        'data-name': data.name,
        'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0),
        'data-mime': data.mime,
      };
    }
  },
};

/**
 * The main object which performs all the formatting actions.
 * @class Drafty
 * @constructor
 */
const Drafty = function() {
  this.txt = '';
  this.fmt = [];
  this.ent = [];
}

/**
 * Initialize Drafty document to a plain text string.
 *
 * @param {string} plainText - string to use as Drafty content.
 *
 * @returns new Drafty document or null is plainText is not a string or undefined.
 */
Drafty.init = function(plainText) {
  if (typeof plainText == 'undefined') {
    plainText = '';
  } else if (typeof plainText != 'string') {
    return null;
  }

  return {
    txt: plainText
  };
}

/**
 * Parse plain text into Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {string} content - plain-text content to parse.
 * @return {Drafty} parsed document or null if the source is not plain text.
 */
Drafty.parse = function(content) {
  // Make sure we are parsing strings only.
  if (typeof content != 'string') {
    return null;
  }

  // Split text into lines. It makes further processing easier.
  const lines = content.split(/\r?\n/);

  // Holds entities referenced from text
  const entityMap = [];
  const entityIndex = {};

  // Processing lines one by one, hold intermediate result in blx.
  const blx = [];
  lines.forEach((line) => {
    let spans = [];
    let entities;

    // Find formatted spans in the string.
    // Try to match each style.
    INLINE_STYLES.forEach((tag) => {
      // Each style could be matched multiple times.
      spans = spans.concat(spannify(line, tag.start, tag.end, tag.name));
    });

    let block;
    if (spans.length == 0) {
      block = {
        txt: line
      };
    } else {
      // Sort spans by style occurence early -> late, then by length: first long then short.
      spans.sort((a, b) => {
        const diff = a.at - b.at;
        return diff != 0 ? diff : b.end - a.end;
      });

      // Convert an array of possibly overlapping spans into a tree.
      spans = toSpanTree(spans);

      // Build a tree representation of the entire string, not
      // just the formatted parts.
      const chunks = chunkify(line, 0, line.length, spans);

      const drafty = draftify(chunks, 0);

      block = {
        txt: drafty.txt,
        fmt: drafty.fmt
      };
    }

    // Extract entities from the cleaned up string.
    entities = extractEntities(block.txt);
    if (entities.length > 0) {
      const ranges = [];
      for (let i in entities) {
        // {offset: match['index'], unique: match[0], len: match[0].length, data: ent.packer(), type: ent.name}
        const entity = entities[i];
        let index = entityIndex[entity.unique];
        if (!index) {
          index = entityMap.length;
          entityIndex[entity.unique] = index;
          entityMap.push({
            tp: entity.type,
            data: entity.data
          });
        }
        ranges.push({
          at: entity.offset,
          len: entity.len,
          key: index
        });
      }
      block.ent = ranges;
    }

    blx.push(block);
  });

  const result = {
    txt: ''
  };

  // Merge lines and save line breaks as BR inline formatting.
  if (blx.length > 0) {
    result.txt = blx[0].txt;
    result.fmt = (blx[0].fmt || []).concat(blx[0].ent || []);

    for (let i = 1; i < blx.length; i++) {
      const block = blx[i];
      const offset = result.txt.length + 1;

      result.fmt.push({
        tp: 'BR',
        len: 1,
        at: offset - 1
      });

      result.txt += ' ' + block.txt;
      if (block.fmt) {
        result.fmt = result.fmt.concat(block.fmt.map((s) => {
          s.at += offset;
          return s;
        }));
      }
      if (block.ent) {
        result.fmt = result.fmt.concat(block.ent.map((s) => {
          s.at += offset;
          return s;
        }));
      }
    }

    if (result.fmt.length == 0) {
      delete result.fmt;
    }

    if (entityMap.length > 0) {
      result.ent = entityMap;
    }
  }
  return result;
}

/**
 * Append one Drafty document to another.
 *
 * @param {Drafty} first - Drafty document to append to.
 * @param {Drafty|string} second - Drafty document or string being appended.
 *
 * @return {Drafty} first document with the second appended to it.
 */
Drafty.append = function(first, second) {
  if (!first) {
    return second;
  }
  if (!second) {
    return first;
  }

  first.txt = first.txt || '';
  const len = first.txt.length;

  if (typeof second == 'string') {
    first.txt += second;
  } else if (second.txt) {
    first.txt += second.txt;
  }

  if (Array.isArray(second.fmt)) {
    first.fmt = first.fmt || [];
    if (Array.isArray(second.ent)) {
      first.ent = first.ent || [];
    }
    second.fmt.forEach(src => {
      const fmt = {
        at: (src.at | 0) + len,
        len: src.len | 0
      };
      // Special case for the outside of the normal rendering flow styles.
      if (src.at == -1) {
        fmt.at = -1;
        fmt.len = 0;
      }
      if (src.tp) {
        fmt.tp = src.tp;
      } else {
        fmt.key = first.ent.length;
        first.ent.push(second.ent[src.key || 0]);
      }
      first.fmt.push(fmt);
    });
  }

  return first;
}

/**
 * Description of an image to attach.
 * @typedef {Object} ImageDesc
 * @memberof Drafty
 *
 * @property {string} mime - mime-type of the image, e.g. "image/png".
 * @property {string} refurl - reference to the content. Could be null/undefined.
 * @property {string} bits - base64-encoded image content. Could be null/undefined.
 * @property {string} preview - base64-encoded thumbnail of the image.
 * @property {integer} width - width of the image.
 * @property {integer} height - height of the image.
 * @property {string} filename - file name suggestion for downloading the image.
 * @property {integer} size - size of the image in bytes. Treat is as an untrusted hint.
 * @property {string} _tempPreview - base64-encoded image preview used during upload process; not serializable.
 * @property {Promise} urlPromise - Promise which returns content URL when resolved.
 */

/**
 * Insert inline image into Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to add image to.
 * @param {integer} at - index where the object is inserted. The length of the image is always 1.
 * @param {ImageDesc} imageDesc - object with image paramenets and data.
 *
 * @return {Drafty} updated document.
 */
Drafty.insertImage = function(content, at, imageDesc) {
  content = content || {
    txt: ' '
  };
  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: at | 0,
    len: 1,
    key: content.ent.length
  });

  const ex = {
    tp: 'IM',
    data: {
      mime: imageDesc.mime,
      ref: imageDesc.refurl,
      val: imageDesc.bits || imageDesc.preview,
      width: imageDesc.width,
      height: imageDesc.height,
      name: imageDesc.filename,
      size: imageDesc.size | 0,
    }
  };

  if (imageDesc.urlPromise) {
    ex.data._tempPreview = imageDesc._tempPreview;
    ex.data._processing = true;
    imageDesc.urlPromise.then(
      url => {
        ex.data.ref = url;
        ex.data._tempPreview = undefined;
        ex.data._processing = undefined;
      },
      _ => {
        // Catch the error, otherwise it will appear in the console.
        ex.data._processing = undefined;
      }
    );
  }

  content.ent.push(ex);

  return content;
}

/**
 * Description of a video to attach.
 * @typedef {Object} VideoDesc
 * @memberof Drafty
 *
 * @property {string} mime - mime-type of the video, e.g. "video/mpeg".
 * @property {string} refurl - reference to the content. Could be null/undefined.
 * @property {string} bits - in-band base64-encoded image data. Could be null/undefined.
 * @property {string} preview - base64-encoded screencapture from the video. Could be null/undefined.
 * @property {string} preref - reference to screencapture from the video. Could be null/undefined.
 * @property {integer} width - width of the video.
 * @property {integer} height - height of the video.
 * @property {integer} duration - duration of the video.
 * @property {string} filename - file name suggestion for downloading the video.
 * @property {integer} size - size of the video in bytes. Treat is as an untrusted hint.
 * @property {string} _tempPreview - base64-encoded screencapture used during upload process; not serializable.
 * @property {Promise} urlPromise - array of two promises, which return URLs of video and preview uploads correspondingly
 *        (either could be null).
 */

/**
 * Insert inline image into Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to add video to.
 * @param {integer} at - index where the object is inserted. The length of the video is always 1.
 * @param {VideoDesc} videoDesc - object with video paramenets and data.
 *
 * @return {Drafty} updated document.
 */
Drafty.insertVideo = function(content, at, videoDesc) {
  content = content || {
    txt: ' '
  };
  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: at | 0,
    len: 1,
    key: content.ent.length
  });

  const ex = {
    tp: 'VD',
    data: {
      mime: videoDesc.mime,
      ref: videoDesc.refurl,
      val: videoDesc.bits,
      preref: videoDesc.preref,
      preview: videoDesc.preview,
      width: videoDesc.width,
      height: videoDesc.height,
      duration: videoDesc.duration | 0,
      name: videoDesc.filename,
      size: videoDesc.size | 0,
    }
  };

  if (videoDesc.urlPromise) {
    ex.data._tempPreview = videoDesc._tempPreview;
    ex.data._processing = true;
    videoDesc.urlPromise.then(
      urls => {
        ex.data.ref = urls[0];
        ex.data.preref = urls[1];
        ex.data._tempPreview = undefined;
        ex.data._processing = undefined;
      },
      _ => {
        // Catch the error, otherwise it will appear in the console.
        ex.data._processing = undefined;
      }
    );
  }

  content.ent.push(ex);

  return content;
}

/**
 * Description of an audio recording to attach.
 * @typedef {Object} AudioDesc
 * @memberof Drafty
 *
 * @property {string} mime - mime-type of the audio, e.g. "audio/ogg".
 * @property {string} refurl - reference to the content. Could be null/undefined.
 * @property {string} bits - base64-encoded audio content. Could be null/undefined.
 * @property {integer} duration - duration of the record in milliseconds.
 * @property {string} preview - base64 encoded short array of amplitude values 0..100.
 * @property {string} filename - file name suggestion for downloading the audio.
 * @property {integer} size - size of the recording in bytes. Treat is as an untrusted hint.
 * @property {Promise} urlPromise - Promise which returns content URL when resolved.
 */

/**
 * Insert audio recording into Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to add audio record to.
 * @param {integer} at - index where the object is inserted. The length of the record is always 1.
 * @param {AudioDesc} audioDesc - object with the audio paramenets and data.
 *
 * @return {Drafty} updated document.
 */
Drafty.insertAudio = function(content, at, audioDesc) {
  content = content || {
    txt: ' '
  };
  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: at | 0,
    len: 1,
    key: content.ent.length
  });

  const ex = {
    tp: 'AU',
    data: {
      mime: audioDesc.mime,
      val: audioDesc.bits,
      duration: audioDesc.duration | 0,
      preview: audioDesc.preview,
      name: audioDesc.filename,
      size: audioDesc.size | 0,
      ref: audioDesc.refurl
    }
  };

  if (audioDesc.urlPromise) {
    ex.data._processing = true;
    audioDesc.urlPromise.then(
      url => {
        ex.data.ref = url;
        ex.data._processing = undefined;
      },
      _ => {
        // Catch the error, otherwise it will appear in the console.
        ex.data._processing = undefined;
      }
    );
  }

  content.ent.push(ex);

  return content;
}

/**
 * Create a (self-contained) video call Drafty document.
 * @memberof Drafty
 * @static
 * @param {boolean} audioOnly <code>true</code> if the call is initially audio-only.
 * @returns Video Call drafty document.
 */
Drafty.videoCall = function(audioOnly) {
  const content = {
    txt: ' ',
    fmt: [{
      at: 0,
      len: 1,
      key: 0
    }],
    ent: [{
      tp: 'VC',
      data: {
        aonly: audioOnly
      },
    }]
  };
  return content;
}

/**
 * Update video call (VC) entity with the new status and duration.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - VC document to update.
 * @param {object} params - new video call parameters.
 * @param {string} params.state - state of video call.
 * @param {number} params.duration - duration of the video call in milliseconds.
 *
 * @returns the same document with update applied.
 */
Drafty.updateVideoCall = function(content, params) {
  // The video element could be just a format or a format + entity.
  // Must ensure it's the latter first.
  const fmt = ((content || {}).fmt || [])[0];
  if (!fmt) {
    // Unrecognized content.
    return content;
  }

  let ent;
  if (fmt.tp == 'VC') {
    // Just a format, convert to format + entity.
    delete fmt.tp;
    fmt.key = 0;
    ent = {
      tp: 'VC'
    };
    content.ent = [ent];
  } else {
    ent = (content.ent || [])[fmt.key | 0];
    if (!ent || ent.tp != 'VC') {
      // Not a VC entity.
      return content;
    }
  }
  ent.data = ent.data || {};
  Object.assign(ent.data, params);
  return content;
}

/**
 * Create a quote to Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {string} header - Quote header (title, etc.).
 * @param {string} uid - UID of the author to mention.
 * @param {Drafty} body - Body of the quoted message.
 *
 * @returns Reply quote Drafty doc with the quote formatting.
 */
Drafty.quote = function(header, uid, body) {
  const quote = Drafty.append(Drafty.appendLineBreak(Drafty.mention(header, uid)), body);

  // Wrap into a quote.
  quote.fmt.push({
    at: 0,
    len: quote.txt.length,
    tp: 'QQ'
  });

  return quote;
}

/**
 * Create a Drafty document with a mention.
 *
 * @param {string} name - mentioned name.
 * @param {string} uid - mentioned user ID.
 *
 * @returns {Drafty} document with the mention.
 */
Drafty.mention = function(name, uid) {
  return {
    txt: name || '',
    fmt: [{
      at: 0,
      len: (name || '').length,
      key: 0
    }],
    ent: [{
      tp: 'MN',
      data: {
        val: uid
      }
    }]
  };
}

/**
 * Append a link to a Drafty document.
 *
 * @param {Drafty} content - Drafty document to append link to.
 * @param {Object} linkData - Link info in format <code>{txt: 'ankor text', url: 'http://...'}</code>.
 *
 * @returns {Drafty} the same document as <code>content</code>.
 */
Drafty.appendLink = function(content, linkData) {
  content = content || {
    txt: ''
  };

  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: content.txt.length,
    len: linkData.txt.length,
    key: content.ent.length
  });
  content.txt += linkData.txt;

  const ex = {
    tp: 'LN',
    data: {
      url: linkData.url
    }
  }
  content.ent.push(ex);

  return content;
}

/**
 * Append image to Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to add image to.
 * @param {ImageDesc} imageDesc - object with image paramenets.
 *
 * @return {Drafty} updated document.
 */
Drafty.appendImage = function(content, imageDesc) {
  content = content || {
    txt: ''
  };
  content.txt += ' ';
  return Drafty.insertImage(content, content.txt.length - 1, imageDesc);
}

/**
 * Append audio recodring to Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to add recording to.
 * @param {AudioDesc} audioDesc - object with audio data.
 *
 * @return {Drafty} updated document.
 */
Drafty.appendAudio = function(content, audioDesc) {
  content = content || {
    txt: ''
  };
  content.txt += ' ';
  return Drafty.insertAudio(content, content.txt.length - 1, audioDesc);
}

/**
 * Description of a file to attach.
 * @typedef {Object} AttachmentDesc
 * @memberof Drafty
 *
 * @property {string} mime - mime-type of the attachment, e.g. "application/octet-stream"
 * @property {string} data - base64-encoded in-band content of small attachments. Could be null/undefined.
 * @property {string} filename - file name suggestion for downloading the attachment.
 * @property {integer} size - size of the file in bytes. Treat is as an untrusted hint.
 * @property {string} refurl - reference to the out-of-band content. Could be null/undefined.
 * @property {Promise} urlPromise - Promise which returns content URL when resolved.
 */

/**
 * Attach file to Drafty content. Either as a blob or as a reference.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to attach file to.
 * @param {AttachmentDesc} object - containing attachment description and data.
 *
 * @return {Drafty} updated document.
 */
Drafty.attachFile = function(content, attachmentDesc) {
  content = content || {
    txt: ''
  };

  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: -1,
    len: 0,
    key: content.ent.length
  });

  const ex = {
    tp: 'EX',
    data: {
      mime: attachmentDesc.mime,
      val: attachmentDesc.data,
      name: attachmentDesc.filename,
      ref: attachmentDesc.refurl,
      size: attachmentDesc.size | 0
    }
  }
  if (attachmentDesc.urlPromise) {
    ex.data._processing = true;
    attachmentDesc.urlPromise.then(
      url => {
        ex.data.ref = url;
        ex.data._processing = undefined;
      },
      _ => {
        /* catch the error, otherwise it will appear in the console. */
        ex.data._processing = undefined;
      }
    );
  }
  content.ent.push(ex);

  return content;
}

/**
 * Wraps drafty document into a simple formatting style.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} content - document or string to wrap into a style.
 * @param {string} style - two-letter style to wrap into.
 * @param {number} at - index where the style starts, default 0.
 * @param {number} len - length of the form content, default all of it.
 *
 * @return {Drafty} updated document.
 */
Drafty.wrapInto = function(content, style, at, len) {
  if (typeof content == 'string') {
    content = {
      txt: content
    };
  }
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: at || 0,
    len: len || content.txt.length,
    tp: style,
  });

  return content;
}

/**
 * Wraps content into an interactive form.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} content - to wrap into a form.
 * @param {number} at - index where the forms starts.
 * @param {number} len - length of the form content.
 *
 * @return {Drafty} updated document.
 */
Drafty.wrapAsForm = function(content, at, len) {
  return Drafty.wrapInto(content, 'FM', at, len);
}

/**
 * Insert clickable button into Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
 * @param {number} at - location where the button is inserted.
 * @param {number} len - the length of the text to be used as button title.
 * @param {string} name - the button. Client should return it to the server when the button is clicked.
 * @param {string} actionType - the type of the button, one of 'url' or 'pub'.
 * @param {string} actionValue - the value to return on click:
 * @param {string} refUrl - the URL to go to when the 'url' button is clicked.
 *
 * @return {Drafty} updated document.
 */
Drafty.insertButton = function(content, at, len, name, actionType, actionValue, refUrl) {
  if (typeof content == 'string') {
    content = {
      txt: content
    };
  }

  if (!content || !content.txt || content.txt.length < at + len) {
    return null;
  }

  if (len <= 0 || ['url', 'pub'].indexOf(actionType) == -1) {
    return null;
  }
  // Ensure refUrl is a string.
  if (actionType == 'url' && !refUrl) {
    return null;
  }
  refUrl = '' + refUrl;

  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: at | 0,
    len: len,
    key: content.ent.length
  });
  content.ent.push({
    tp: 'BN',
    data: {
      act: actionType,
      val: actionValue,
      ref: refUrl,
      name: name
    }
  });

  return content;
}

/**
 * Append clickable button to Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} content - Drafty document to insert button to or a string to be used as button text.
 * @param {string} title - the text to be used as button title.
 * @param {string} name - the button. Client should return it to the server when the button is clicked.
 * @param {string} actionType - the type of the button, one of 'url' or 'pub'.
 * @param {string} actionValue - the value to return on click:
 * @param {string} refUrl - the URL to go to when the 'url' button is clicked.
 *
 * @return {Drafty} updated document.
 */
Drafty.appendButton = function(content, title, name, actionType, actionValue, refUrl) {
  content = content || {
    txt: ''
  };
  const at = content.txt.length;
  content.txt += title;
  return Drafty.insertButton(content, at, title.length, name, actionType, actionValue, refUrl);
}

/**
 * Attach a generic JS object. The object is attached as a json string.
 * Intended for representing a form response.
 *
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - Drafty document to attach file to.
 * @param {Object} data - data to convert to json string and attach.
 * @returns {Drafty} the same document as <code>content</code>.
 */
Drafty.attachJSON = function(content, data) {
  content = content || {
    txt: ''
  };
  content.ent = content.ent || [];
  content.fmt = content.fmt || [];

  content.fmt.push({
    at: -1,
    len: 0,
    key: content.ent.length
  });

  content.ent.push({
    tp: 'EX',
    data: {
      mime: JSON_MIME_TYPE,
      val: data
    }
  });

  return content;
}
/**
 * Append line break to a Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - Drafty document to append linebreak to.
 * @returns {Drafty} the same document as <code>content</code>.
 */
Drafty.appendLineBreak = function(content) {
  content = content || {
    txt: ''
  };
  content.fmt = content.fmt || [];
  content.fmt.push({
    at: content.txt.length,
    len: 1,
    tp: 'BR'
  });
  content.txt += ' ';

  return content;
}
/**
 * Given Drafty document, convert it to HTML.
 * No attempt is made to strip pre-existing html markup.
 * This is potentially unsafe because <code>content.txt</code> may contain malicious HTML
 * markup.
 * @memberof Tinode.Drafty
 * @static
 *
 * @param {Drafty} doc - document to convert.
 *
 * @returns {string} HTML-representation of content.
 */
Drafty.UNSAFE_toHTML = function(doc) {
  const tree = draftyToTree(doc);
  const htmlFormatter = function(type, data, values) {
    const tag = DECORATORS[type];
    let result = values ? values.join('') : '';
    if (tag) {
      result = tag.open(data) + result + tag.close(data);
    }
    return result;
  };
  return treeBottomUp(tree, htmlFormatter, 0);
}

/**
 * Callback for applying custom formatting to a Drafty document.
 * Called once for each style span.
 * @memberof Drafty
 * @static
 *
 * @callback Formatter
 * @param {string} style - style code such as "ST" or "IM".
 * @param {Object} data - entity's data.
 * @param {Object} values - possibly styled subspans contained in this style span.
 * @param {number} index - index of the element guaranteed to be unique.
 */

/**
 * Convert Drafty document to a representation suitable for display.
 * The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
 * it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|Object} content - Drafty document to transform.
 * @param {Formatter} formatter - callback which formats individual elements.
 * @param {Object} context - context provided to formatter as <code>this</code>.
 *
 * @return {Object} transformed object
 */
Drafty.format = function(original, formatter, context) {
  return treeBottomUp(draftyToTree(original), formatter, 0, [], context);
}

/**
 * Shorten Drafty document making the drafty text no longer than the limit.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} original - Drafty object to shorten.
 * @param {number} limit - length in characrets to shorten to.
 * @param {boolean} light - remove heavy data from entities.
 * @returns new shortened Drafty object leaving the original intact.
 */
Drafty.shorten = function(original, limit, light) {
  let tree = draftyToTree(original);
  tree = shortenTree(tree, limit, '…');
  if (tree && light) {
    tree = lightEntity(tree);
  }
  return treeToDrafty({}, tree, []);
}

/**
 * Transform Drafty doc for forwarding: strip leading @mention and any leading line breaks or whitespace.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} original - Drafty object to shorten.
 * @returns converted Drafty object leaving the original intact.
 */
Drafty.forwardedContent = function(original) {
  let tree = draftyToTree(original);
  const rmMention = function(node) {
    if (node.type == 'MN') {
      if (!node.parent || !node.parent.type) {
        return null;
      }
    }
    return node;
  }
  // Strip leading mention.
  tree = treeTopDown(tree, rmMention);
  // Remove leading whitespace.
  tree = lTrim(tree);
  // Convert back to Drafty.
  return treeToDrafty({}, tree, []);
}

/**
 * Prepare Drafty doc for wrapping into QQ as a reply:
 *  - Replace forwarding mention with symbol '➦' and remove data (UID).
 *  - Remove quoted text completely.
 *  - Replace line breaks with spaces.
 *  - Strip entities of heavy content.
 *  - Move attachments to the end of the document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} original - Drafty object to shorten.
 * @param {number} limit - length in characters to shorten to.
 * @returns converted Drafty object leaving the original intact.
 */
Drafty.replyContent = function(original, limit) {
  const convMNnQQnBR = function(node) {
    if (node.type == 'QQ') {
      return null;
    } else if (node.type == 'MN') {
      if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
        node.text = '➦';
        delete node.children;
        delete node.data;
      }
    } else if (node.type == 'BR') {
      node.text = ' ';
      delete node.type;
      delete node.children;
    }
    return node;
  }

  let tree = draftyToTree(original);
  if (!tree) {
    return original;
  }

  // Strip leading mention.
  tree = treeTopDown(tree, convMNnQQnBR);
  // Move attachments to the end of the doc.
  tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);
  // Shorten the doc.
  tree = shortenTree(tree, limit, '…');
  // Strip heavy elements except IM.data['val'] and VD.data['preview'] (have to keep them to generate previews later).
  const filter = node => {
    switch (node.type) {
      case 'IM':
        return ['val'];
      case 'VD':
        return ['preview'];
    }
    return null;
  };
  tree = lightEntity(tree, filter);
  // Convert back to Drafty.
  return treeToDrafty({}, tree, []);
}


/**
 * Generate drafty preview:
 *  - Shorten the document.
 *  - Strip all heavy entity data leaving just inline styles and entity references.
 *  - Replace line breaks with spaces.
 *  - Replace content of QQ with a space.
 *  - Replace forwarding mention with symbol '➦'.
 * move all attachments to the end of the document and make them visible.
 * The <code>context</code> may expose a function <code>getFormatter(style)</code>. If it's available
 * it will call it to obtain a <code>formatter</code> for a subtree of styles under the <code>style</code>.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty|string} original - Drafty object to shorten.
 * @param {number} limit - length in characters to shorten to.
 * @param {boolean} forwarding - this a forwarding message preview.
 * @returns new shortened Drafty object leaving the original intact.
 */
Drafty.preview = function(original, limit, forwarding) {
  let tree = draftyToTree(original);

  // Move attachments to the end.
  tree = attachmentsToEnd(tree, MAX_PREVIEW_ATTACHMENTS);

  // Convert leading mention to '➦' and replace QQ and BR with a space ' '.
  const convMNnQQnBR = function(node) {
    if (node.type == 'MN') {
      if ((!node.parent || !node.parent.type) && (node.text || '').startsWith('➦')) {
        node.text = '➦';
        delete node.children;
      }
    } else if (node.type == 'QQ') {
      node.text = ' ';
      delete node.children;
    } else if (node.type == 'BR') {
      node.text = ' ';
      delete node.children;
      delete node.type;
    }
    return node;
  }
  tree = treeTopDown(tree, convMNnQQnBR);

  tree = shortenTree(tree, limit, '…');
  if (forwarding) {
    // Keep some IM and VD data for preview.
    const filter = {
      IM: ['val'],
      VD: ['preview']
    };
    tree = lightEntity(tree, node => {
      return filter[node.type];
    });
  } else {
    tree = lightEntity(tree);
  }

  // Convert back to Drafty.
  return treeToDrafty({}, tree, []);
}

/**
 * Given Drafty document, convert it to plain text.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to convert to plain text.
 * @returns {string} plain-text representation of the drafty document.
 */
Drafty.toPlainText = function(content) {
  return typeof content == 'string' ? content : content.txt;
}

/**
 * Check if the document has no markup and no entities.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - content to check for presence of markup.
 * @returns <code>true</code> is content is plain text, <code>false</code> otherwise.
 */
Drafty.isPlainText = function(content) {
  return typeof content == 'string' || !(content.fmt || content.ent);
}

/**
 * Convert document to plain text with markdown. All elements which cannot
 * be represented in markdown are stripped.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to convert to plain text with markdown.
 */
Drafty.toMarkdown = function(content) {
  let tree = draftyToTree(content);
  const mdFormatter = function(type, _, values) {
    const def = FORMAT_TAGS[type];
    let result = (values ? values.join('') : '');
    if (def) {
      if (def.isVoid) {
        result = def.md_tag || '';
      } else if (def.md_tag) {
        result = def.md_tag + result + def.md_tag;
      }
    }
    return result;
  };
  return treeBottomUp(tree, mdFormatter, 0);
}

/**
 * Checks if the object represets is a valid Drafty document.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - content to check for validity.
 * @returns <code>true</code> is content is valid, <code>false</code> otherwise.
 */
Drafty.isValid = function(content) {
  if (!content) {
    return false;
  }

  const {
    txt,
    fmt,
    ent
  } = content;

  if (!txt && txt !== '' && !fmt && !ent) {
    return false;
  }

  const txt_type = typeof txt;
  if (txt_type != 'string' && txt_type != 'undefined' && txt !== null) {
    return false;
  }

  if (typeof fmt != 'undefined' && !Array.isArray(fmt) && fmt !== null) {
    return false;
  }

  if (typeof ent != 'undefined' && !Array.isArray(ent) && ent !== null) {
    return false;
  }
  return true;
}

/**
 * Check if the drafty document has attachments: style EX and outside of normal rendering flow,
 * i.e. <code>at = -1</code>.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to check for attachments.
 * @returns <code>true</code> if there are attachments.
 */
Drafty.hasAttachments = function(content) {
  if (!Array.isArray(content.fmt)) {
    return false;
  }
  for (let i in content.fmt) {
    const fmt = content.fmt[i];
    if (fmt && fmt.at < 0) {
      const ent = content.ent[fmt.key | 0];
      return ent && ent.tp == 'EX' && ent.data;
    }
  }
  return false;
}

/**
 * Callback for enumerating entities in a Drafty document.
 * Called once for each entity.
 * @memberof Drafty
 * @static
 *
 * @callback EntityCallback
 * @param {Object} data entity data.
 * @param {string} entity type.
 * @param {number} index entity's index in `content.ent`.
 *
 * @return 'true-ish' to stop processing, 'false-ish' otherwise.
 */

/**
 * Enumerate attachments: style EX and outside of normal rendering flow, i.e. <code>at = -1</code>.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to process for attachments.
 * @param {EntityCallback} callback - callback to call for each attachment.
 * @param {Object} context - value of "this" for callback.
 */
Drafty.attachments = function(content, callback, context) {
  if (!Array.isArray(content.fmt)) {
    return;
  }
  let count = 0;
  for (let i in content.ent) {
    let fmt = content.fmt[i];
    if (fmt && fmt.at < 0) {
      const ent = content.ent[fmt.key | 0];
      if (ent && ent.tp == 'EX' && ent.data) {
        if (callback.call(context, ent.data, count++, 'EX')) {
          break;
        }
      }
    }
  };
}

/**
 * Check if the drafty document has entities.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document to check for entities.
 * @returns <code>true</code> if there are entities.
 */
Drafty.hasEntities = function(content) {
  return content.ent && content.ent.length > 0;
}

/**
 * Enumerate entities. Enumeration stops if callback returns 'true'.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document with entities to enumerate.
 * @param {EntityCallback} callback - callback to call for each entity.
 * @param {Object} context - value of "this" for callback.
 *
 */
Drafty.entities = function(content, callback, context) {
  if (content.ent && content.ent.length > 0) {
    for (let i in content.ent) {
      if (content.ent[i]) {
        if (callback.call(context, content.ent[i].data, i, content.ent[i].tp)) {
          break;
        }
      }
    }
  }
}

/**
 * Callback for enumerating styles (inline formats) in a Drafty document.
 * Called once for each style.
 * @memberof Drafty
 * @static
 *
 * @callback StyleCallback
 * @param {string} tp - format type.
 * @param {number} at - starting position of the format in text.
 * @param {number} len - extent of the format in characters.
 * @param {number} key - index of the entity if format is a reference.
 * @param {number} index - style's index in `content.fmt`.
 *
 * @return 'true-ish' to stop processing, 'false-ish' otherwise.
 */

/**
 * Enumerate styles (inline formats). Enumeration stops if callback returns 'true'.
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document with styles (formats) to enumerate.
 * @param {StyleCallback} callback - callback to call for each format.
 * @param {Object} context - value of "this" for callback.
 */
Drafty.styles = function(content, callback, context) {
  if (content.fmt && content.fmt.length > 0) {
    for (let i in content.fmt) {
      const fmt = content.fmt[i];
      if (fmt) {
        if (callback.call(context, fmt.tp, fmt.at, fmt.len, fmt.key, i)) {
          break;
        }
      }
    }
  }
}

/**
 * Remove unrecognized fields from entity data
 * @memberof Drafty
 * @static
 *
 * @param {Drafty} content - document with entities to enumerate.
 * @returns content.
 */
Drafty.sanitizeEntities = function(content) {
  if (content && content.ent && content.ent.length > 0) {
    for (let i in content.ent) {
      const ent = content.ent[i];
      if (ent && ent.data) {
        const data = copyEntData(ent.data);
        if (data) {
          content.ent[i].data = data;
        } else {
          delete content.ent[i].data;
        }
      }
    }
  }
  return content;
}

/**
 * Given the entity, get URL which can be used for downloading
 * entity data.
 * @memberof Drafty
 * @static
 *
 * @param {Object} entData - entity.data to get the URl from.
 * @returns {string} URL to download entity data or <code>null</code>.
 */
Drafty.getDownloadUrl = function(entData) {
  let url = null;
  if (entData.mime != JSON_MIME_TYPE && entData.val) {
    url = base64toObjectUrl(entData.val, entData.mime, Drafty.logger);
  } else if (typeof entData.ref == 'string') {
    url = entData.ref;
  }
  return url;
}

/**
 * Check if the entity data is not ready for sending, such as being uploaded to the server.
 * @memberof Drafty
 * @static
 *
 * @param {Object} entity.data to get the URl from.
 * @returns {boolean} true if upload is in progress, false otherwise.
 */
Drafty.isProcessing = function(entData) {
  return !!entData._processing;
}

/**
 * Given the entity, get URL which can be used for previewing
 * the entity.
 * @memberof Drafty
 * @static
 *
 * @param {Object} entity.data to get the URl from.
 *
 * @returns {string} url for previewing or null if no such url is available.
 */
Drafty.getPreviewUrl = function(entData) {
  return entData.val ? base64toObjectUrl(entData.val, entData.mime, Drafty.logger) : null;
}

/**
 * Get approximate size of the entity.
 * @memberof Drafty
 * @static
 *
 * @param {Object} entData - entity.data to get the size for.
 * @returns {number} size of entity data in bytes.
 */
Drafty.getEntitySize = function(entData) {
  // Either size hint or length of value. The value is base64 encoded,
  // the actual object size is smaller than the encoded length.
  return entData.size ? entData.size : entData.val ? (entData.val.length * 0.75) | 0 : 0;
}

/**
 * Get entity mime type.
 * @memberof Drafty
 * @static
 *
 * @param {Object} entData - entity.data to get the type for.
 * @returns {string} mime type of entity.
 */
Drafty.getEntityMimeType = function(entData) {
  return entData.mime || 'text/plain';
}

/**
 * Get HTML tag for a given two-letter style name.
 * @memberof Drafty
 * @static
 *
 * @param {string} style - two-letter style, like ST or LN.
 *
 * @returns {string} HTML tag name if style is found, {code: undefined} if style is falsish or not found.
 */
Drafty.tagName = function(style) {
  return FORMAT_TAGS[style] && FORMAT_TAGS[style].html_tag;
}

/**
 * For a given data bundle generate an object with HTML attributes,
 * for instance, given {url: "http://www.example.com/"} return
 * {href: "http://www.example.com/"}
 * @memberof Drafty
 * @static
 *
 * @param {string} style - two-letter style to generate attributes for.
 * @param {Object} data - data bundle to convert to attributes
 *
 * @returns {Object} object with HTML attributes.
 */
Drafty.attrValue = function(style, data) {
  if (data && DECORATORS[style]) {
    return DECORATORS[style].props(data);
  }

  return undefined;
}

/**
 * Drafty MIME type.
 * @memberof Drafty
 * @static
 *
 * @returns {string} content-Type "text/x-drafty".
 */
Drafty.getContentType = function() {
  return DRAFTY_MIME_TYPE;
}

// =================
// Utility methods.
// =================

// Take a string and defined earlier style spans, re-compose them into a tree where each leaf is
// a same-style (including unstyled) string. I.e. 'hello *bold _italic_* and ~more~ world' ->
// ('hello ', (b: 'bold ', (i: 'italic')), ' and ', (s: 'more'), ' world');
//
// This is needed in order to clear markup, i.e. 'hello *world*' -> 'hello world' and convert
// ranges from markup-ed offsets to plain text offsets.
function chunkify(line, start, end, spans) {
  const chunks = [];

  if (spans.length == 0) {
    return [];
  }

  for (let i in spans) {
    // Get the next chunk from the queue
    const span = spans[i];

    // Grab the initial unstyled chunk
    if (span.at > start) {
      chunks.push({
        txt: line.slice(start, span.at)
      });
    }

    // Grab the styled chunk. It may include subchunks.
    const chunk = {
      tp: span.tp
    };
    const chld = chunkify(line, span.at + 1, span.end, span.children);
    if (chld.length > 0) {
      chunk.children = chld;
    } else {
      chunk.txt = span.txt;
    }
    chunks.push(chunk);
    start = span.end + 1; // '+1' is to skip the formatting character
  }

  // Grab the remaining unstyled chunk, after the last span
  if (start < end) {
    chunks.push({
      txt: line.slice(start, end)
    });
  }

  return chunks;
}

// Detect starts and ends of formatting spans. Unformatted spans are
// ignored at this stage.
function spannify(original, re_start, re_end, type) {
  const result = [];
  let index = 0;
  let line = original.slice(0); // make a copy;

  while (line.length > 0) {
    // match[0]; // match, like '*abc*'
    // match[1]; // match captured in parenthesis, like 'abc'
    // match['index']; // offset where the match started.

    // Find the opening token.
    const start = re_start.exec(line);
    if (start == null) {
      break;
    }

    // Because javascript RegExp does not support lookbehind, the actual offset may not point
    // at the markup character. Find it in the matched string.
    let start_offset = start['index'] + start[0].lastIndexOf(start[1]);
    // Clip the processed part of the string.
    line = line.slice(start_offset + 1);
    // start_offset is an offset within the clipped string. Convert to original index.
    start_offset += index;
    // Index now point to the beginning of 'line' within the 'original' string.
    index = start_offset + 1;

    // Find the matching closing token.
    const end = re_end ? re_end.exec(line) : null;
    if (end == null) {
      break;
    }
    let end_offset = end['index'] + end[0].indexOf(end[1]);
    // Clip the processed part of the string.
    line = line.slice(end_offset + 1);
    // Update offsets
    end_offset += index;
    // Index now points to the beginning of 'line' within the 'original' string.
    index = end_offset + 1;

    result.push({
      txt: original.slice(start_offset + 1, end_offset),
      children: [],
      at: start_offset,
      end: end_offset,
      tp: type
    });
  }

  return result;
}

// Convert linear array or spans into a tree representation.
// Keep standalone and nested spans, throw away partially overlapping spans.
function toSpanTree(spans) {
  if (spans.length == 0) {
    return [];
  }

  const tree = [spans[0]];
  let last = spans[0];
  for (let i = 1; i < spans.length; i++) {
    // Keep spans which start after the end of the previous span or those which
    // are complete within the previous span.
    if (spans[i].at > last.end) {
      // Span is completely outside of the previous span.
      tree.push(spans[i]);
      last = spans[i];
    } else if (spans[i].end <= last.end) {
      // Span is fully inside of the previous span. Push to subnode.
      last.children.push(spans[i]);
    }
    // Span could partially overlap, ignoring it as invalid.
  }

  // Recursively rearrange the subnodes.
  for (let i in tree) {
    tree[i].children = toSpanTree(tree[i].children);
  }

  return tree;
}

// Convert drafty document to a tree.
function draftyToTree(doc) {
  if (!doc) {
    return null;
  }

  doc = (typeof doc == 'string') ? {
    txt: doc
  } : doc;
  let {
    txt,
    fmt,
    ent
  } = doc;

  txt = txt || '';
  if (!Array.isArray(ent)) {
    ent = [];
  }

  if (!Array.isArray(fmt) || fmt.length == 0) {
    if (ent.length == 0) {
      return {
        text: txt
      };
    }

    // Handle special case when all values in fmt are 0 and fmt therefore is skipped.
    fmt = [{
      at: 0,
      len: 0,
      key: 0
    }];
  }

  // Sanitize spans.
  const spans = [];
  const attachments = [];
  fmt.forEach((span) => {
    if (!span || typeof span != 'object') {
      return;
    }

    if (!['undefined', 'number'].includes(typeof span.at)) {
      // Present, but non-numeric 'at'.
      return;
    }
    if (!['undefined', 'number'].includes(typeof span.len)) {
      // Present, but non-numeric 'len'.
      return;
    }
    let at = span.at | 0;
    let len = span.len | 0;
    if (len < 0) {
      // Invalid span length.
      return;
    }

    let key = span.key || 0;
    if (ent.length > 0 && (typeof key != 'number' || key < 0 || key >= ent.length)) {
      // Invalid key value.
      return;
    }

    if (at <= -1) {
      // Attachment. Store attachments separately.
      attachments.push({
        start: -1,
        end: 0,
        key: key
      });
      return;
    } else if (at + len > txt.length) {
      // Span is out of bounds.
      return;
    }

    if (!span.tp) {
      if (ent.length > 0 && (typeof ent[key] == 'object')) {
        spans.push({
          start: at,
          end: at + len,
          key: key
        });
      }
    } else {
      spans.push({
        type: span.tp,
        start: at,
        end: at + len
      });
    }
  });

  // Sort spans first by start index (asc) then by length (desc), then by weight.
  spans.sort((a, b) => {
    let diff = a.start - b.start;
    if (diff != 0) {
      return diff;
    }
    diff = b.end - a.end;
    if (diff != 0) {
      return diff;
    }
    return FMT_WEIGHT.indexOf(b.type) - FMT_WEIGHT.indexOf(a.type);
  });

  // Move attachments to the end of the list.
  if (attachments.length > 0) {
    spans.push(...attachments);
  }

  spans.forEach((span) => {
    if (ent.length > 0 && !span.type && ent[span.key] && typeof ent[span.key] == 'object') {
      span.type = ent[span.key].tp;
      span.data = ent[span.key].data;
    }

    // Is type still undefined? Hide the invalid element!
    if (!span.type) {
      span.type = 'HD';
    }
  });

  let tree = spansToTree({}, txt, 0, txt.length, spans);

  // Flatten tree nodes.
  const flatten = function(node) {
    if (Array.isArray(node.children) && node.children.length == 1) {
      // Unwrap.
      const child = node.children[0];
      if (!node.type) {
        const parent = node.parent;
        node = child;
        node.parent = parent;
      } else if (!child.type && !child.children) {
        node.text = child.text;
        delete node.children;
      }
    }
    return node;
  }
  tree = treeTopDown(tree, flatten);

  return tree;
}

// Add tree node to a parent tree.
function addNode(parent, n) {
  if (!n) {
    return parent;
  }

  if (!parent.children) {
    parent.children = [];
  }

  // If text is present, move it to a subnode.
  if (parent.text) {
    parent.children.push({
      text: parent.text,
      parent: parent
    });
    delete parent.text;
  }

  n.parent = parent;
  parent.children.push(n);

  return parent;
}

// Returns a tree of nodes.
function spansToTree(parent, text, start, end, spans) {
  if (!spans || spans.length == 0) {
    if (start < end) {
      addNode(parent, {
        text: text.substring(start, end)
      });
    }
    return parent;
  }

  // Process subspans.
  for (let i = 0; i < spans.length; i++) {
    const span = spans[i];
    if (span.start < 0 && span.type == 'EX') {
      addNode(parent, {
        type: span.type,
        data: span.data,
        key: span.key,
        att: true
      });
      continue;
    }

    // Add un-styled range before the styled span starts.
    if (start < span.start) {
      addNode(parent, {
        text: text.substring(start, span.start)
      });
      start = span.start;
    }

    // Get all spans which are within the current span.
    const subspans = [];
    while (i < spans.length - 1) {
      const inner = spans[i + 1];
      if (inner.start < 0) {
        // Attachments are in the end. Stop.
        break;
      } else if (inner.start < span.end) {
        if (inner.end <= span.end) {
          const tag = FORMAT_TAGS[inner.tp] || {};
          if (inner.start < inner.end || tag.isVoid) {
            // Valid subspan: completely within the current span and
            // either non-zero length or zero length is acceptable.
            subspans.push(inner);
          }
        }
        i++;
        // Overlapping subspans are ignored.
      } else {
        // Past the end of the current span. Stop.
        break;
      }
    }

    addNode(parent, spansToTree({
      type: span.type,
      data: span.data,
      key: span.key
    }, text, start, span.end, subspans));
    start = span.end;
  }

  // Add the last unformatted range.
  if (start < end) {
    addNode(parent, {
      text: text.substring(start, end)
    });
  }

  return parent;
}

// Append a tree to a Drafty doc.
function treeToDrafty(doc, tree, keymap) {
  if (!tree) {
    return doc;
  }

  doc.txt = doc.txt || '';

  // Checkpoint to measure length of the current tree node.
  const start = doc.txt.length;

  if (tree.text) {
    doc.txt += tree.text;
  } else if (Array.isArray(tree.children)) {
    tree.children.forEach((c) => {
      treeToDrafty(doc, c, keymap);
    });
  }

  if (tree.type) {
    const len = doc.txt.length - start;
    doc.fmt = doc.fmt || [];
    if (Object.keys(tree.data || {}).length > 0) {
      doc.ent = doc.ent || [];
      const newKey = (typeof keymap[tree.key] == 'undefined') ? doc.ent.length : keymap[tree.key];
      keymap[tree.key] = newKey;
      doc.ent[newKey] = {
        tp: tree.type,
        data: tree.data
      };
      if (tree.att) {
        // Attachment.
        doc.fmt.push({
          at: -1,
          len: 0,
          key: newKey
        });
      } else {
        doc.fmt.push({
          at: start,
          len: len,
          key: newKey
        });
      }
    } else {
      doc.fmt.push({
        tp: tree.type,
        at: start,
        len: len
      });
    }
  }
  return doc;
}

// Traverse the tree top down transforming the nodes: apply transformer to every tree node.
function treeTopDown(src, transformer, context) {
  if (!src) {
    return null;
  }

  let dst = transformer.call(context, src);
  if (!dst || !dst.children) {
    return dst;
  }

  const children = [];
  for (let i in dst.children) {
    let n = dst.children[i];
    if (n) {
      n = treeTopDown(n, transformer, context);
      if (n) {
        children.push(n);
      }
    }
  }

  if (children.length == 0) {
    dst.children = null;
  } else {
    dst.children = children;
  }

  return dst;
}

// Traverse the tree bottom-up: apply formatter to every node.
// The formatter must maintain its state through context.
function treeBottomUp(src, formatter, index, stack, context) {
  if (!src) {
    return null;
  }

  if (stack && src.type) {
    stack.push(src.type);
  }

  let values = [];
  for (let i in src.children) {
    const n = treeBottomUp(src.children[i], formatter, i, stack, context);
    if (n) {
      values.push(n);
    }
  }
  if (values.length == 0) {
    if (src.text) {
      values = [src.text];
    } else {
      values = null;
    }
  }

  if (stack && src.type) {
    stack.pop();
  }

  return formatter.call(context, src.type, src.data, values, index, stack);
}

// Clip tree to the provided limit.
function shortenTree(tree, limit, tail) {
  if (!tree) {
    return null;
  }

  if (tail) {
    limit -= tail.length;
  }

  const shortener = function(node) {
    if (limit <= -1) {
      // Limit -1 means the doc was already clipped.
      return null;
    }

    if (node.att) {
      // Attachments are unchanged.
      return node;
    }
    if (limit == 0) {
      node.text = tail;
      limit = -1;
    } else if (node.text) {
      const len = node.text.length;
      if (len > limit) {
        node.text = node.text.substring(0, limit) + tail;
        limit = -1;
      } else {
        limit -= len;
      }
    }
    return node;
  }

  return treeTopDown(tree, shortener);
}

// Strip heavy entities from a tree.
function lightEntity(tree, allow) {
  const lightCopy = node => {
    const data = copyEntData(node.data, true, allow ? allow(node) : null);
    if (data) {
      node.data = data;
    } else {
      delete node.data;
    }
    return node;
  }
  return treeTopDown(tree, lightCopy);
}

// Remove spaces and breaks on the left.
function lTrim(tree) {
  if (tree.type == 'BR') {
    tree = null;
  } else if (tree.text) {
    if (!tree.type) {
      tree.text = tree.text.trimStart();
      if (!tree.text) {
        tree = null;
      }
    }
  } else if (!tree.type && tree.children && tree.children.length > 0) {
    const c = lTrim(tree.children[0]);
    if (c) {
      tree.children[0] = c;
    } else {
      tree.children.shift();
      if (!tree.type && tree.children.length == 0) {
        tree = null;
      }
    }
  }
  return tree;
}

// Move attachments to the end. Attachments must be at the top level, no need to traverse the tree.
function attachmentsToEnd(tree, limit) {
  if (!tree) {
    return null;
  }

  if (tree.att) {
    tree.text = ' ';
    delete tree.att;
    delete tree.children;
  } else if (tree.children) {
    const attachments = [];
    const children = [];
    for (let i in tree.children) {
      const c = tree.children[i];
      if (c.att) {
        if (attachments.length == limit) {
          // Too many attachments to preview;
          continue;
        }
        if (c.data['mime'] == JSON_MIME_TYPE) {
          // JSON attachments are not shown in preview.
          continue;
        }

        delete c.att;
        delete c.children;
        c.text = ' ';
        attachments.push(c);
      } else {
        children.push(c);
      }
    }
    tree.children = children.concat(attachments);
  }
  return tree;
}

// Get a list of entities from a text.
function extractEntities(line) {
  let match;
  let extracted = [];
  ENTITY_TYPES.forEach((entity) => {
    while ((match = entity.re.exec(line)) !== null) {
      extracted.push({
        offset: match['index'],
        len: match[0].length,
        unique: match[0],
        data: entity.pack(match[0]),
        type: entity.name
      });
    }
  });

  if (extracted.length == 0) {
    return extracted;
  }

  // Remove entities detected inside other entities, like #hashtag in a URL.
  extracted.sort((a, b) => {
    return a.offset - b.offset;
  });

  let idx = -1;
  extracted = extracted.filter((el) => {
    const result = (el.offset > idx);
    idx = el.offset + el.len;
    return result;
  });

  return extracted;
}

// Convert the chunks into format suitable for serialization.
function draftify(chunks, startAt) {
  let plain = '';
  let ranges = [];
  for (let i in chunks) {
    const chunk = chunks[i];
    if (!chunk.txt) {
      const drafty = draftify(chunk.children, plain.length + startAt);
      chunk.txt = drafty.txt;
      ranges = ranges.concat(drafty.fmt);
    }

    if (chunk.tp) {
      ranges.push({
        at: plain.length + startAt,
        len: chunk.txt.length,
        tp: chunk.tp
      });
    }

    plain += chunk.txt;
  }
  return {
    txt: plain,
    fmt: ranges
  };
}

// Create a copy of entity data with (light=false) or without (light=true) the large payload.
// The array 'allow' contains a list of fields exempt from stripping.
function copyEntData(data, light, allow) {
  if (data && Object.entries(data).length > 0) {
    allow = allow || [];
    const dc = {};
    ALLOWED_ENT_FIELDS.forEach(key => {
      if (data[key]) {
        if (light && !allow.includes(key) &&
          (typeof data[key] == 'string' || Array.isArray(data[key])) &&
          data[key].length > MAX_PREVIEW_DATA_SIZE) {
          return;
        }
        if (typeof data[key] == 'object') {
          return;
        }
        dc[key] = data[key];
      }
    });

    if (Object.entries(dc).length != 0) {
      return dc;
    }
  }
  return null;
}

if (typeof module != 'undefined') {
  module.exports = Drafty;
}