Source: tables/TableUtil.js

/*
 * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt
 */

import {get, set, has, isEmpty, isUndefined, uniqueId, cloneDeep, omitBy, isNil, isPlainObject, isArray, padEnd} from 'lodash';
import Enum from 'enum';

import {makeFileRequest, MAX_ROW} from './TableRequestUtil.js';
import * as TblCntlr from './TablesCntlr.js';
import {SortInfo, SORT_ASC, UNSORTED} from './SortInfo.js';
import {FilterInfo} from './FilterInfo.js';
import {SelectInfo} from './SelectInfo.js';
import {flux} from '../Firefly.js';
import {encodeServerUrl, uniqueID} from '../util/WebUtil.js';
import {fetchTable, queryTable, selectedValues} from '../rpc/SearchServicesJson.js';
import {DEF_BASE_URL} from '../core/JsonUtils.js';
import {ServerParams} from '../data/ServerParams.js';
import {doUpload} from '../ui/FileUpload.jsx';
import {dispatchAddActionWatcher, dispatchCancelActionWatcher} from '../core/MasterSaga.js';
import {getWsConnId} from '../core/messaging/WebSocketClient.js';
import {MetaConst} from '../data/MetaConst';


// this is so test can mock the function when used within it's module
const local = {
    isTableLoaded,
    getTblById,
    getTblInfoById,
    getColumn,
    getCellValue,
    getSelectedData
};
export default local;

export const COL_TYPE = new Enum(['ALL', 'NUMBER', 'TEXT']);
const char_types = ['char', 'c', 's', 'str'];
const num_types = ['double', 'd', 'long', 'l', 'int', 'i', 'float', 'f'];


/**
 * tableRequest will be sent to the server as a json string.
 * @param {TableRequest} tableRequest is a table request params object
 * @param {number} [hlRowIdx] set the highlightedRow.  default to startIdx.
 * @returns {Promise.<TableModel>}
 * @public
 * @func doFetchTable
 * @memberof firefly.util.table
 */
export function doFetchTable(tableRequest, hlRowIdx) {
    const {tbl_id} = tableRequest;
    const tableModel = getTblById(tbl_id) || {};
    if (tableModel.origTableModel) {
        return Promise.resolve(processRequest(tableModel, tableRequest, hlRowIdx));
    } else {
        return fetchTable(tableRequest, hlRowIdx);
    }
}


/**
 * return a promise of a tableModel for the given tbl_id.
 * @param {string} tbl_id the table ID to watch for.
 * @returns {Promise.<TableModel>}
 * @public
 * @func onTableLoad
 * @memberof firefly.util.table
 */
export function onTableLoaded(tbl_id) {
    if (isFullyLoaded(tbl_id)) {
        return Promise.resolve(getTblById(tbl_id));
    } else {
        return new Promise((resolve) => {
            dispatchAddActionWatcher({
                actions:[TblCntlr.TABLE_UPDATE, TblCntlr.TABLE_REPLACE],
                callback: doOnTblLoaded,
                params: {tbl_id, resolve}
            });
        });
    }
}

/**
 * returns true is there is data within the given range.  this is needed because
 * of paging table not loading the full table.
 * @param {number} startIdx
 * @param {number} endIdx
 * @param {TableModel} tableModel
 * @returns {boolean}
 * @func isTblDataAvail
 * @memberof firefly.util.table
 */
export function isTblDataAvail(startIdx, endIdx, tableModel) {
    if (!tableModel) return false;
    endIdx =  endIdx >0 ? Math.min( endIdx, tableModel.totalRows) : startIdx;
    if (startIdx >=0 && endIdx > startIdx) {
        const data = get(tableModel, 'tableData.data', []);
        const dataCount = Object.keys(data.slice(startIdx, endIdx)).length;
        return dataCount === (endIdx-startIdx);
    } else return false;
}

/**
 * returns the table model with the given tbl_id
 * @param tbl_id
 * @returns {TableModel}
 * @public
 * @func getTblById
 * @memberof firefly.util.table
 */
export function getTblById(tbl_id) {
    return get(flux.getState(),[TblCntlr.TABLE_SPACE_PATH, 'data', tbl_id]);
}

/**
 * returns all table group IDs
 * @returns {string[]}
 * @memberof firefly.util.table
 * @func getAllTableGroupIds
 */
export function getAllTableGroupIds() {
    const groups = get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'results']) || {};
    return Object.keys(groups);
}

/**
 * returns the table group information
 * @param {string} tbl_group    the group name to look for
 * @returns {TableGroup}
 * @public
 * @memberof firefly.util.table
 * @func getTableGroup
 */
export function getTableGroup(tbl_group='main') {
    return get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'results', tbl_group]);
}

/**
 * returns the table group name given a tbl_id.  it will return undefined if
 * the given tbl_id is not in a group.
 * @param {string} tbl_id    table id
 * @returns {TableGroup}
 * @public
 * @memberof firefly.util.table
 * @func findGroupByTblId
 */
export function findGroupByTblId(tbl_id) {
    const resultsRoot = get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'results'], {});
    const groupName = Object.keys(resultsRoot).find( (tbl_grp_id) => {
        return has(resultsRoot, [tbl_grp_id, 'tables', tbl_id]);
    });
    return groupName;
}

