1"use strict" 2 3const wcwidth = require('./width') 4const { 5 padRight, 6 padCenter, 7 padLeft, 8 splitIntoLines, 9 splitLongWords, 10 truncateString 11} = require('./utils') 12 13const DEFAULT_HEADING_TRANSFORM = key => key.toUpperCase() 14 15const DEFAULT_DATA_TRANSFORM = (cell, column, index) => cell 16 17const DEFAULTS = Object.freeze({ 18 maxWidth: Infinity, 19 minWidth: 0, 20 columnSplitter: ' ', 21 truncate: false, 22 truncateMarker: '…', 23 preserveNewLines: false, 24 paddingChr: ' ', 25 showHeaders: true, 26 headingTransform: DEFAULT_HEADING_TRANSFORM, 27 dataTransform: DEFAULT_DATA_TRANSFORM 28}) 29 30module.exports = function(items, options = {}) { 31 32 let columnConfigs = options.config || {} 33 delete options.config // remove config so doesn't appear on every column. 34 35 let maxLineWidth = options.maxLineWidth || Infinity 36 if (maxLineWidth === 'auto') maxLineWidth = process.stdout.columns || Infinity 37 delete options.maxLineWidth // this is a line control option, don't pass it to column 38 39 // Option defaults inheritance: 40 // options.config[columnName] => options => DEFAULTS 41 options = mixin({}, DEFAULTS, options) 42 43 options.config = options.config || Object.create(null) 44 45 options.spacing = options.spacing || '\n' // probably useless 46 options.preserveNewLines = !!options.preserveNewLines 47 options.showHeaders = !!options.showHeaders; 48 options.columns = options.columns || options.include // alias include/columns, prefer columns if supplied 49 let columnNames = options.columns || [] // optional user-supplied columns to include 50 51 items = toArray(items, columnNames) 52 53 // if not suppled column names, automatically determine columns from data keys 54 if (!columnNames.length) { 55 items.forEach(function(item) { 56 for (let columnName in item) { 57 if (columnNames.indexOf(columnName) === -1) columnNames.push(columnName) 58 } 59 }) 60 } 61 62 // initialize column defaults (each column inherits from options.config) 63 let columns = columnNames.reduce((columns, columnName) => { 64 let column = Object.create(options) 65 columns[columnName] = mixin(column, columnConfigs[columnName]) 66 return columns 67 }, Object.create(null)) 68 69 // sanitize column settings 70 columnNames.forEach(columnName => { 71 let column = columns[columnName] 72 column.name = columnName 73 column.maxWidth = Math.ceil(column.maxWidth) 74 column.minWidth = Math.ceil(column.minWidth) 75 column.truncate = !!column.truncate 76 column.align = column.align || 'left' 77 }) 78 79 // sanitize data 80 items = items.map(item => { 81 let result = Object.create(null) 82 columnNames.forEach(columnName => { 83 // null/undefined -> '' 84 result[columnName] = item[columnName] != null ? item[columnName] : '' 85 // toString everything 86 result[columnName] = '' + result[columnName] 87 if (columns[columnName].preserveNewLines) { 88 // merge non-newline whitespace chars 89 result[columnName] = result[columnName].replace(/[^\S\n]/gmi, ' ') 90 } else { 91 // merge all whitespace chars 92 result[columnName] = result[columnName].replace(/\s/gmi, ' ') 93 } 94 }) 95 return result 96 }) 97 98 // transform data cells 99 columnNames.forEach(columnName => { 100 let column = columns[columnName] 101 items = items.map((item, index) => { 102 let col = Object.create(column) 103 item[columnName] = column.dataTransform(item[columnName], col, index) 104 105 let changedKeys = Object.keys(col) 106 // disable default heading transform if we wrote to column.name 107 if (changedKeys.indexOf('name') !== -1) { 108 if (column.headingTransform !== DEFAULT_HEADING_TRANSFORM) return 109 column.headingTransform = heading => heading 110 } 111 changedKeys.forEach(key => column[key] = col[key]) 112 return item 113 }) 114 }) 115 116 // add headers 117 let headers = {} 118 if(options.showHeaders) { 119 columnNames.forEach(columnName => { 120 let column = columns[columnName] 121 122 if(!column.showHeaders){ 123 headers[columnName] = ''; 124 return; 125 } 126 127 headers[columnName] = column.headingTransform(column.name) 128 }) 129 items.unshift(headers) 130 } 131 // get actual max-width between min & max 132 // based on length of data in columns 133 columnNames.forEach(columnName => { 134 let column = columns[columnName] 135 column.width = items 136 .map(item => item[columnName]) 137 .reduce((min, cur) => { 138 // if already at maxWidth don't bother testing 139 if (min >= column.maxWidth) return min 140 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur)))) 141 }, 0) 142 }) 143 144 // split long words so they can break onto multiple lines 145 columnNames.forEach(columnName => { 146 let column = columns[columnName] 147 items = items.map(item => { 148 item[columnName] = splitLongWords(item[columnName], column.width, column.truncateMarker) 149 return item 150 }) 151 }) 152 153 // wrap long lines. each item is now an array of lines. 154 columnNames.forEach(columnName => { 155 let column = columns[columnName] 156 items = items.map((item, index) => { 157 let cell = item[columnName] 158 item[columnName] = splitIntoLines(cell, column.width) 159 160 // if truncating required, only include first line + add truncation char 161 if (column.truncate && item[columnName].length > 1) { 162 item[columnName] = splitIntoLines(cell, column.width - wcwidth(column.truncateMarker)) 163 let firstLine = item[columnName][0] 164 if (!endsWith(firstLine, column.truncateMarker)) item[columnName][0] += column.truncateMarker 165 item[columnName] = item[columnName].slice(0, 1) 166 } 167 return item 168 }) 169 }) 170 171 // recalculate column widths from truncated output/lines 172 columnNames.forEach(columnName => { 173 let column = columns[columnName] 174 column.width = items.map(item => { 175 return item[columnName].reduce((min, cur) => { 176 if (min >= column.maxWidth) return min 177 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, wcwidth(cur)))) 178 }, 0) 179 }).reduce((min, cur) => { 180 if (min >= column.maxWidth) return min 181 return Math.max(min, Math.min(column.maxWidth, Math.max(column.minWidth, cur))) 182 }, 0) 183 }) 184 185 186 let rows = createRows(items, columns, columnNames, options.paddingChr) // merge lines into rows 187 // conceive output 188 return rows.reduce((output, row) => { 189 return output.concat(row.reduce((rowOut, line) => { 190 return rowOut.concat(line.join(options.columnSplitter)) 191 }, [])) 192 }, []) 193 .map(line => truncateString(line, maxLineWidth)) 194 .join(options.spacing) 195} 196 197/** 198 * Convert wrapped lines into rows with padded values. 199 * 200 * @param Array items data to process 201 * @param Array columns column width settings for wrapping 202 * @param Array columnNames column ordering 203 * @return Array items wrapped in arrays, corresponding to lines 204 */ 205 206function createRows(items, columns, columnNames, paddingChr) { 207 return items.map(item => { 208 let row = [] 209 let numLines = 0 210 columnNames.forEach(columnName => { 211 numLines = Math.max(numLines, item[columnName].length) 212 }) 213 // combine matching lines of each rows 214 for (let i = 0; i < numLines; i++) { 215 row[i] = row[i] || [] 216 columnNames.forEach(columnName => { 217 let column = columns[columnName] 218 let val = item[columnName][i] || '' // || '' ensures empty columns get padded 219 if (column.align === 'right') row[i].push(padLeft(val, column.width, paddingChr)) 220 else if (column.align === 'center' || column.align === 'centre') row[i].push(padCenter(val, column.width, paddingChr)) 221 else row[i].push(padRight(val, column.width, paddingChr)) 222 }) 223 } 224 return row 225 }) 226} 227 228/** 229 * Object.assign 230 * 231 * @return Object Object with properties mixed in. 232 */ 233 234function mixin(...args) { 235 if (Object.assign) return Object.assign(...args) 236 return ObjectAssign(...args) 237} 238 239function ObjectAssign(target, firstSource) { 240 "use strict"; 241 if (target === undefined || target === null) 242 throw new TypeError("Cannot convert first argument to object"); 243 244 var to = Object(target); 245 246 var hasPendingException = false; 247 var pendingException; 248 249 for (var i = 1; i < arguments.length; i++) { 250 var nextSource = arguments[i]; 251 if (nextSource === undefined || nextSource === null) 252 continue; 253 254 var keysArray = Object.keys(Object(nextSource)); 255 for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) { 256 var nextKey = keysArray[nextIndex]; 257 try { 258 var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey); 259 if (desc !== undefined && desc.enumerable) 260 to[nextKey] = nextSource[nextKey]; 261 } catch (e) { 262 if (!hasPendingException) { 263 hasPendingException = true; 264 pendingException = e; 265 } 266 } 267 } 268 269 if (hasPendingException) 270 throw pendingException; 271 } 272 return to; 273} 274 275/** 276 * Adapted from String.prototype.endsWith polyfill. 277 */ 278 279function endsWith(target, searchString, position) { 280 position = position || target.length; 281 position = position - searchString.length; 282 let lastIndex = target.lastIndexOf(searchString); 283 return lastIndex !== -1 && lastIndex === position; 284} 285 286 287function toArray(items, columnNames) { 288 if (Array.isArray(items)) return items 289 let rows = [] 290 for (let key in items) { 291 let item = {} 292 item[columnNames[0] || 'key'] = key 293 item[columnNames[1] || 'value'] = items[key] 294 rows.push(item) 295 } 296 return rows 297} 298