Home Reference Source Test Repository

src/index.js

import Trello from 'node-trello';


/**
 * Parse Error
 */
class ParseError extends Error {
  /**
   * constructor
   *
   * @param {string} type error type
   * @param {string} message error message
   * @param {string} text raw text
   */
  constructor({type, message, text}) {
    super(`(${type}): ${message} "${text}"`);
    this.name = this.constructor.name;
    this.type = type;
    this.text = text;
    Error.captureStackTrace(this, this.constructor.name);
  }
}


/**
 * Create name for Trello card
 *
 * @param {Object} story story
 * @return {string}
 */
function createCardName(story) {
  const items = [`${story.id}:`];
  if (story.times) {
    const timeStr = [story.times.spent, story.times.es50, story.times.es90]
      .filter(i => i !== undefined)
      .map(i => i.toString())
      .join('/');
    if (timeStr) {
      items.push(`(${timeStr})`);
    }
  }
  items.push(`${story.title}`);
  if (story.parentId) {
    items.push(`#${story.parentId}`);
  }
  if (story.dependIds.length > 0) {
    items.push(`${story.dependIds.map(i => `&${i}`).join(' ')}`);
  }
  return items.join(' ');
}


/**
 * is Story
 *
 * @param {string} text text
 * @return {boolean}
 */
function isStory(text) {
  return /^#(\d+)_/.test(text);
}


/**
 * Parse text to times
 *
 * @param {string} text title text
 * @return {Object}
 */
function parseTextToTimes(text) {
  const match = /(?:\((?:(\d+)\/)?(\d+)\/(\d+)\)\s+)?(.*)$/.exec(text);
  if (match) {
    const [spent, es50, es90, body] = match.slice(1);
    return {
      body: body.trim(),
      times: {
        spent: spent && Number.parseInt(spent, 10),
        es50: es50 && Number.parseInt(es50, 10),
        es90: es90 && Number.parseInt(es90, 10)
      }
    };
  }
  return {
    body: text.trim(),
    times: null
  };
}


/**
 * Parse text to labels
 *
 * must to use after parseTextToTimes()
 *
 * @param {string} text title text
 * @return {Object}
 */
function parseTextToLabels(text) {
  const matches = /^(?:\[(.+)\]\s+)?(.*)$/.exec(text);
  if (!matches) {
    return {
      body: text.trim(),
      labels: []
    };
  }
  const [labels, body] = matches.slice(1);
  return {
    body: body.trim(),
    labels: labels ? labels.split(',').map(l => l.trim()).filter(l => l) : []
  };
}


/**
 * Parse text to raw story object
 *
 * @param {string} text text
 * @return {Object}
 */
function parseTextToStory(text) {
  const matches = /^#(\d+)_\s+(.+)$/.exec(text);
  if (matches) {
    const [id, content] = matches.slice(1);
    const regex = /#(\d+)_/g;
    const idx = content.search(regex);
    const title = idx >= 0 ? content.slice(0, idx).trim() : content;
    const {body, times} = parseTextToTimes(title);
    const ret = parseTextToLabels(body);
    return {
      id,
      title: ret.body,
      times,
      labels: ret.labels,
      dependIds: (content.match(regex) || []).map(i => i.match(/\d+/)[0])
    };
  }
  return {
    id: null,
    title: text,
    times: null,
    labels: [],
    dependIds: []
  };
}


/**
 * Parse Item to Story
 *
 * @param {Object} item item
 * @return {Array} current story and descendants stories
 */