export function removeTablesFromGroup(tbl_group_id = 'main') {
    const tblAry = getTblIdsByGroup(tbl_group_id);
    tblAry && tblAry.forEach((tbl_id) => {
        TblCntlr.dispatchTableRemove(tbl_id, false);        // all table will be removed.  not need to fireActiveTableChanged
    });
}

/**
 * returns an array of tbl_id for the given tbl_group_id
 * @param {string} tbl_group_id    table group name.  defaults to 'main' if not given
 * @returns {String[]} array of tbl_id
 * @public
 * @func getTblIdsByGroup
 * @memberof firefly.util.table
 */
export function getTblIdsByGroup(tbl_group_id = 'main') {
    const tableGroup = get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'results', tbl_group_id]);
    return Object.keys(get(tableGroup, 'tables', []));
}

/**
 * returns the table information for the given id and group.
 * @param {string} tbl_id       table id.
 * @param {string} tbl_group    table group name.  defaults to 'main' if not given
 * @returns {TableModel}
 * @public
 * @func getTableInGroup
 * @memberof firefly/util/table
 */
export function getTableInGroup(tbl_id, tbl_group='main') {
    return get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'results', tbl_group, 'tables',  tbl_id]);
}

/**
 * get the table working state by tbl_ui_id
 * @param {string} tbl_ui_id     table UI id.
 * @returns {Object}
 * @memberof firefly.util.table
 * @func  getTableUiById
 */
export function getTableUiById(tbl_ui_id) {
    return get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'ui', tbl_ui_id]);
}

/**
 * returns the first table working state for the given tbl_id
 * @param {string} tbl_id
 * @returns {Object}
 * @public
 * @func getTableUiByTblId
 * @memberof firefly.util.table
 */
export function getTableUiByTblId(tbl_id) {
    const uiRoot = get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'ui'], {});
    return Object.keys(uiRoot).find( (ui_id) => get(uiRoot, [ui_id, 'tbl_id']) === tbl_id);
}

/**
 * returns the working state of the currently expanded table.
 * @returns {Object}
 * @memberof firefly.util.table
 * @func getTblExpandedInfo
 */
export function getTblExpandedInfo() {
    return get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH, 'ui', 'expanded'], {});
}

/**
 * returns true if the table referenced by the given tbl_id is fully loaded.
 * @param {string} tbl_id
 * @returns {boolean}
 * @memberof firefly.util.table
 * @func  isFullyLoaded
 */
export function isFullyLoaded(tbl_id) {
    return isTableLoaded(getTblById(tbl_id));
}

/**
 * return the resultSetID of this table.
 * This is a unique ID used to identify the resultset on the server used to populate this table.
 * @see TableRequestUtil.setResultSetID for the reverse
 * @param {string} tbl_id
 * @returns {string}
 */
export function getResultSetID(tbl_id) {
    return get(getTblById(tbl_id), 'tableMeta.resultSetID', '');
}

/**
 * return the resultSetRequest of this table.
 * This is a JSON string of the request used to created this table
 * @see TableRequestUtil.setResultSetRequest for the reverse
 * @param {string} tbl_id
 * @returns {string}
 */
export function getResultSetRequest(tbl_id) {
    return get(getTblById(tbl_id), 'tableMeta.resultSetRequest', '');
}

/**
 * Returns the first index of the found row.  It will search the table on the client first.
 * If none is found and the table is partially loaded, it will search the server-side as well.
 * @param {string} tbl_id the tbl_id of the table to search on.
 * @param {string} filterInfo filter info string used to find the first row that matches it.
 * @returns {Promise.<number>} Returns the index of the found row, else -1.
 * @func findIndex
 * @memberof firefly.util.table
 */
export function findIndex(tbl_id, filterInfo) {

    const tableModel = getTblById(tbl_id);
    if (!tableModel) return Promise.resolve(-1);
    const comparators = filterInfo.split(';').map((s) => s.trim()).map((s) => FilterInfo.createComparator(s, tableModel));
    const idx = get(tableModel, 'tableData.data', []).findIndex((row, idx) => comparators.reduce( (rval, matcher) => rval && matcher(row, idx), true));
    if (idx >= 0) {
        return Promise.resolve(idx);
    } else {
        const inclCols = 'ROW_NUM as ORG_ROWNUM';
        return queryTable(tableModel.request, {filters: filterInfo, inclCols}).then( (tableModel) => {
            return get(tableModel, ['tableData','data']) ? get(getColumnValues(tableModel, 'ORG_ROWNUM'), '0', -1) : -1;
        });
    }
}


/**
 * Returns the column index with the given name; otherwise, -1.
 * @param {TableModel} tableModel
 * @param {string} colName
 * @returns {number}
 * @public
 * @func getColumnIdx
 * @memberof firefly.util.table
 */
export function getColumnIdx(tableModel, colName) {
    const cols = getAllColumns(tableModel);
    return cols.findIndex((col) => {
        return col.name === colName;
    });
}

/**
 * Returns the column data type with the given name
 * @param {TableModel} tableModel
 * @param {string} colName
 * @returns {string}
 * @public
 */
export function getColumnType(tableModel, colName) {
    const cols = getAllColumns(tableModel);
    return get(cols.find((col)=> col.name === colName), 'type', '');
}


/**
 * returns column information for the given name.
 * @param {TableModel} tableModel
 * @param {string} colName
 * @returns {TableColumn}
 * @public
 * @func getColumn
 * @memberof firefly.util.table
 */
