/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ import type { Complex } from 'treb-base-types'; import { ValueType, Localization } from 'treb-base-types'; import { UnlotusDate } from './format'; /** * this is code that was in the old number format class, which was superceded * by the new treb-format module. we still need to do rough value parsing, * which is separate from parsing and from formatting. * * cleaning up to remove redundant bits, move inference in here * * FIXME: move this somewhere else, this is the format library */ // tslint:disable:no-bitwise /* * hints is a bitfield * / export enum Hints { None = 0x00, Nan = 0x01, Exponential = 0x02, Percent = 0x04, Currency = 0x08, Grouping = 0x10, Parens = 0x20, Date = 0x40, Time = 0x80, } */ export interface Hints { Nan?: boolean; Exponential?: boolean; Percent?: boolean; Currency?: boolean; Grouping?: boolean; Parens?: boolean; Date?: boolean; Time?: boolean; } /** * parse result now uses base valuetype */ export interface ParseResult { value: number|string|boolean|undefined|Complex; hints?: Hints; type: ValueType; } const this_year = new Date().getUTCFullYear(); /** * value parser class is a singleton, instance is exported */ class ValueParserType { public compare_day?: Record; public compare_month?: Record; public TestDate(text: string): number|false { // first check if JS can turn it into a date at all. we can // use that as a baseline/initial check (in any event there's // no point in doing the work otherwise) const date_value = Date.parse(text); if (isNaN(date_value)) { // console.info('td false: is nan') return false; } const date = new Date(date_value); // OK so there's a date, now we can do whatever validation we want // is there a regexp for accented characters? actually we can // maybe go backwards... remove things that properly belong in // dates: numbers, dashes and slashes (+ comma, whitespace) // dots are in some l10n systems... maybe we should check first? // Q: what does lowercase do for accented characters? (...) // NOTE: as it turns out, Date.parse() only handles US-EN. so // all this is unecessary (and unused) in other locales. to really // do this properly we will probably need to write our own locale- // aware date parser. which is probably a lot of work. TODO. const tmp = text.replace(/[\d\-\\/,.\s]+/g, ' ').toLocaleLowerCase(); // then split into individual strings. trim and drop empty const components = tmp.split(/\s+/).map(component => component.trim()).filter(component => !!component); if (!components.length) { // console.info('td true: no strings'); return date_value; // probably a date } // now we'll compare these to stuff we have in l10n. rule (WIP): // should be in month or day-of-week. accept short or long, or one // character. (one character introduces some ambiguity...) // drop 1 character, it's ambiguous and annoying (also hard to handle) // so basically, any string in here has to be a month or day; we can't // have two of either; they have to match what was parsed; and you can't // have a day-of-week but not a month. // FIXME: cache/precalc // let's do it lazily // NOTE: portugeuse seems to include periods in their abbreviations... if (!this.compare_month) { this.compare_month = {}; for (let i = 0; i < 12; i++) { this.compare_month[Localization.date_components.long_months[i].toLocaleLowerCase().replace(/\./, '')] = i; this.compare_month[Localization.date_components.short_months[i].toLocaleLowerCase().replace(/\./, '')] = i; // comparison[Localization.date_components.long_months[i][0].toLocaleLowerCase()] = i; } } if (!this.compare_day) { this.compare_day = {}; for (let i = 0; i < 7; i++) { this.compare_day[Localization.date_components.long_days[i].toLocaleLowerCase().replace(/\./, '')] = i; this.compare_day[Localization.date_components.short_days[i].toLocaleLowerCase().replace(/\./, '')] = i; } } let found_month = false; let found_day = false; for (const component of components) { let found = false; for (const [month, value] of Object.entries(this.compare_month)) { if (component === month) { // can't have two months in a single date if (found_month) { // console.info('td false: two months') return false; } // have a string match [FIXME: fuzzy?] // check that the month matches if (date.getUTCMonth() !== value) { // console.info('td false: month mismatch') return false; } found = true; found_month = true; } } if (!found) { for (const [day, value] of Object.entries(this.compare_day)) { if (component === day) { // can't have two days either if (found_day) { // console.info('td false: two days') return false; } if (date.getUTCDay() !== value) { // console.info('td false: day mismatch') return false; } found = true; found_day = true; } } } if (!found) { // whatever this string is, we don't recognize it. so this is // probably not a date. // console.info('td false: unmatched string') return false; } } // last check: no DOW without month. if (found_day && !found_month) { // console.info('td false: day but no month') return false; } // OK, accept it // console.info('td true: ran out of cases') return date_value; } /** * parse a string. if it can reasonably be converted to a number, * do that and return the number; otherwise return the original * string. we also return hints as to formatting, which the caller * may use to select a number format. * * remind me why this is better than just using a parser? (...) */ public TryParse(text = ''): ParseResult { let hints: Hints = {}; // Hints.None; // starts with SINGLE quote mark. express string. if (text[0] === '\'') return { value: text, type: ValueType.string }; // empty string, treat as string (should be === 0 though?) if (text === '') return { value: text, type: ValueType.string }; // we test if the conversion returns NaN, which usually means // it's not a number -- unless the string is actually NaN, which // is something we want to preserve. if ( text === 'NaN' ) return { value: NaN, type: ValueType.number, hints: { Nan: true }, }; let x = text.trim(); // x = x.replace(/^[\$£€]/, '').trim(); const currency = x.match(/^[$](.*?)$/); if (currency) { x = currency[1]; // hints |= Hints.Currency; hints.Currency = true; } const parens = x.match(/^\((.*?)\)$/); if (parens) { x = parens[1]; // hints |= Hints.Parens; hints.Parens = true; } const pct = x.match(/^(.*?)%\s*$/); if (pct) { x = pct[1]; // hints |= Hints.Percent; hints.Percent = true; } if (Localization.decimal_separator === '.'){ if (/,/.test(x)) { x = x.replace(/,/g, ''); // hints |= Hints.Grouping; hints.Grouping = true; } } else { x = x.replace(/(\d)\s+/g, '$1'); // remove spaces inside numbers x = x.replace(/\./g, ''); // remove point separators x = x.replace(/,/, '.'); // convert to US-style } let num = Number(x); if (null === num || isNaN(num)){ // check boolean const lc = text.toLowerCase(); if (lc === 'false') return { value: false, type: ValueType.boolean }; if (lc === 'true' ) return { value: true, type: ValueType.boolean }; // check date, but bound on reasonable years... // also maybe parameterize, make this optional // this is too aggressive (at least in chrome). we need to dial it // back a bit. to do that, we will validate any strings in the text // and ensure they look like date components (usually months and days), // within the current locale. const date = this.TestDate(text); if (false !== date && !isNaN(date)) { // we can drop this bit, now (I think) const check = new Date(date); const year = check.getUTCFullYear(); if (year >= (this_year - 200) && year <= (this_year + 200)) { hints = { Date: true }; if (check.getHours() || check.getMinutes() || check.getSeconds()) { hints.Time = true; } return { value: UnlotusDate(date, true), type: ValueType.number, hints, }; } } return { value: text, type: ValueType.string }; } if (parens) { num = -num; } if (pct) { // NOTE: this is an attempt to reduce fp errors that arise // if you /100 (or if you /10 twice, which actually helps, but // is not sufficient). there's probably a better way to do this... const sign = num < 0 ? -1 : 1; const split = (sign * num).toString().split('.'); split[0] = ('00' + split[0]).replace(/(\d\d)$/, '.$1'); num = Number(split.join('')) * sign; } if (/e/.test(text)) hints.Exponential = true; return { value: num, type: ValueType.number, hints }; } } export const ValueParser = new ValueParserType();