UNPKG

@entestat/formula

Version:

fast excel formula parser

600 lines (536 loc) 20.3 kB
const FormulaError = require('../error'); const {FormulaHelpers, Types} = require('../helpers'); const H = FormulaHelpers; const MS_PER_DAY = 1000 * 60 * 60 * 24; const d1900 = new Date(Date.UTC(1900, 0, 1)); const WEEK_STARTS = [ undefined, 0, 1, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 1, 2, 3, 4, 5, 6, 0]; const WEEK_TYPES = [ undefined, [1, 2, 3, 4, 5, 6, 7], [7, 1, 2, 3, 4, 5, 6], [6, 0, 1, 2, 3, 4, 5], undefined, undefined, undefined, undefined, undefined, undefined, undefined, [7, 1, 2, 3, 4, 5, 6], [6, 7, 1, 2, 3, 4, 5], [5, 6, 7, 1, 2, 3, 4], [4, 5, 6, 7, 1, 2, 3], [3, 4, 5, 6, 7, 1, 2], [2, 3, 4, 5, 6, 7, 1], [1, 2, 3, 4, 5, 6, 7] ]; const WEEKEND_TYPES = [ undefined, [6, 0], [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], undefined, undefined, undefined, [0], [1], [2], [3], [4], [5], [6] ]; // Formats: h:mm:ss A, h:mm A, H:mm, H:mm:ss, H A const timeRegex = /^\s*(\d\d?)\s*(:\s*\d\d?)?\s*(:\s*\d\d?)?\s*(pm|am)?\s*$/i; // 12-3, 12/3 const dateRegex1 = /^\s*((\d\d?)\s*([-\/])\s*(\d\d?))([\d:.apm\s]*)$/i; // 3-Dec, 3/Dec const dateRegex2 = /^\s*((\d\d?)\s*([-/])\s*(jan\w*|feb\w*|mar\w*|apr\w*|may\w*|jun\w*|jul\w*|aug\w*|sep\w*|oct\w*|nov\w*|dec\w*))([\d:.apm\s]*)$/i; // Dec-3, Dec/3 const dateRegex3 = /^\s*((jan\w*|feb\w*|mar\w*|apr\w*|may\w*|jun\w*|jul\w*|aug\w*|sep\w*|oct\w*|nov\w*|dec\w*)\s*([-/])\s*(\d\d?))([\d:.apm\s]*)$/i; function parseSimplifiedDate(text) { const fmt1 = text.match(dateRegex1); const fmt2 = text.match(dateRegex2); const fmt3 = text.match(dateRegex3); if (fmt1) { text = fmt1[1] + fmt1[3] + new Date().getFullYear() + fmt1[5]; } else if (fmt2) { text = fmt2[1] + fmt2[3] + new Date().getFullYear() + fmt2[5]; } else if (fmt3) { text = fmt3[1] + fmt3[3] + new Date().getFullYear() + fmt3[5]; } return new Date(Date.parse(`${text} UTC`)); } /** * Parse time string to date in UTC. * @param {string} text */ function parseTime(text) { const res = text.match(timeRegex); if (!res) return; //  ["4:50:55 pm", "4", ":50", ":55", "pm", ...] const minutes = res[2] ? res[2] : ':00'; const seconds = res[3] ? res[3] : ':00'; const ampm = res[4] ? ' ' + res[4] : ''; const date = new Date(Date.parse(`1/1/1900 ${res[1] + minutes + seconds + ampm} UTC`)); let now = new Date(); now = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds())); return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds())); } /** * Parse a UTC date to excel serial number. * @param {Date|number} date - A UTC date. * @returns {number} */ function toSerial(date) { const addOn = (date > -2203891200000) ? 2 : 1; return Math.floor((date - d1900) / 86400000) + addOn; } /** * Parse an excel serial number to UTC date. * @param serial * @returns {Date} */ function toDate(serial) { if (serial < 0) { throw FormulaError.VALUE; } if (serial <= 60) { return new Date(d1900.getTime() + (serial - 1) * 86400000); } return new Date(d1900.getTime() + (serial - 2) * 86400000); } function parseDateWithExtra(serialOrString) { if (serialOrString instanceof Date) return {date: serialOrString}; serialOrString = H.accept(serialOrString); let isDateGiven = true, date; if (!isNaN(serialOrString)) { serialOrString = Number(serialOrString); date = toDate(serialOrString); } else { // support time without date date = parseTime(serialOrString); if (!date) { date = parseSimplifiedDate(serialOrString); } else { isDateGiven = false; } } return {date, isDateGiven}; } function parseDate(serialOrString) { return parseDateWithExtra(serialOrString).date; } function compareDateIgnoreTime(date1, date2) { return date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getUTCMonth() === date2.getUTCMonth() && date1.getUTCDate() === date2.getUTCDate(); } function isLeapYear(year) { if (year === 1900) { return true; } return new Date(year, 1, 29).getMonth() === 1; } const DateFunctions = { DATE: (year, month, day) => { year = H.accept(year, Types.NUMBER); month = H.accept(month, Types.NUMBER); day = H.accept(day, Types.NUMBER); if (year < 0 || year >= 10000) throw FormulaError.NUM; // If year is between 0 (zero) and 1899 (inclusive), Excel adds that value to 1900 to calculate the year. if (year < 1900) { year += 1900; } return toSerial(Date.UTC(year, month - 1, day)); }, DATEDIF: (startDate, endDate, unit) => { startDate = parseDate(startDate); endDate = parseDate(endDate); unit = H.accept(unit, Types.STRING).toLowerCase(); if (startDate > endDate) throw FormulaError.NUM; const yearDiff = endDate.getUTCFullYear() - startDate.getUTCFullYear(); const monthDiff = endDate.getUTCMonth() - startDate.getUTCMonth(); const dayDiff = endDate.getUTCDate() - startDate.getUTCDate(); let offset; switch (unit) { case 'y': offset = monthDiff < 0 || monthDiff === 0 && dayDiff < 0 ? -1 : 0; return offset + yearDiff; case 'm': offset = dayDiff < 0 ? -1 : 0; return yearDiff * 12 + monthDiff + offset; case 'd': return Math.floor(endDate - startDate) / MS_PER_DAY; case 'md': // The months and years of the dates are ignored. startDate.setUTCFullYear(endDate.getUTCFullYear()); if (dayDiff < 0) { startDate.setUTCMonth(endDate.getUTCMonth() - 1) } else { startDate.setUTCMonth(endDate.getUTCMonth()) } return Math.floor(endDate - startDate) / MS_PER_DAY; case 'ym': // The days and years of the dates are ignored offset = dayDiff < 0 ? -1 : 0; return (offset + yearDiff * 12 + monthDiff) % 12; case 'yd': // The years of the dates are ignored. if (monthDiff < 0 || monthDiff === 0 && dayDiff < 0) { startDate.setUTCFullYear(endDate.getUTCFullYear() - 1); } else { startDate.setUTCFullYear(endDate.getUTCFullYear()); } return Math.floor(endDate - startDate) / MS_PER_DAY; } }, /** * Limitation: Year must be four digit, only support ISO 8016 date format. * Does not support date without year, i.e. "5-JUL". * @param {string} dateText */ DATEVALUE: (dateText) => { dateText = H.accept(dateText, Types.STRING); const {date, isDateGiven} = parseDateWithExtra(dateText); if (!isDateGiven) return 0; const serial = toSerial(date); if (serial < 0 || serial > 2958465) throw FormulaError.VALUE; return serial; }, DAY: serialOrString => { const date = parseDate(serialOrString); return date.getUTCDate(); }, DAYS: (endDate, startDate) => { endDate = parseDate(endDate); startDate = parseDate(startDate); let offset = 0; if (startDate < -2203891200000 && -2203891200000 < endDate) { offset = 1; } return Math.floor(endDate - startDate) / MS_PER_DAY + offset; }, DAYS360: (startDate, endDate, method) => { startDate = parseDate(startDate); endDate = parseDate(endDate); // default is US method method = H.accept(method, Types.BOOLEAN, false); if (startDate.getUTCDate() === 31) { startDate.setUTCDate(30); } if (!method && startDate.getUTCDate() < 30 && endDate.getUTCDate() > 30) { endDate.setUTCMonth(endDate.getUTCMonth() + 1, 1); } else { // European method if (endDate.getUTCDate() === 31) { endDate.setUTCDate(30); } } const yearDiff = endDate.getUTCFullYear() - startDate.getUTCFullYear(); const monthDiff = endDate.getUTCMonth() - startDate.getUTCMonth(); const dayDiff = endDate.getUTCDate() - startDate.getUTCDate(); return (monthDiff) * 30 + dayDiff + yearDiff * 12 * 30; }, EDATE: (startDate, months) => { startDate = parseDate(startDate); months = H.accept(months, Types.NUMBER); startDate.setUTCMonth(startDate.getUTCMonth() + months); return toSerial(startDate); }, EOMONTH: (startDate, months) => { startDate = parseDate(startDate); months = H.accept(months, Types.NUMBER); startDate.setUTCMonth(startDate.getUTCMonth() + months + 1, 0); return toSerial(startDate); }, HOUR: serialOrString => { const date = parseDate(serialOrString); return date.getUTCHours(); }, ISOWEEKNUM: (serialOrString) => { const date = parseDate(serialOrString); // https://stackoverflow.com/questions/6117814/get-week-of-year-in-javascript-like-in-php const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay(); d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7) }, MINUTE: serialOrString => { const date = parseDate(serialOrString); return date.getUTCMinutes(); }, MONTH: serialOrString => { const date = parseDate(serialOrString); return date.getUTCMonth() + 1; }, NETWORKDAYS: (startDate, endDate, holidays) => { startDate = parseDate(startDate); endDate = parseDate(endDate); let sign = 1; if (startDate > endDate) { sign = -1; const temp = startDate; startDate = endDate; endDate = temp; } const holidaysArr = []; if (holidays != null) { H.flattenParams([holidays], Types.NUMBER, false, item => { holidaysArr.push(parseDate(item)); }); } let numWorkDays = 0; while (startDate <= endDate) { // Skips Sunday and Saturday if (startDate.getUTCDay() !== 0 && startDate.getUTCDay() !== 6) { let found = false; for (let i = 0; i < holidaysArr.length; i++) { if (compareDateIgnoreTime(startDate, holidaysArr[i])) { found = true; break; } } if (!found) numWorkDays++; } startDate.setUTCDate(startDate.getUTCDate() + 1); } return sign * numWorkDays; }, 'NETWORKDAYS.INTL': (startDate, endDate, weekend, holidays) => { startDate = parseDate(startDate); endDate = parseDate(endDate); let sign = 1; if (startDate > endDate) { sign = -1; const temp = startDate; startDate = endDate; endDate = temp; } weekend = H.accept(weekend, null, 1); // Using 1111111 will always return 0. if (weekend === '1111111') return 0; // using weekend string, i.e, 0000011 if (typeof weekend === "string" && Number(weekend).toString() !== weekend) { if (weekend.length !== 7) throw FormulaError.VALUE; weekend = weekend.charAt(6) + weekend.slice(0, 6); const weekendArr = []; for (let i = 0; i < weekend.length; i++) { if (weekend.charAt(i) === '1') weekendArr.push(i); } weekend = weekendArr; } else { // using weekend number if (typeof weekend !== "number") throw FormulaError.VALUE; weekend = WEEKEND_TYPES[weekend]; } const holidaysArr = []; if (holidays != null) { H.flattenParams([holidays], Types.NUMBER, false, item => { holidaysArr.push(parseDate(item)); }); } let numWorkDays = 0; while (startDate <= endDate) { let skip = false; for (let i = 0; i < weekend.length; i++) { if (weekend[i] === startDate.getUTCDay()) { skip = true; break; } } if (!skip) { let found = false; for (let i = 0; i < holidaysArr.length; i++) { if (compareDateIgnoreTime(startDate, holidaysArr[i])) { found = true; break; } } if (!found) numWorkDays++; } startDate.setUTCDate(startDate.getUTCDate() + 1); } return sign * numWorkDays; }, NOW: () => { const now = new Date(); return toSerial(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds())) + (3600 * now.getHours() + 60 * now.getMinutes() + now.getSeconds()) / 86400; }, SECOND: (serialOrString) => { const date = parseDate(serialOrString); return date.getUTCSeconds(); }, TIME: (hour, minute, second) => { hour = H.accept(hour, Types.NUMBER); minute = H.accept(minute, Types.NUMBER); second = H.accept(second, Types.NUMBER); if (hour < 0 || hour > 32767 || minute < 0 || minute > 32767 || second < 0 || second > 32767) throw FormulaError.NUM; return (3600 * hour + 60 * minute + second) / 86400; }, TIMEVALUE: (timeText) => { timeText = parseDate(timeText); return (3600 * timeText.getUTCHours() + 60 * timeText.getUTCMinutes() + timeText.getUTCSeconds()) / 86400; }, TODAY: () => { const now = new Date(); return toSerial(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())); }, WEEKDAY: (serialOrString, returnType) => { const date = parseDate(serialOrString); returnType = H.accept(returnType, Types.NUMBER, 1); const day = date.getUTCDay(); const weekTypes = WEEK_TYPES[returnType]; if (!weekTypes) throw FormulaError.NUM; return weekTypes[day]; }, WEEKNUM: (serialOrString, returnType) => { const date = parseDate(serialOrString); returnType = H.accept(returnType, Types.NUMBER, 1); if (returnType === 21) { return DateFunctions.ISOWEEKNUM(serialOrString); } const weekStart = WEEK_STARTS[returnType]; const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); const offset = yearStart.getUTCDay() < weekStart ? 1 : 0; return Math.ceil((((date - yearStart) / 86400000) + 1) / 7) + offset; }, WORKDAY: (startDate, days, holidays) => { return DateFunctions["WORKDAY.INTL"](startDate, days, 1, holidays); }, 'WORKDAY.INTL': (startDate, days, weekend, holidays) => { startDate = parseDate(startDate); days = H.accept(days, Types.NUMBER); weekend = H.accept(weekend, null, 1); // Using 1111111 will always return value error. if (weekend === '1111111') throw FormulaError.VALUE; // using weekend string, i.e, 0000011 if (typeof weekend === "string" && Number(weekend).toString() !== weekend) { if (weekend.length !== 7) throw FormulaError.VALUE; weekend = weekend.charAt(6) + weekend.slice(0, 6); const weekendArr = []; for (let i = 0; i < weekend.length; i++) { if (weekend.charAt(i) === '1') weekendArr.push(i); } weekend = weekendArr; } else { // using weekend number if (typeof weekend !== "number") throw FormulaError.VALUE; weekend = WEEKEND_TYPES[weekend]; if (weekend == null) throw FormulaError.NUM; } const holidaysArr = []; if (holidays != null) { H.flattenParams([holidays], Types.NUMBER, false, item => { holidaysArr.push(parseDate(item)); }); } startDate.setUTCDate(startDate.getUTCDate() + 1); let cnt = 0; while (cnt < days) { let skip = false; for (let i = 0; i < weekend.length; i++) { if (weekend[i] === startDate.getUTCDay()) { skip = true; break; } } if (!skip) { let found = false; for (let i = 0; i < holidaysArr.length; i++) { if (compareDateIgnoreTime(startDate, holidaysArr[i])) { found = true; break; } } if (!found) cnt++; } startDate.setUTCDate(startDate.getUTCDate() + 1); } return toSerial(startDate) - 1; }, YEAR: (serialOrString) => { const date = parseDate(serialOrString); return date.getUTCFullYear(); }, // Warning: may have bugs YEARFRAC: (startDate, endDate, basis) => { startDate = parseDate(startDate); endDate = parseDate(endDate); if (startDate > endDate) { const temp = startDate; startDate = endDate; endDate = temp; } basis = H.accept(basis, Types.NUMBER, 0); basis = Math.trunc(basis); if (basis < 0 || basis > 4) throw FormulaError.VALUE; // https://github.com/LesterLyu/formula.js/blob/develop/lib/date-time.js#L508 let sd = startDate.getUTCDate(); const sm = startDate.getUTCMonth() + 1; const sy = startDate.getUTCFullYear(); let ed = endDate.getUTCDate(); const em = endDate.getUTCMonth() + 1; const ey = endDate.getUTCFullYear(); switch (basis) { case 0: // US (NASD) 30/360 if (sd === 31 && ed === 31) { sd = 30; ed = 30; } else if (sd === 31) { sd = 30; } else if (sd === 30 && ed === 31) { ed = 30; } return Math.abs((ed + em * 30 + ey * 360) - (sd + sm * 30 + sy * 360)) / 360; case 1: // Actual/actual if (ey - sy < 2) { const yLength = isLeapYear(sy) && sy !== 1900 ? 366 : 365; const days = DateFunctions.DAYS(endDate, startDate); return days / yLength; } else { const years = (ey - sy) + 1; const days = (new Date(ey + 1, 0, 1) - new Date(sy, 0, 1)) / 1000 / 60 / 60 / 24; const average = days / years; return DateFunctions.DAYS(endDate, startDate) / average; } case 2: // Actual/360 return Math.abs(DateFunctions.DAYS(endDate, startDate) / 360); case 3: // Actual/365 return Math.abs(DateFunctions.DAYS(endDate, startDate) / 365); case 4: // European 30/360 return Math.abs((ed + em * 30 + ey * 360) - (sd + sm * 30 + sy * 360)) / 360; } }, }; module.exports = DateFunctions;