export function getColumn(tableModel, colName) {
    const colIdx = getColumnIdx(tableModel, colName);
    if (colIdx >= 0) {
        return get(tableModel, `tableData.columns.${colIdx}`);
    }
}

/**
 * returns column information for the given ID.
 * @param {TableModel} tableModel
 * @param {string} ID
 * @returns {TableColumn}
 * @public
 * @func getColumn
 * @memberof firefly.util.table
 */
export function getColumnByID(tableModel, ID) {
    const colIdx = getColumns(tableModel).findIndex((col) => col.ID === ID);
    if (colIdx >= 0) {
        return get(tableModel, `tableData.columns.${colIdx}`);
    }
}


export function getFilterCount(tableModel) {
    const filterInfo = get(tableModel, 'request.filters');
    const filterCount = filterInfo ? filterInfo.split(';').length : 0;
    return filterCount;
}

export function clearFilters(tableModel) {
    const {request, tbl_id} = tableModel || {};
    if (request && request.filters) {
        TblCntlr.dispatchTableFilter({tbl_id, filters: ''});
    }
}

export function clearSelection(tableModel) {
    if (tableModel) {
        const selectInfoCls = SelectInfo.newInstance({rowCount: tableModel.totalRows});
        TblCntlr.dispatchTableSelect(tableModel.tbl_id, selectInfoCls.data);
    }
}

/**
 * return the tbl_id of the active table for the given group.
 * @param {string} tbl_group group name; defaults to 'main' if not given.
 * @returns {string}
 * @public
 * @memberof firefly.util.table
 * @func getActiveTableId
 */
export function getActiveTableId(tbl_group='main') {
    return get(flux.getState(), [TblCntlr.TABLE_SPACE_PATH,'results',tbl_group,'active']);
}

/**
 *
 * @param {TableModel} tableModel
 * @param {number} rowIdx
 * @param {string} colName
 * @return {string}
 * @public
 * @func getCellValue
 * @memberof firefly.util.table
 */
export function getCellValue(tableModel, rowIdx, colName) {
    if (get(tableModel, 'tableData.data.length', 0) > 0) {
        const colIdx = getColumnIdx(tableModel, colName);
        return get(tableModel, ['tableData', 'data', rowIdx, colIdx]);
    }
}

/**
 * returns an array of all the values for a column
 * @param {TableModel} tableModel
 * @param {string} colName
 * @return {Object[]}
 * @func getColumnValues
 * @public
 * @memberof firefly.util.table
 */
export function getColumnValues(tableModel, colName) {
    const colIdx = getColumnIdx(tableModel, colName);
    if (colIdx >= 0 && colIdx < get(tableModel, 'tableData.columns.length', 0)) {
        return get(tableModel, 'tableData.data', []).map( (r) => r[colIdx]);
    } else {
        return [];
    }
}

/**
 * returns an array of all the values for a row
 * @param {TableModel} tableModel
 * @param {number} rowIdx
 * @return {Object[]}
 * @public
 * @memberof firefly.util.table
 * @func getRowValues
 */
export function getRowValues(tableModel, rowIdx) {
    return get(tableModel, ['tableData', 'data', rowIdx], []);
}

/**
 * returns an array of all the values for a columns
 * @param {string} tbl_id
 * @param {string[]} columnNames  defaults to all columns
 * @return {Promise.<TableModel>}
 * @func getSelectedData
 * @public
 * @memberof firefly.util.table
 */
export function getSelectedData(tbl_id, columnNames=[]) {
    const {tableModel, totalRows, selectInfo, request} = local.getTblInfoById(tbl_id);
    const selectedRows = [...SelectInfo.newInstance(selectInfo).getSelected()];  // get selected row idx as an array

    if (selectedRows.length === 0 || isTblDataAvail(0, totalRows -1, tableModel)) {
        return Promise.resolve(getSelectedDataSync(tbl_id, columnNames));
    } else {
        return selectedValues({columnNames, request, selectedRows});
    }
}

/**
 * Similar to getSelectedData, but will only check data available on the client.
 * It will not attempt to fetch required data.  This is good for client table.
 * @param tbl_id
 * @param columnNames
 */
export function getSelectedDataSync(tbl_id, columnNames=[]) {
    const {tableModel, tableMeta, selectInfo} = local.getTblInfoById(tbl_id);
    const selectedRows = [...SelectInfo.newInstance(selectInfo).getSelected()];  // get selected row idx as an array

    if (columnNames.length === 0) {
        columnNames = local.getColumns(tableModel).map( (c) => c.name);       // return all columns
    }
    const meta = cloneDeep(tableMeta);

    const columns = columnNames.map((cname) => local.getColumn(tableModel, cname))
        .filter((c) => c)
        .map((c) => cloneDeep(c));

    const data = selectedRows.sort()
        .map( (rIdx) => columns.map((c) => local.getCellValue(tableModel, rIdx, c.name)));

    return {tableMeta: meta, totalRows: data.length, tableData: {columns, data}};
}

/**
 * return true if the given table is fully loaded.
 * @param {TableModel} tableModel
 * @returns {boolean}
 * @memberof ffirefly.util.table
 * @func isTableLoaded
 */
export function isTableLoaded(tableModel={}) {
    const {isFetching} = tableModel;
    const status = get(tableModel, 'tableMeta.Loading-Status');
    return !isFetching && status === 'COMPLETED';
}