function parseOpmlItem(item) {
  const parsedItem = parseTextToStory(item.text);
  if (!parsedItem.id && isStory(item.text)) {
    throw new ParseError({
      type: 'item',
      message: 'invalid',
      text: item.text
    });
  }
  const children = item.children || [];
  let descendants = [];
  let descriptions = [];
  children.forEach(child => {
    const [parsedChild, ds] = parseOpmlItem(child);
    if (parsedChild.id) {
      parsedChild.parentId = parsedItem.id;
      descendants = descendants.concat([parsedChild]).concat(ds);
    } else {
      descriptions = descriptions.concat(
        [`- ${parsedChild.title}`]
      ).concat(
        parsedChild.description.split('\n').filter(d => d).map(d => `  ${d}`)
      );
    }
  });
  const ret = Object.assign({}, parsedItem, {
    description: descriptions.join('\n')
  });
  return [ret, descendants];
}


/**
 * opmlToStories
 *
 * @param {Object} opmlJSON opml object
 * @return {Promise<Object[], ParseError[]>}
 */
export function opmlToStories(opmlJSON) {
  const errors = [];
  const stories = opmlJSON.children.reduce((results, item) => {
    try {
      const [parsed, ds] = parseOpmlItem(item);
      return results.concat([parsed]).concat(ds);
    } catch (e) {
      if (e.type) {
        errors.push(e);
      } else {
        throw e;
      }
    }
    return results;
  }, []);
  if (errors.length) {
    return Promise.reject(errors);
  }
  return Promise.resolve(stories);
}


/**
 * Stories To Cards
 *
 * @param {Object[]} stories stories
 * @param {Object} config config
 * @return {Promise<Object[], ParseError[]>}
 */
export function storiesToCards(stories, config) {
  const errors = [];
  const cards = stories.map(story => {
    let labels = story.parentId ? [] : [config.labels.issue];
    labels = labels.concat(story.labels.map(l => config.labelAliases[l]));
    return {
      name: createCardName(story),
      desc: story.description,
      labels: labels.concat(config.labels.open)
    };
  });
  if (errors.length) {
    return Promise.reject(errors, cards);
  }
  return Promise.resolve(cards);
}


/**
 * fetch Board data from Trello
 *
 * @param {Object} trello Trello instance
 * @param {string} boardId Trello board ID
 * @return {Promise<Object, Error>}
 */
function fetchBoardInfo(trello, boardId) {
  return new Promise((resolve, reject) => {
    trello.get(`/1/boards/${boardId}`, {
      labels: 'all',
      lists: 'open'
    }, (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}


/**
 * Post Card to Trello
 *
 * @param {Object} trello Trello instance
 * @param {Object} card card
 * @return {Promise<Object, Error>}
 */
function postCard(trello, card) {
  return new Promise((resolve, reject) => {
    trello.post(`/1/cards`, card, (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}


/**
 * Register cards to Trello
 *
 * @param {Object[]} cards card list
 * @param {Object} config config
 * @param {boolean} dryRun flag
 * @return {Promise<Object, Error>}
 */
export function sendTrello(cards, config, dryRun) {
  const trello = new Trello(config.apiKey, config.apiToken);
  const promise = fetchBoardInfo(trello, config.boardId).then(board => {
    const labelMap = board.labels.reduce((m, l) => {
      if (!l.name) {
        return m;
      }
      return Object.assign({}, m, {[l.name]: l.id});
    }, {});
    const inboxList = board.lists.find(l => l.name === config.inboxListName);
    return cards.map(c => {
      const idLabels = c.labels.map(l => labelMap[l]);
      if (idLabels.indexOf(undefined) >= 0) {
        throw new Error(`Label id not found: ${c.labels}`);
      }
      return Object.assign({}, c, {
        due: null,
        urlSource: null,
        idLabels: idLabels.join(','),
        idList: inboxList.id,
        labels: undefined
      });
    });
  });
  if (dryRun) {
    return promise.then(validCards => {
      return {
        validCards,
        responses: []
      };
    });
  }
  return promise.then(validCards => {
    const results = [];
    let pr = Promise.resolve();
    validCards.forEach(c => {
      pr = pr.then(res => {
        results.push(res);
        return postCard(trello, c);
      });
    });
    return pr.then(() => {
      return {
        validCards,
        responses: results
      };
    });
  });
}