All files / packages/icons/scripts slack-list-to-csv.ts

77.88% Statements 81/104
73.23% Branches 52/71
66.66% Functions 8/12
80.45% Lines 70/87

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161                                  1x       4x 4x 4x 4x   4x 922x 922x 612x 20x 20x 592x 38x   554x   310x 38x 272x 74x 74x 198x 11x 11x 11x 11x 11x   187x     4x 4x 4x   4x         9x 9x 8x 8x 5x           1x 1x 1x                 3x 70x 3x 3x 3x 3x 3x 3x 3x   3x 1x         2x 2x 50x 9x 9x 9x 7x 5x   4x   5x   4x 9x 9x 1x 1x   3x       2x   3x   2x       1x                                                 1x            
/**
 * Convert a CSV downloaded from the "SLDS Icon Contribution" Slack list into the
 * workflow CSV format (icon_name,icon_type,color,synonyms,has_ltr_rtl) read by
 * csv-parser, keeping only rows whose Target Build matches the given release.
 *
 * Usage:
 *   yarn workspace @salesforce-ux/icons slack-list-to-csv --<release> <downloaded.csv> [output.csv]
 *   e.g. slack-list-to-csv --266 ~/Downloads/list.csv
 *
 * yarn runs this from packages/icons, so pass an absolute path (e.g. ~/Downloads/…).
 * The output defaults to <downloaded>-workflow.csv next to the input.
 */
 
import { readFile, writeFile } from 'fs/promises';
import { basename, dirname, extname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
 
const WORKFLOW_CSV_HEADER = 'icon_name,icon_type,color,synonyms,has_ltr_rtl';
 
/** Parse RFC 4180 CSV (quoted fields, "" escapes, newlines inside quotes) into rows of cells. */
export function parseCsv(content: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let cell = '';
  let quoted = false;
 
  for (let i = 0; i < content.length; i++) {
    const char = content[i];
    if (quoted) {
      if (char === '"' && content[i + 1] === '"') {
        cell += '"';
        i++;
      } else if (char === '"') {
        quoted = false;
      } else {
        cell += char;
      }
    } else if (char === '"') {
      quoted = true;
    } else if (char === ',') {
      row.push(cell);
      cell = '';
    } else if (char === '\n' || char === '\r') {
      if (char === '\r' && content[i + 1] === '\n') i++;
      row.push(cell);
      rows.push(row);
      row = [];
      cell = '';
    } else {
      cell += char;
    }
  }
  Eif (cell || row.length > 0) {
    row.push(cell);
    rows.push(row);
  }
  return rows;
}
 
/** Map the list's "Icon Type" choice to a workflow icon type, or null when it isn't a new icon. */
function toIconType(value: string): string | null {
  const type = value.toLowerCase();
  if (type.includes('to utility')) return 'Utility';
  Iif (type.includes('to standard')) return 'Standard Object';
  if (type.startsWith('standard')) return 'Standard Object';
  Eif (type.startsWith('utility')) return 'Utility';
  if (type.startsWith('action')) return 'Action';
  if (type.startsWith('doctype')) return 'Doctype';
  return null;
}
 
const ICON_NAME_REGEX = /^[a-z0-9_]+$/;
const COLOR_NAME_REGEX = /^[a-z0-9-]+$/;
const QUOTES_REGEX = /["“”]/g;
 
/**
 * Convert the Slack list export to workflow CSV. Keeps rows for the given Target Build
 * (e.g. "266") that are handed to engineering and not yet shipped, with a valid snake_case
 * name and a known type. Duplicate rows for the same icon are merged, keeping the first
 * color and synonyms that are filled in.
 */
export function slackListToWorkflowCsv(listCsv: string, targetBuild: string): { csv: string; count: number } {
  const [headers = [], ...rows] = parseCsv(listCsv.replace(/^/, ''));
  const column = (name: string) => headers.findIndex((header) => header.trim().startsWith(name));
  const nameCol = column('Proposed Icon name');
  const typeCol = column('Icon Type');
  const colorCol = column('Icon color');
  const synonymsCol = column('List of synonyms');
  const designCol = column('Design Status');
  const engineerCol = column('Engineer Status');
  const buildCol = column('Target Build');
 
  if ([nameCol, typeCol, colorCol, synonymsCol, designCol, engineerCol, buildCol].includes(-1)) {
    throw new Error(
      'Slack list is missing an expected column (name, type, color, synonyms, status or target build)',
    );
  }
 
  const icons = new Map<string, { name: string; type: string; color: string; synonyms: string[] }>();
  for (const row of rows) {
    const cell = (index: number) => (row[index] ?? '').trim();
    const name = cell(nameCol);
    const type = toIconType(cell(typeCol));
    if (cell(buildCol) !== targetBuild) continue;
    if (cell(designCol) !== 'Handed to Eng.' || cell(engineerCol) === 'Shipped') continue;
    if (!type || !ICON_NAME_REGEX.test(name)) continue;
 
    const synonyms = cell(synonymsCol)
      .split(',')
      .map((synonym) => synonym.replace(QUOTES_REGEX, '').trim())
      .filter(Boolean);
    const color = COLOR_NAME_REGEX.test(cell(colorCol)) ? cell(colorCol) : '';
    const existing = icons.get(`${name}--${type}`);
    if (existing) {
      existing.color ||= color;
      Iif (existing.synonyms.length === 0) existing.synonyms = synonyms;
    } else {
      icons.set(`${name}--${type}`, { name, type, color, synonyms });
    }
  }
 
  const lines = [...icons.values()].map(
    ({ name, type, color, synonyms }) =>
      `${name},${type},${color},${synonyms.length > 0 ? `"${synonyms.join(', ')}"` : ''},`,
  );
  return { csv: [WORKFLOW_CSV_HEADER, ...lines].join('\n'), count: lines.length };
}
 
const USAGE =
  'Usage: yarn workspace @salesforce-ux/icons slack-list-to-csv --<release> <downloaded.csv> [output.csv]\n' +
  '  e.g. slack-list-to-csv --266 ~/Downloads/list.csv';
 
async function main() {
  const args = process.argv.slice(2);
  const release = args.find((arg) => /^--\d+$/.test(arg))?.slice(2);
  const [input, output] = args.filter((arg) => !/^--\d+$/.test(arg));
  if (!release || !input) {
    console.error(USAGE);
    process.exit(1);
  }
 
  const inputPath = resolve(input);
  const outputPath = output
    ? resolve(output)
    : join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}-workflow.csv`);
 
  const { csv, count } = slackListToWorkflowCsv(await readFile(inputPath, 'utf8'), release);
  await writeFile(outputPath, `${csv}\n`, 'utf8');
  console.log(
    `Wrote ${count} icons targeting build ${release}, handed to engineering (not yet shipped), to ${outputPath}`,
  );
}
 
// Run as a CLI only when executed directly, so tests can import the functions above.
Iif (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : error);
    process.exit(1);
  });
}