/**
 * This function merges the source object into the target object
 * by traversing and comparing every like path.  If a value was
 * merged at any data node in the data graph, the node and all of its
 * parent nodes will be shallow cloned and returned.  Otherwise, the target's value
 * will be returned.
 * @param {Object} target
 * @param {Object} source
 * @returns {Object}
 * @public
 * @memberof firefly.util.table
 * @func smartMerge
 */
export function smartMerge(target, source) {
    if (!target) return source;

    if (isPlainObject(source) && isPlainObject(target)) {
        const objChanges = {};
        Object.keys(source).forEach((k) => {
            const nval = smartMerge(target[k], source[k]);
            if (nval !== target[k]) {
                objChanges[k] = nval;
            }
        });
        return (isEmpty(objChanges)) ? target : Object.assign({}, target, objChanges);
    } else if (isArray(source) && isArray(target)){
        const aryChanges = [];
        source.forEach((v, idx) => {
            const nval = smartMerge(target[idx], source[idx]);
            if (nval !== target[idx]) {
                aryChanges[idx] = nval;
            }
        });
        if (isEmpty(aryChanges)) return target;
        else {
            const nAry = target.slice();
            aryChanges.forEach((v, idx) => nAry[idx] = v);
            return nAry;
        }
    } else {
        return (target === source) ? target : source;
    }
}

/**
 * sort table data in-place.
 * @param {TableData} tableData
 * @param {TableColumn[]} columns
 * @param {string} sortInfoStr
 * @returns {TableData}
 * @public
 * @memberof firefly.util.table
 * @func sortTableData
 */
export function sortTableData(tableData, columns, sortInfoStr) {
    const sortInfoCls = SortInfo.parse(sortInfoStr);
    const colName = get(sortInfoCls, 'sortColumns.0');
    const dir = get(sortInfoCls, 'direction', UNSORTED);
    if (dir === UNSORTED || get(tableData, 'length', 0) === 0) return tableData;

    const multiplier = dir === SORT_ASC ? 1 : -1;
    const colIdx = columns.findIndex( (col) => {return col.name === colName;} );
    const col = columns[colIdx];
    if (!col) return tableData;

    var comparator;
    if (!col.type || ['char', 'c'].includes(col.type) ) {
        comparator = (r1, r2) => {
            let [s1, s2] = [r1[colIdx], r2[colIdx]];
            s1 = s1 === '' ? '\u0002' : s1 === null ? '\u0001' : isUndefined(s1) ? '\u0000' : s1;
            s2 = s2 === '' ? '\u0002' : s2 === null ? '\u0001' : isUndefined(s2) ? '\u0000' : s2;
            return multiplier * (s1 > s2 ? 1 : -1);
        };
    } else {
        comparator = (r1, r2) => {
            let [v1, v2] = [r1[colIdx], r2[colIdx]];
            v1 = v1 === null ? -Number.MAX_VALUE : isUndefined(v1) ? Number.NEGATIVE_INFINITY : Number(v1);
            v2 = v2 === null ? -Number.MAX_VALUE : isUndefined(v2) ? Number.NEGATIVE_INFINITY : Number(v2);
            return multiplier * (Number(v1) - Number(v2));
        };
    }
    tableData.sort(comparator);
    return tableData;
}

/**
 * filter the given table.  This function update the table data in-place.
 * @param {TableModel} table
 * @param {string} filterInfoStr filters are separated by comma(',').
 * @memberof firefly.util.table
 * @func filterTable
 */
export function filterTable(table, filterInfoStr) {
    if (filterInfoStr) {
        const comparators = filterInfoStr.split(';').map((s) => s.trim()).map((s) => FilterInfo.createComparator(s, table));
        table.tableData.data = table.tableData.data.filter((row, idx) => {
            return comparators.reduce( (rval, match) => rval && match(row, idx), true);
        } );
        table.totalRows = table.tableData.data.length;
    }
    return table.tableData;
}


export function cloneClientTable (tableModel) {
    const nTable = cloneDeep(tableModel);
    nTable.origTableModel = tableModel;

    nTable.isFetching = false;
    set(nTable, 'tableMeta.Loading-Status', 'COMPLETED');

    if (nTable.tableData) {
        const {data=[], columns=[]} = nTable.tableData;
        // add ROW_IDX to working table for tracking original row index
        columns.push({name: 'ROW_IDX', type: 'int', visibility: 'hidden'});
        data.forEach((r, idx) => r.push(String(idx)));
    }
    return nTable;
}

