table-clipboard.ts2.8 KBView on GitHub
/**
 * The clipboard grammar a spreadsheet speaks: TAB-separated columns, newline-separated rows,
 * with `"`-quoting for any value that contains one of those.
 *
 * This is what Excel, Sheets, Numbers and every grid before them put on `text/plain`, so it is
 * what copying out of this grid has to produce and what pasting into it has to accept. Written
 * here rather than inline in `TableGrid` because a serializer and its parser are only correct
 * as a PAIR — a round-trip test over this module is what pins that, and it cannot be written
 * against a component.
 *
 * Values are cell strings, tokens included: a `[[conversation: id]]` copied between two Cedar
 * tables must land as a live reference on the other side, not as the text of one.
 */

const CELL_SEPARATOR = '\t';
const ROW_SEPARATOR = '\n';

/** True when a value has to be quoted to survive the round trip. */
function needsQuoting(value: string): boolean {
  return value.includes(CELL_SEPARATOR) || value.includes('\n') || value.includes('"');
}

export function toTsv(grid: string[][]): string {
  return grid
    .map((row) =>
      row
        .map((value) => (needsQuoting(value) ? `"${value.replace(/"/g, '""')}"` : value))
        .join(CELL_SEPARATOR),
    )
    .join(ROW_SEPARATOR);
}

/**
 * Parse pasted text into a rectangle of cell values.
 *
 * Hand-rolled rather than split-on-tab because a quoted field may legally CONTAIN both
 * separators — a pasted email body with newlines in it is the common case, and splitting would
 * turn one cell into a dozen rows. Anything unquoted is taken verbatim, so plain text with no
 * quotes at all parses exactly as a naive split would.
 */
export function fromTsv(text: string): string[][] {
  if (text === '') return [];
  const rows: string[][] = [];
  let row: string[] = [];
  let value = '';
  let quoted = false;

  for (let index = 0; index < text.length; index++) {
    const char = text[index];
    if (quoted) {
      if (char !== '"') {
        value += char;
        continue;
      }
      // `""` inside a quoted field is one literal quote; a lone `"` closes the field.
      if (text[index + 1] === '"') {
        value += '"';
        index++;
        continue;
      }
      quoted = false;
      continue;
    }
    if (char === '"' && value === '') {
      quoted = true;
      continue;
    }
    if (char === CELL_SEPARATOR) {
      row.push(value);
      value = '';
      continue;
    }
    if (char === '\r') continue;
    if (char === '\n') {
      row.push(value);
      rows.push(row);
      row = [];
      value = '';
      continue;
    }
    value += char;
  }
  row.push(value);
  rows.push(row);
  // A trailing newline is punctuation, not an empty row — every spreadsheet emits one.
  const last = rows[rows.length - 1];
  if (rows.length > 1 && last?.length === 1 && last[0] === '') rows.pop();
  return rows;
}