cli-ux
Version:
cli IO utilities
313 lines (312 loc) • 12.8 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.table = void 0;
const tslib_1 = require("tslib");
const F = (0, tslib_1.__importStar)(require("@oclif/core/lib/flags"));
const screen_1 = require("@oclif/screen");
const chalk_1 = (0, tslib_1.__importDefault)(require("chalk"));
const capitalize_1 = (0, tslib_1.__importDefault)(require("lodash/capitalize"));
const sumBy_1 = (0, tslib_1.__importDefault)(require("lodash/sumBy"));
const js_yaml_1 = require("js-yaml");
const util_1 = require("util");
const sw = require('string-width');
const { orderBy } = require('natural-orderby');
class Table {
constructor(data, columns, options = {}) {
this.data = data;
// assign columns
this.columns = Object.keys(columns).map((key) => {
const col = columns[key];
const extended = col.extended || false;
const get = col.get || ((row) => row[key]);
const header = typeof col.header === 'string' ? col.header : (0, capitalize_1.default)(key.replace(/_/g, ' '));
const minWidth = Math.max(col.minWidth || 0, sw(header) + 1);
return {
extended,
get,
header,
key,
minWidth,
};
});
// assign options
const { columns: cols, filter, csv, output, extended, sort, title, printLine } = options;
this.options = {
columns: cols,
output: csv ? 'csv' : output,
extended,
filter,
'no-header': options['no-header'] || false,
'no-truncate': options['no-truncate'] || false,
printLine: printLine || ((s) => process.stdout.write(s + '\n')),
rowStart: ' ',
sort,
title,
};
}
display() {
// build table rows from input array data
let rows = this.data.map(d => {
const row = {};
for (const col of this.columns) {
let val = col.get(d);
if (typeof val !== 'string')
val = (0, util_1.inspect)(val, { breakLength: Number.POSITIVE_INFINITY });
row[col.key] = val;
}
return row;
});
// filter rows
if (this.options.filter) {
/* eslint-disable-next-line prefer-const */
let [header, regex] = this.options.filter.split('=');
const isNot = header[0] === '-';
if (isNot)
header = header.slice(1);
const col = this.findColumnFromHeader(header);
if (!col || !regex)
throw new Error('Filter flag has an invalid value');
rows = rows.filter((d) => {
const re = new RegExp(regex);
const val = d[col.key];
const match = val.match(re);
return isNot ? !match : match;
});
}
// sort rows
if (this.options.sort) {
const sorters = this.options.sort.split(',');
const sortHeaders = sorters.map(k => k[0] === '-' ? k.slice(1) : k);
const sortKeys = this.filterColumnsFromHeaders(sortHeaders).map(c => {
return ((v) => v[c.key]);
});
const sortKeysOrder = sorters.map(k => k[0] === '-' ? 'desc' : 'asc');
rows = orderBy(rows, sortKeys, sortKeysOrder);
}
// and filter columns
if (this.options.columns) {
const filters = this.options.columns.split(',');
this.columns = this.filterColumnsFromHeaders(filters);
}
else if (!this.options.extended) {
// show extented columns/properties
this.columns = this.columns.filter(c => !c.extended);
}
this.data = rows;
switch (this.options.output) {
case 'csv':
this.outputCSV();
break;
case 'json':
this.outputJSON();
break;
case 'yaml':
this.outputYAML();
break;
default:
this.outputTable();
}
}
findColumnFromHeader(header) {
return this.columns.find(c => c.header.toLowerCase() === header.toLowerCase());
}
filterColumnsFromHeaders(filters) {
// unique
filters = [...(new Set(filters))];
const cols = [];
for (const f of filters) {
const c = this.columns.find(c => c.header.toLowerCase() === f.toLowerCase());
if (c)
cols.push(c);
}
return cols;
}
getCSVRow(d) {
const values = this.columns.map(col => d[col.key] || '');
const lineToBeEscaped = values.find((e) => e.includes('"') || e.includes('\n') || e.includes('\r\n') || e.includes('\r') || e.includes(','));
return values.map(e => lineToBeEscaped ? `"${e.replace('"', '""')}"` : e);
}
resolveColumnsToObjectArray() {
// tslint:disable-next-line:no-this-assignment
const { data, columns } = this;
return data.map((d) => {
// eslint-disable-next-line unicorn/prefer-object-from-entries
return columns.reduce((obj, col) => {
return Object.assign(Object.assign({}, obj), { [col.key]: d[col.key] || '' });
}, {});
});
}
outputJSON() {
this.options.printLine(JSON.stringify(this.resolveColumnsToObjectArray(), undefined, 2));
}
outputYAML() {
this.options.printLine((0, js_yaml_1.safeDump)(this.resolveColumnsToObjectArray()));
}
outputCSV() {
// tslint:disable-next-line:no-this-assignment
const { data, columns, options } = this;
if (!options['no-header']) {
options.printLine(columns.map(c => c.header).join(','));
}
for (const d of data) {
const row = this.getCSVRow(d);
options.printLine(row.join(','));
}
}
outputTable() {
// tslint:disable-next-line:no-this-assignment
const { data, columns, options } = this;
// column truncation
//
// find max width for each column
for (const col of columns) {
// convert multi-line cell to single longest line
// for width calculations
const widthData = data.map((row) => {
const d = row[col.key];
const manyLines = d.split('\n');
if (manyLines.length > 1) {
return '*'.repeat(Math.max(...manyLines.map((r) => sw(r))));
}
return d;
});
const widths = ['.'.padEnd(col.minWidth - 1), col.header, ...widthData.map((row) => row)].map(r => sw(r));
col.maxWidth = Math.max(...widths) + 1;
col.width = col.maxWidth;
}
// terminal width
const maxWidth = screen_1.stdtermwidth - 2;
// truncation logic
const shouldShorten = () => {
// don't shorten if full mode
if (options['no-truncate'] || (!process.stdout.isTTY && !process.env.CLI_UX_SKIP_TTY_CHECK))
return;
// don't shorten if there is enough screen width
const dataMaxWidth = (0, sumBy_1.default)(columns, c => c.width);
const overWidth = dataMaxWidth - maxWidth;
if (overWidth <= 0)
return;
// not enough room, short all columns to minWidth
for (const col of columns) {
col.width = col.minWidth;
}
// if sum(minWidth's) is greater than term width
// nothing can be done so
// display all as minWidth
const dataMinWidth = (0, sumBy_1.default)(columns, c => c.minWidth);
if (dataMinWidth >= maxWidth)
return;
// some wiggle room left, add it back to "needy" columns
let wiggleRoom = maxWidth - dataMinWidth;
const needyCols = columns.map(c => ({ key: c.key, needs: c.maxWidth - c.width })).sort((a, b) => a.needs - b.needs);
for (const { key, needs } of needyCols) {
if (!needs)
continue;
const col = columns.find(c => key === c.key);
if (!col)
continue;
if (wiggleRoom > needs) {
col.width = col.width + needs;
wiggleRoom -= needs;
}
else if (wiggleRoom) {
col.width = col.width + wiggleRoom;
wiggleRoom = 0;
}
}
};
shouldShorten();
// print table title
if (options.title) {
options.printLine(options.title);
// print title divider
options.printLine(''.padEnd(columns.reduce((sum, col) => sum + col.width, 1), '='));
options.rowStart = '| ';
}
// print headers
if (!options['no-header']) {
let headers = options.rowStart;
for (const col of columns) {
const header = col.header;
headers += header.padEnd(col.width);
}
options.printLine(chalk_1.default.bold(headers));
// print header dividers
let dividers = options.rowStart;
for (const col of columns) {
const divider = ''.padEnd(col.width - 1, '─') + ' ';
dividers += divider.padEnd(col.width);
}
options.printLine(chalk_1.default.bold(dividers));
}
// print rows
for (const row of data) {
// find max number of lines
// for all cells in a row
// with multi-line strings
let numOfLines = 1;
for (const col of columns) {
const d = row[col.key];
const lines = d.split('\n').length;
if (lines > numOfLines)
numOfLines = lines;
}
// eslint-disable-next-line unicorn/no-new-array
const linesIndexess = [...new Array(numOfLines).keys()];
// print row
// including multi-lines
for (const i of linesIndexess) {
let l = options.rowStart;
for (const col of columns) {
const width = col.width;
let d = row[col.key];
d = d.split('\n')[i] || '';
const visualWidth = sw(d);
const colorWidth = (d.length - visualWidth);
let cell = d.padEnd(width + colorWidth);
if ((cell.length - colorWidth) > width || visualWidth === width) {
cell = cell.slice(0, width - 2) + '… ';
}
l += cell;
}
options.printLine(l);
}
}
}
}
function table(data, columns, options = {}) {
new Table(data, columns, options).display();
}
exports.table = table;
(function (table) {
table.Flags = {
columns: F.string({ exclusive: ['extended'], description: 'only show provided columns (comma-separated)' }),
sort: F.string({ description: 'property to sort by (prepend \'-\' for descending)' }),
filter: F.string({ description: 'filter property by partial string matching, ex: name=foo' }),
csv: F.boolean({ exclusive: ['no-truncate'], description: 'output is csv format [alias: --output=csv]' }),
output: F.string({
exclusive: ['no-truncate', 'csv'],
description: 'output in a more machine friendly format',
options: ['csv', 'json', 'yaml'],
}),
extended: F.boolean({ exclusive: ['columns'], char: 'x', description: 'show extra columns' }),
'no-truncate': F.boolean({ exclusive: ['csv'], description: 'do not truncate output to fit screen' }),
'no-header': F.boolean({ exclusive: ['csv'], description: 'hide table header from output' }),
};
// eslint-disable-next-line no-inner-declarations
function flags(opts) {
if (opts) {
const f = {};
const o = (opts.only && typeof opts.only === 'string' ? [opts.only] : opts.only) || Object.keys(table.Flags);
const e = (opts.except && typeof opts.except === 'string' ? [opts.except] : opts.except) || [];
for (const key of o) {
if (!e.includes(key)) {
f[key] = table.Flags[key];
}
}
return f;
}
return table.Flags;
}
table.flags = flags;
})(table = exports.table || (exports.table = {}));
;