export function processRequest(tableModel, tableRequest, hlRowIdx) {
    const {filters, sortInfo, inclCols} = tableRequest;
    const {origTableModel=tableModel} = tableModel;
    let {startIdx=0, pageSize} = tableRequest;

    const nTable = cloneClientTable(origTableModel);
    let {data, columns} = nTable.tableData;

    nTable.request = tableRequest;
    pageSize = pageSize || data.length || MAX_ROW;

    if (filters) {
        filterTable(nTable, filters);
    }

    data = nTable.tableData.data;

    if (sortInfo) {
        data = sortTableData(data, columns, sortInfo);
    }
    if (inclCols) {
        const colAry = inclCols.split(',').map((s) => s.trim());
        columns = columns.filters( (c) => colAry.includes(c));
        const inclIdices = columns.map( (c) => origTableModel.tableData.indexOf(c));
        data = data.map( (r) =>  r.filters( (c, idx) => inclIdices.includes(idx)));
    }

    const prevHlRowIdx = get(tableRequest, ['META_OPTIONS', MetaConst.HIGHLIGHTED_ROW_BY_ROWIDX], 0);
    if (hlRowIdx) {
        const currentPage = Math.floor(hlRowIdx / pageSize) + 1;
        startIdx = (currentPage-1) * pageSize;
        nTable.highlightedRow = hlRowIdx;
    } else if (prevHlRowIdx) {
        // preserve previous highlightedRow if possible.
        let relRowIdx = data.findIndex( (r, rIdx) => getCellValue(nTable, rIdx, 'ROW_IDX') === prevHlRowIdx );
        relRowIdx = relRowIdx < 0 ? 0 : relRowIdx;
        const currentPage = Math.floor(relRowIdx / pageSize) + 1;
        startIdx = (currentPage-1) * pageSize;
        nTable.highlightedRow = relRowIdx;
    } else {
        nTable.highlightedRow = startIdx;
    }

    data = data.slice(startIdx, startIdx + pageSize);

    nTable.tableData.data = data;
    nTable.tableData.columns = columns;

    // set selections from the original table
    const idxCol = getColumnIdx(nTable, 'ROW_IDX');
    if (idxCol >= 0) {
        const nTableSelectInfoCls = SelectInfo.newInstance({rowCount: data.length});
        const origSelectInfoCls = SelectInfo.newInstance(get(origTableModel, 'selectInfo'));
        data.forEach((row, i) => {
            const origIdx = parseInt(row[idxCol]);
            // set selection
            nTableSelectInfoCls.setRowSelect(i, origSelectInfoCls.isSelected(origIdx));
        });
        nTable.selectInfo = nTableSelectInfoCls.data;
    }

    return nTable;
}

/**
 * collects all available table information given the tbl_id
 * @param {string} tbl_id
 * @param {number} aPageSize  use this pageSize instead of the one in the request.
 * @returns {{tableModel, tbl_id, title, totalRows, request, startIdx, endIdx, hlRowIdx, currentPage, pageSize, totalPages, highlightedRow, selectInfo, error, tableMeta, bgStatus}}
 * @public
 * @memberof firefly.util.table
 * @func getTblInfoById
 */
export function getTblInfoById(tbl_id, aPageSize) {
    const tableModel = getTblById(tbl_id);
    return getTblInfo(tableModel, aPageSize);
}

/**
 * collects all available table information given the tableModel.
 * @param {TableModel} tableModel
 * @param {number} aPageSize  use this pageSize instead of the one in the request.
 * @returns {{tableModel, tbl_id, title, totalRows, request, startIdx, endIdx, hlRowIdx, currentPage, pageSize, totalPages, highlightedRow, selectInfo, error, tableMeta, backgroundable}}
 * @public
 * @memberof firefly.util.table
 * @func getTblInfo
 */
export function getTblInfo(tableModel, aPageSize) {
    if (!tableModel) return {};
    var {tbl_id, request, highlightedRow=0, totalRows=0, tableMeta={}, selectInfo, error} = tableModel;
    const title = tableMeta.title || get(request, 'META_INFO.title');
    const pageSize = aPageSize || get(request, 'pageSize', MAX_ROW);  // there should be a pageSize.. default to 1 in case of error.  pageSize cannot be 0 because it'll overflow.
    if (highlightedRow < 0 ) {
        highlightedRow = 0;
    } else  if (highlightedRow >= totalRows-1) {
        highlightedRow = totalRows-1;
    }
    const currentPage = highlightedRow >= 0 ? Math.floor(highlightedRow / pageSize)+1 : 1;
    const hlRowIdx = highlightedRow >= 0 ? highlightedRow % pageSize : 0;
    const startIdx = (currentPage-1) * pageSize;
    const endIdx = Math.min(startIdx+pageSize, totalRows) || get(tableModel,'tableData.data.length', startIdx) ;
    const totalPages = Math.ceil((totalRows || 0)/pageSize);
    const backgroundable = get(tableModel, 'request.META_INFO.backgroundable', false);

    return { tableModel, tbl_id, title, totalRows, request, startIdx, endIdx, hlRowIdx, currentPage, pageSize,totalPages, highlightedRow, selectInfo, error, tableMeta, backgroundable};
}


/**
 * Return the row data as an object keyed by the column name
 * @param {TableModel} tableModel
 * @param {Number} [rowIdx] = the index of the row to return, default to highlighted row
 * @return {Object<String,String>} the values of the row keyed by the column name
 * @public
 * @memberof firefly.util.table
 * @func getTblRowAsObj
 */
export function getTblRowAsObj(tableModel, rowIdx= undefined) {
    if (!tableModel) return {};
    const {highlightedRow, tableData} = tableModel;
    if (!tableData) return {};
    const {data, columns}= tableData;
    if (isUndefined(rowIdx)) rowIdx= highlightedRow;
    if (rowIdx<0 || rowIdx>= get(tableData, 'data.length',0)) return {};
    const row= data[rowIdx];
    if (!row) return {};
    return row.reduce( (obj,v, idx)  => {
           obj[columns[idx].name]= v;
           return obj;
          }, {});
}

/**
 * returns the url to download a snapshot of the current table data.
 * @param {string} tbl_ui_id  UI id of the table
 * @param {object} params supplement parameter setting such as the information for workspace
 * @returns {string}
 * @public
 * @memberof firefly.util.table
 * @func getTableSourceUrl
 */
export function getTableSourceUrl(tbl_ui_id, params) {
    const {columns, request} = getTableUiById(tbl_ui_id) || {};
    return makeTableSourceUrl(columns, request, params);
}

