@borgar/fx
Version:
Utilities for working with Excel formulas
227 lines (213 loc) • 6.37 kB
text/typescript
import { REF_RANGE, REF_BEAM, REF_TERNARY, UNKNOWN, REF_STRUCT } from './constants.ts';
import { parseA1RefXlsx } from './parseA1Ref.ts';
import { parseStructRefXlsx } from './parseStructRef.ts';
import type { ReferenceA1Xlsx, ReferenceStructXlsx, Token, TokenEnhanced } from './types.ts';
function getIDer (): () => string {
let i = 1;
return () => 'fxg' + (i++);
}
function sameValue (a: unknown, b: unknown): boolean {
if (a == null && b == null) {
return true;
}
return a === b;
}
function sameArray (a: unknown[], b: unknown[]): boolean {
if ((Array.isArray(a) !== Array.isArray(b)) || a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!sameValue(a[i], b[i])) {
return false;
}
}
return true;
}
function sameStr (a: string, b: string): boolean {
if (!a && !b) {
return true;
}
return String(a).toLowerCase() === String(b).toLowerCase();
}
function isEquivalent (refA, refB): boolean {
// if named, name must match
if ((refA.name || refB.name) && refA.name !== refB.name) {
return false;
}
// if structured
if ((refA.columns || refB.columns)) {
if (refA.table !== refB.table) {
return false;
}
if (!sameArray(refA.columns, refB.columns)) {
return false;
}
if (!sameArray(refA.sections, refB.sections)) {
return false;
}
}
// if ranged, range must have the same dimensions (we don't care about $)
if (refA.range || refB.range) {
if (
!sameValue(refA.range.top, refB.range.top) ||
!sameValue(refA.range.bottom, refB.range.bottom) ||
!sameValue(refA.range.left, refB.range.left) ||
!sameValue(refA.range.right, refB.range.right)
) {
return false;
}
}
// must have same context
if (
!sameStr(refA.workbookName, refB.workbookName) ||
!sameStr(refA.sheetName, refB.sheetName)
) {
return false;
}
return true;
}
function addContext (ref, sheetName: string, workbookName: string) {
if (!ref.sheetName) {
ref.sheetName = sheetName;
}
if (!ref.workbookName) {
ref.workbookName = workbookName;
}
return ref;
}
type RefWithGroupId = (ReferenceA1Xlsx | ReferenceStructXlsx) & { groupId: string };
class Reflist {
refs: RefWithGroupId[];
uid: () => string;
constructor (uid: () => string) {
this.refs = [];
this.uid = uid;
}
getGroupId (ref: ReferenceA1Xlsx | ReferenceStructXlsx): string {
const known = this.refs.find(d => isEquivalent(d, ref));
if (known) {
return known.groupId;
}
else {
const r: RefWithGroupId = Object.assign(ref, { groupId: this.uid() });
this.refs.push(r);
return r.groupId;
}
}
}
/**
* Runs through a list of tokens and adds extra attributes such as matching
* parens and ranges.
*
* The `context` parameter defines default reference attributes:
* `{ workbookName: 'report.xlsx', sheetName: 'Sheet1' }`.
* If supplied, these are used to match `A1` to `Sheet1!A1`.
*
* All tokens will be tagged with a `.depth` number value to indicating the
* level of nesting in parentheses as well as an `.index` number indicating
* their zero based position in the list.
*
* The returned output will be the same array of tokens but the following
* properties will added to tokens (as applicable):
*
* #### Parentheses ( )
*
* Matching parens will be tagged with `.groupId` string identifier as well as
* a `.depth` number value (indicating the level of nesting).
*
* Closing parens without a counterpart will be tagged with `.error`
* (boolean true).
*
* #### Curly brackets { }
*
* Matching curly brackets will be tagged with `.groupId` string identifier.
* These may not be nested in Excel.
*
* Closing curly brackets without a counterpart will be tagged with `.error`
* (boolean `true`).
*
* #### Ranges (`REF_RANGE` or `REF_BEAM` type tokens)
*
* All ranges will be tagged with `.groupId` string identifier regardless of
* the number of times they occur.
*
* #### Tokens of type `UNKNOWN`
*
* All will be tagged with `.error` (boolean `true`).
*
* @param tokenlist An array of tokens (from `tokenize()`)
* @param [context={}] A contest used to match `A1` to `Sheet1!A1`.
* @param [context.sheetName=''] An implied sheet name ('Sheet1')
* @param [context.workbookName=''] An implied workbook name ('report.xlsx')
* @returns The input array with the enchanced tokens
*/
export function addTokenMeta (tokenlist: Token[], { sheetName = '', workbookName = '' } = {}): TokenEnhanced[] {
const parenStack = [];
let arrayStart = null;
const uid = getIDer();
const knownRefs = new Reflist(uid);
const getCurrDepth = () => parenStack.length + (arrayStart ? 1 : 0);
tokenlist.forEach((token, i) => {
token.index = i;
token.depth = getCurrDepth();
if (token.value === '(') {
parenStack.push(token);
token.depth = getCurrDepth();
}
else if (token.value === ')') {
const counter = parenStack.pop();
if (counter) {
const pairId = uid();
token.groupId = pairId;
token.depth = counter.depth;
counter.groupId = pairId;
}
else {
token.error = true;
}
}
else if (token.value === '{') {
if (!arrayStart) {
arrayStart = token;
token.depth = getCurrDepth();
}
else {
token.error = true;
}
}
else if (token.value === '}') {
if (arrayStart) {
const pairId = uid();
token.groupId = pairId;
token.depth = arrayStart.depth;
arrayStart.groupId = pairId;
}
else {
token.error = true;
}
arrayStart = null;
}
else if (
token.type === REF_RANGE ||
token.type === REF_BEAM ||
token.type === REF_TERNARY
) {
const ref = parseA1RefXlsx(token.value, { allowTernary: true });
if (ref && 'range' in ref) {
addContext(ref, sheetName, workbookName);
token.groupId = knownRefs.getGroupId(ref);
}
}
else if (token.type === REF_STRUCT) {
const ref = parseStructRefXlsx(token.value);
if (ref) {
addContext(ref, sheetName, workbookName);
token.groupId = knownRefs.getGroupId(ref);
}
}
else if (token.type === UNKNOWN) {
token.error = true;
}
});
return tokenlist as TokenEnhanced[];
}