/**
 * Async version of getTableSourceUrl.  If the given tbl_ui_id is backed by a local TableModel,
 * then we need to push/upload the content of the server before it can be referenced via url.
 * @param {string} tbl_ui_id  UI id of the table
 * @param {object} params supplement parameter setting such as the information for workspace
 * @returns {Promise.<string, Error>}
 */
export function getAsyncTableSourceUrl(tbl_ui_id, params) {
    const {tbl_id, columns} = getTableUiById(tbl_ui_id) || {};
    const ipacTable = tableToIpac(getTblById(tbl_id));
    const blob = new Blob([ipacTable]);
    //const file = new File([new Blob([ipacTable])], filename);
    return doUpload(blob).then( ({status, cacheKey}) => {
        const request = makeFileRequest('save as text', cacheKey, null, {pageSize: MAX_ROW});
        return makeTableSourceUrl(columns, request, params);
    });
}

function makeTableSourceUrl(columns, request, otherParams) {
    const tableRequest = Object.assign(cloneDeep(request), {startIdx: 0,pageSize : MAX_ROW});
    const visiCols = columns.filter( (col) => get(col, 'visibility', 'show') === 'show')
                            .map( (col) => col.name);
    if (visiCols.length !== columns.length) {
        tableRequest['inclCols'] = visiCols.map( (c) => c.includes('"') ? c : '"' + c + '"').join();  // add quotes to cname unless it's already quoted.
    }
    Reflect.deleteProperty(tableRequest, 'tbl_id');
    const {wsCmd, file_name} = otherParams || {};

    const params = omitBy({
        [ServerParams.COMMAND]: (!wsCmd ? ServerParams.TABLE_SAVE : ServerParams.WS_PUT_TABLE_FILE),
        [ServerParams.REQUEST]: JSON.stringify(tableRequest),
        file_name: (!wsCmd) && (file_name || get(tableRequest, 'META_INFO.title'))
    }, isNil);

    if (otherParams) {
        Object.assign(params, omitBy(otherParams, isNil));
    }
    return wsCmd ? params : encodeServerUrl(DEF_BASE_URL, params);
}

export function setHlRowByRowIdx(nreq, tableModel) {
    const hlRowIdx = getCellValue(tableModel, tableModel.highlightedRow, 'ROW_IDX');
    if (hlRowIdx) {
        set(nreq, ['META_OPTIONS', MetaConst.HIGHLIGHTED_ROW_BY_ROWIDX], hlRowIdx);
    }
}

/**
 * convert this table into IPAC format
 * @param tableModel
 * @returns {string}
 */
export function tableToIpac(tableModel) {
    const {tableData, tableMeta} = tableModel;
    const {columns, data} = tableData || {};

    const colWidths = calcColumnWidths(columns, data);

    const meta = Object.entries(tableMeta).map(([k,v]) => `\\${k} = ${v}`)
                    .concat(columns.filter( (c) => c.visibility === 'hidden').map( (c) => `\\col.${c.name}.Visibility = ${c.visibility}`))
                    .concat(columns.filter( (c) => c.filterable).map( (c) => `\\col.${c.name}.Filterable = ${c.filterable}`))
                    .concat(columns.filter( (c) => c.sortable).map( (c) => `\\col.${c.name}.Sortable = ${c.sortable}`))
                    .concat(columns.filter( (c) => c.label).map( (c) => `\\col.${c.name}.Label = ${c.label}`))
                    .concat(columns.filter( (c) => c.desc).map( (c) => `\\col.${c.name}.ShortDescription = ${c.desc}`))
                    .join('\n');

    const head = [
                    columns.map((c, idx) => padEnd(c.name, colWidths[idx])),
                    columns.map((c, idx) => padEnd(c.type, colWidths[idx])),
                    columns.find((c) => c.units) && columns.map((c, idx) => padEnd(c.units, colWidths[idx])),
                    columns.find((c) => c.nullString) && columns.map((c, idx) => padEnd(c.nullString, colWidths[idx]))
                ]
                .filter( (ary) => ary)
                .map( (ary) => '|' + ary.join('|') + '|')
                .join('\n');
    const dataStr = data.map( (row) => ' ' + row.map( (c, idx) => padEnd(c, colWidths[idx])).join(' ') + ' ' )
                    .join('\n');

    return [meta, '\\', head, dataStr].join('\n');
}

export function tableTextView(columns, dataAry, showUnits=false, tableMeta) {

    const colWidths = calcColumnWidths(columns, dataAry);
    const meta = tableMeta && Object.entries(tableMeta).map(([k,v]) => `\\${k} = ${v}`).join('\n');
    const head = [
                    columns.map((c, idx) => get(c,'visibility', 'show') === 'show' && padEnd(c.label || c.name, colWidths[idx])),
                    columns.map((c, idx) => get(c,'visibility', 'show') === 'show' && padEnd(c.type, colWidths[idx])),
                    showUnits && columns.map((c, idx) => get(c,'visibility', 'show') === 'show' && padEnd(c.units, colWidths[idx]))
                ]
                .filter( (ary) => ary)
                .map( (ary) => '|' + ary.filter( (v) => v ).join('|') + '|')
                .join('\n');

    const dataStr = dataAry.map((row) => ' ' +
                                    row.map((c, idx) => get(columns, `${idx}.visibility`, 'show') === 'show' && padEnd(c, colWidths[idx]))
                                       .filter((v) => v)
                                       .join(' ')
                                    + ' ')
                           .join('\n');

    return [meta, head, dataStr].join('\n');
}

/**
 * returns a details view of the highlightedRow in a form of a tableModel
 * with columns Name, Value, Type, Units, and Description
 * @param {string} tbl_id             tbl_id of the table
 * @param {number} highlightedRow     the row to generate the details for
 * @param {string} details_tbl_id     tbl_id of the details table.  defaults to tbl_id + '_details'
 */
export function tableDetailsView(tbl_id, highlightedRow, details_tbl_id) {
    const tableModel = getTblById(tbl_id);
    const dataCols = getColumns(tableModel);
    const {totalRows} = tableModel || {};
    const nTblId = details_tbl_id || tbl_id + '_details';

    if (totalRows <= 0 || highlightedRow < 0 || highlightedRow > tableModel.totalRows) {
        return {tbl_id: nTblId, error: 'No Data Found'};
    }

    const columns = [
        {name: 'Name', type: 'char', desc: 'Column name'},
        {name: 'Value', type: 'char'},
        {name: 'Type', type: 'char'},
        {name: 'Units', type: 'char'},
        {name: 'Description', type: 'char'}
    ];

    const data = dataCols.map((c) => {
        const name  = c.label || c.name;
        const value = getCellValue(tableModel, highlightedRow, c.name) || '';
        const type  = c.type || '';
        const units = c.units || '';
        const desc  = c.desc || '';

        return [name, value, type, units, desc];
    });

    const prevDetails = getTblById(nTblId) || {};
    const {request={}} = prevDetails;
    let nTable = {
        tbl_id: nTblId,
        request,
        title: 'Additional Information',
        tableData: {columns, data},
        totalRows: data.length,
        highlightedRow: prevDetails.highlightedRow
    };
    if (request.sortInfo || request.filters) {
        setHlRowByRowIdx(request, prevDetails);
        nTable = processRequest(nTable, request);
    }
    return nTable;
}

/**
 * returns an object map of the column name and its width.
 * The width is the number of characters needed to display
 * the header and the data in a table given columns and dataAry.
 * @param {TableColumn[]} columns  array of column object
 * @param {TableData} dataAry  array of array.
 * @returns {number[]} an array of widths corresponding to the given columns array.
 * @memberof firefly.util.table
 * @func calcColumnWidths
 */
export function calcColumnWidths(columns, dataAry) {
    return columns.map( (cv, idx) => {

        let width = cv.prefWidth || cv.width;
        if (width) {
            return width;
        }
        const cname = cv.label || cv.name;
        width = Math.max(cname.length, get(cv, 'units.length', 0),  get(cv, 'type.length', 0));
        width = dataAry.reduce( (maxWidth, row) => {
            return Math.max(maxWidth, get(row, [idx, 'length'], 0));
        }, width);  // max width of data
        return width;
    });
}

/**
 * There are some inconsistencies in how a request is created.
 * This fixes any of the inconsistencies it finds.
 * @param request
 */
export function fixRequest(request) {
    // ensure tbl_id exists and it's set correctly.
    const tbl_id = request.tbl_id || get(request, 'META_INFO.tbl_id', uniqueTblId());
    request.tbl_id = tbl_id;
    set(request, 'META_INFO.tbl_id', tbl_id);
}

/**
 * create a unique table id (tbl_id)
 * @returns {string}
 * @public
 * @memberof firefly.util.table
 * @func uniqueTblId
 */
export function uniqueTblId() {
    const uid = getWsConnId();
    const id = uniqueId(`tbl_id-c${uid}-`);
    if (getTblById(id)) {
        return uniqueTblId();
    } else {
        return id;
    }
}

/**
 * create a unique table UI id (tbl_ui_id)
 * @returns {string}
 * @public
 * @memberof firefly.util.table
 * @func uniqueTblUiId
 */
export function uniqueTblUiId() {
    const uid = getWsConnId();
    const id = uniqueId(`tbl_ui_id-c${uid}-`);
    if (getTableUiById(id)) {
        return uniqueTblUiId();
    } else {
        return id;
    }
}
/**
 *  This function provides a patch until we can reliably determine that the ra/dec columns use radians or degrees.
 * @param tableOrMeta the table object or the tableMeta object
 * @memberof firefly.util.table
 * @func isTableUsingRadians
 *
 */
export function isTableUsingRadians(tableOrMeta) {
    if (!tableOrMeta) return false;
    const tableMeta= tableOrMeta.tableMeta || tableOrMeta;
    return has(tableMeta, 'HIERARCH.AFW_TABLE_VERSION');
}

export function createErrorTbl(tbl_id, error) {
    return set({tbl_id, error: error||'something went wrong'}, 'tableMeta.Loading-Status', 'COMPLETED');
}



/**
 * this function invoke the given callback when changes are made to the given tbl_id
 * @param {string}   tbl_id  table id to watch
 * @param {Object}   actions  an array of table actions to watch
 * @param {function} callback  callback to execute when table is loaded.
 * @param {string} [watcherId] action watcher id to be used
 * @return {function} returns a function used to cancel
 */
export function watchTableChanges(tbl_id, actions, callback, watcherId) {
    const accept = (a) => tbl_id === (get(a, 'payload.tbl_id') || get(a, 'payload.request.tbl_id'));
    return monitorChanges(actions, accept, callback, watcherId);
}

/**
 * this function invoke the given callback when the given actions occur.
 * @param {Object}   actions  an array of actions to watch
 * @param {function} accept  a function used to filter incoming actions.  if not given, it will accept all.
 * @param {function} callback  callback to execute when action occurs.
 * @param {string} [watcherId] action watcher id to be used
 * @return {function} returns a function used to cancel
 */
export function monitorChanges(actions, accept, callback, watcherId) {
    if (!Array.isArray(actions) || actions.length === 0 || !callback) return;

    const id = watcherId || uniqueID();
    const mCallback = (action) => {
        if (accept(action)) {
            callback(action);
        }
    };
    dispatchAddActionWatcher({id, actions, callback:mCallback});
    return () => dispatchCancelActionWatcher(id);
}


/**
 * @summary returns the non-hidden columns of the given table.  If type is given, it
 * will only return columns that match type.
 * @param {TableModel} tableModel
 * @param {COL_TYPE} type  one of predefined COL_TYPE.  defaults to 'ALL'.
 * @returns {Array<TableColumn>}
 * @public
 * @memberof firefly.util.table
 * @func getColumns
 */
export function getColumns(tableModel, type=COL_TYPE.ALL) {
    return getColsByType(getAllColumns(tableModel), type);
}

/**
 * @summary returns all columns of the given table including hidden ones.
 * @param {TableModel} tableModel
 * @returns {Array<TableColumn>}
 * @public
 * @memberof firefly.util.table
 * @func getAllColumns
 */
export function getAllColumns(tableModel) {
    return get(tableModel, 'tableData.columns', []);
}

/**
 * @summary returns only the non-hidden columns matching the given type.
 * @param {Array<TableColumn>} tblColumns
 * @param {COL_TYPE} type  one of predefined COL_TYPE.  defaults to 'ALL'.
 * @returns {Array<TableColumn>}
 */
export function getColsByType(tblColumns=[], type=COL_TYPE.ALL) {
    const matcher = type === COL_TYPE.TEXT ? isTextType : isNumericType;
    return tblColumns.filter((col) => get(col, 'visibility') !== 'hidden'
                        && (type === COL_TYPE.ALL || matcher(col)));
}

export function getColByUtype(tableModel, utype) {
    return getColumns(tableModel).filter( (col) => col.utype === utype);
}

export function getColByUCD(tableModel, ucd) {
    return getColumns(tableModel).filter( (col) => col.UCD === ucd);
}

/**
 * returns a 2-part array, [release date column, datarights column] if this table
 * contains proprietary data
 * @param tableModel
 * @returns {string[]}
 */
export function getProprietaryInfo(tableModel) {
    const rcname = get(tableModel, ['tableMeta', MetaConst.RELEASE_DATE_COL]);
    const dcname = get(tableModel, ['tableMeta', MetaConst.DATARIGHTS_COL]);
    return rcname || dcname ? [rcname, dcname] : [];
}

export function hasRowAccess(tableModel, rowIdx) {

    const [rcname, dcname] = getProprietaryInfo(tableModel);
    if (!rcname && !dcname) return true;        // no proprietary info

    if (dcname) {
        const rights = getCellValue(tableModel, rowIdx, dcname) || '';
        if (['public', 'secure', '1', 'true', 't'].includes(rights.trim().toLocaleLowerCase()) ) {
            return true;
        }
    }
    if (rcname) {
        const rdate = getCellValue(tableModel, rowIdx, rcname) || '';
        if ( rdate && new Date() > new Date(rdate)) {
            return true;
        }
    }
    return false;
}


export function isNumericType(col={}) {
    return num_types.includes(col.type);
}

export function isTextType(col={}) {
    return char_types.includes(col.type);
}

/**
 * @param {string} tbl_id
 * @returns {string} returns a key used by table to store backgrounding information.  This is used by BgMaskPanel.
 */
export function makeBgKey(tbl_id) {
    return `tables:${tbl_id}`;
}

/**
 * If input is '"x"', outputs 'x', but if input is '"x"+"y"' or 'log("x")' output is the same as input
 * @param s
 * @returns {*}
 */
export function stripColumnNameQuotes(s) {
    const newS = s.replace(/^"(.+)"$/, '$1');
    return newS.includes('"') ? s : newS;
}

export function tblDropDownId(tbl_id) {
    return `table_dropDown-${tbl_id}`;
}


/*-------------------------------------private------------------------------------------------*/

/**
 * Action watcher callback for table update, which is invoked when
 * the table given by tbl_id is fully loaded.
 * @callback actionWatcherCallback
 * @param action  action that triggered this watcher
 * @param cancelSelf  function to cancel this watcher
 * @param params  parameters object
 * @param {string}   params.tbl_id  table id to watch
 * @param {function} params.resolve  callback to execute when table is loaded.
 */
function doOnTblLoaded(action, cancelSelf, {tbl_id, resolve}) {
    if (!resolve) cancelSelf();

    if (tbl_id === get(action, 'payload.tbl_id')) {
        const tableModel = getTblById(tbl_id);
        if (get(tableModel, 'error')) {
            // there was an error loading this table.
            resolve(createErrorTbl(tbl_id, tableModel.error));
            cancelSelf();
        } else if (isTableLoaded(tableModel) &&  get(tableModel, 'tableData.columns.length')) {
            resolve(getTblInfoById(tbl_id));
            cancelSelf();
        }
    }
}