UNPKG

@eclipse-scout/core

Version:
1,194 lines (1,120 loc) 48.2 kB
/* * Copyright (c) 2010, 2024 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import {DateFormatPatternDefinition, DateFormatPatternType, DateFormatSymbols, dates, Locale, numbers, objects, scout, strings} from '../index'; /** * Custom JavaScript Date Format * * Support for formatting and parsing dates based on a pattern string and some locale * information from the server model. A subset of the standard Java pattern strings * (see SimpleDateFormat) with the most commonly used patterns is supported. * * This object only operates on the local time zone. * * @see http://docs.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html */ export class DateFormat { locale: Locale; pattern: string; symbols: DateFormatSymbols; lenient: boolean; /** * List of terms, e.g. split up parts of this.pattern. The length of this array is equal * to the length of this._formatFunctions, this._parseFunctions and this._analyzeFunctions. */ protected _terms: string[]; /** * List of format function to be called _in that exact order_ to convert this.pattern * to a formatted date string (by sequentially replacing all terms with real values). */ protected _formatFunctions: ((formatContext: DateFormatContext) => void)[]; /** * List of parse functions to be called _in that exact order_ to convert an input * string to a valid JavaScript Date object. This order matches the recognized terms * in the pattern. Unrecognized terms are represented by a "constant" function that * matches the string itself (e.g. separator characters or spaces). */ protected _parseFunctions: ((parseContext: DateFormatParseContext) => boolean)[]; /** Array of arrays, same order as _parseFunctions, but term functions are a list of term functions (to support lenient parsing) */ protected _analyzeFunctions: ((parseContext: DateFormatParseContext) => boolean)[][]; protected _patternDefinitions: DateFormatPatternDefinition[]; protected _patternLibrary: Record<string, DateFormatPatternDefinition[]>; constructor(locale: Locale, pattern: string, options?: DateFormatOptions) { options = options || {}; this.locale = locale; scout.assertParameter('locale', this.locale); this.pattern = pattern || locale.dateFormatPatternDefault; scout.assertParameter('pattern', this.pattern); this.symbols = locale.dateFormatSymbols; this.symbols.firstDayOfWeek = 1; // monday // TODO [7.0] cgu: deliver from server this.symbols.weekdaysOrdered = dates.orderWeekdays(this.symbols.weekdays, this.symbols.firstDayOfWeek); this.symbols.weekdaysShortOrdered = dates.orderWeekdays(this.symbols.weekdaysShort, this.symbols.firstDayOfWeek); this.lenient = scout.nvl(options.lenient, true); this._terms = []; this._formatFunctions = []; this._parseFunctions = []; this._analyzeFunctions = []; // Build a list of all pattern definitions. This list is then used to build the list of // format, parse and analyze functions according to this.pattern. // // !!! PLEASE NOTE !!! // The order of these definitions is important! For each term in the pattern, the list // is scanned from the beginning until a definition accepts the term. If the wrong // definition was picked, results would be unpredictable. // // Following the following rules ensures that the algorithm can pick the best matching // pattern format definition for each term in the pattern: // - Sort definitions by time span, from large (year) to small (milliseconds). // - Two definitions of the same type should be sorted by term length, from long // (e.g. MMMM) to short (e.g. M). this._patternDefinitions = [ // --- Year --- // This definition can _format_ dates with years with 4 or more digits. // See: http://docs.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html // chapter 'Date and Time Patterns', paragraph 'Year' // We do not allow to _parse_ a date with 5 or more digits. We could allow that in a // future release, but it could have an impact on backend logic, databases, etc. new DateFormatPatternDefinition({ type: DateFormatPatternType.YEAR, terms: ['yyyy'], // meaning: any number of digits is allowed dateFormat: this, formatFunction: (formatContext, acceptedTerm) => { let year = formatContext.inputDate.getFullYear(); let length = Math.max(4, year.toString().length); // min. digits = 4 return strings.padZeroLeft(year, length).slice(-length); }, parseRegExp: /^(\d{4})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.matchInfo.year = match; parseContext.dateInfo.year = Number(match); } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.YEAR, terms: ['yyy', 'yy', 'y'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => { let year = formatContext.inputDate.getFullYear(); if (formatContext.analyzeInfo?.matchInfo?.year) { let length = formatContext.analyzeInfo.matchInfo.year.length; return strings.padZeroLeft(year, length).slice(-length); } // "For formatting, if the number of pattern letters is 2, the year is truncated to 2 digits" if (acceptedTerm.length === 2) { let length = acceptedTerm.length; return strings.padZeroLeft(year, length).slice(-length); } // "For formatting, the number of pattern letters is the minimum number of digits, and shorter numbers are zero-padded to this amount." return strings.padZeroLeft(year, acceptedTerm.length); }, parseRegExp: /^(\d{1,4})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { // "For parsing, if the number of pattern letters is more than 2, the year is interpreted literally, regardless of the number of digits." if (match.length > 2) { parseContext.dateInfo.year = Number(match); parseContext.matchInfo.year = match; return; } // "For parsing with the abbreviated year pattern (y or yy), DateFormat must interpret the abbreviated year relative to some century. // It does this by adjusting dates to be within 80 years before and 20 years after the 'startYear'." let startYear = (parseContext.startDate || new Date()).getFullYear(); let year = Number(strings.padZeroLeft(startYear, 4).substring(0, 2) + strings.padZeroLeft(match, 2)); let distance = year - startYear; if (distance <= -80) { year += 100; } else if (distance > 20) { year -= 100; } parseContext.dateInfo.year = year; parseContext.matchInfo.year = match; } }), // --- Month --- new DateFormatPatternDefinition({ type: DateFormatPatternType.MONTH, terms: ['MMMM'], dateFormat: this, formatFunction: function(formatContext, acceptedTerm) { return this.dateFormat.symbols.months[formatContext.inputDate.getMonth()]; }, parseFunction: function(parseContext, acceptedTerm) { for (let i = 0; i < this.dateFormat.symbols.months.length; i++) { let symbol = this.dateFormat.symbols.months[i]; if (!symbol) { continue; // Ignore empty symbols (otherwise, pattern would match everything) } let re = new RegExp('^(' + strings.quote(symbol) + ')(.*)$', 'i'); let m = re.exec(parseContext.inputString); if (m) { // match found parseContext.dateInfo.month = i; parseContext.matchInfo.month = m[1]; parseContext.inputString = m[2]; return m[1]; } } // No match found so far. In analyze mode, check prefixes. if (parseContext.analyze) { for (let i = 0; i < this.dateFormat.symbols.months.length; i++) { let symbol = this.dateFormat.symbols.months[i]; let re = new RegExp('^(' + strings.quote(parseContext.inputString) + ')(.*)$', 'i'); let m = re.exec(symbol); if (m) { // match found parseContext.dateInfo.month = i; parseContext.matchInfo.month = symbol; parseContext.inputString = ''; return m[1]; } } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.MONTH, terms: ['MMM'], dateFormat: this, formatFunction: function(formatContext, acceptedTerm) { return this.dateFormat.symbols.monthsShort[formatContext.inputDate.getMonth()]; }, parseFunction: function(parseContext, acceptedTerm) { for (let i = 0; i < this.dateFormat.symbols.monthsShort.length; i++) { let symbol = this.dateFormat.symbols.monthsShort[i]; if (!symbol) { continue; // Ignore empty symbols (otherwise, pattern would match everything) } let re = new RegExp('^(' + strings.quote(symbol) + ')(.*)$', 'i'); let m = re.exec(parseContext.inputString); if (m) { // match found parseContext.dateInfo.month = i; parseContext.matchInfo.month = m[1]; parseContext.inputString = m[2]; return m[1]; } } // No match found so far. In analyze mode, check prefixes. if (parseContext.analyze) { for (let i = 0; i < this.dateFormat.symbols.monthsShort.length; i++) { let symbol = this.dateFormat.symbols.monthsShort[i]; let re = new RegExp('^(' + strings.quote(parseContext.inputString) + ')(.*)$', 'i'); let m = re.exec(symbol); if (m) { // match found parseContext.dateInfo.month = i; parseContext.matchInfo.month = symbol; parseContext.inputString = ''; return m[1]; } } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.MONTH, terms: ['MM'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getMonth() + 1, 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { let month = Number(match); parseContext.dateInfo.month = month - 1; parseContext.matchInfo.month = match; }, parseFunction: function(parseContext, acceptedTerm) { // Special case! When regexp did not match, check if input is '0'. In this case (and only // if we are in analyze mode), predict '01' as input. if (parseContext.analyze) { if (parseContext.inputString === '0') { // Use current dateInfo to create a date let date = this.dateFormat._dateInfoToDate(parseContext.dateInfo); if (!date) { return null; // parsing failed (dateInfo does not seem to contain a valid string) } let month = date.getMonth(); if (month >= 9) { month = 0; if (parseContext.dateInfo.year === undefined) { parseContext.dateInfo.year = Number(date.getFullYear()) + 1; } else { parseContext.dateInfo.year = parseContext.dateInfo.year + 1; } } parseContext.dateInfo.month = month; parseContext.matchInfo.month = strings.padZeroLeft(String(month + 1), 2); parseContext.inputString = ''; return '0'; } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.MONTH, terms: ['M'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getMonth() + 1), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { let month = Number(match); parseContext.dateInfo.month = month - 1; parseContext.matchInfo.month = match; } }), // --- Week in year --- new DateFormatPatternDefinition({ type: DateFormatPatternType.WEEK_IN_YEAR, terms: ['ww'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(dates.weekInYear(formatContext.inputDate), 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.matchInfo.week = match; parseContext.hints.weekInYear = Number(match); } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.WEEK_IN_YEAR, terms: ['w'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(dates.weekInYear(formatContext.inputDate)), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.matchInfo.week = match; parseContext.hints.weekInYear = Number(match); } }), // --- Day in month --- new DateFormatPatternDefinition({ type: DateFormatPatternType.DAY_IN_MONTH, terms: ['dd'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getDate(), 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.day = Number(match); parseContext.matchInfo.day = match; }, parseFunction: (parseContext, acceptedTerm) => { // Special case! When regexp did not match, check if input is '0'. In this case (and only // if we are in analyze mode), predict '01' as input. if (parseContext.analyze) { if (parseContext.inputString === '0') { parseContext.dateInfo.day = 1; parseContext.matchInfo.day = '01'; parseContext.inputString = ''; return '0'; } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.DAY_IN_MONTH, terms: ['d'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getDate()), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.day = Number(match); parseContext.matchInfo.day = match; } }), // --- Weekday --- new DateFormatPatternDefinition({ type: DateFormatPatternType.WEEKDAY, terms: ['EEEE'], dateFormat: this, formatFunction: function(formatContext, acceptedTerm) { return this.dateFormat.symbols.weekdays[formatContext.inputDate.getDay()]; }, parseFunction: function(parseContext, acceptedTerm) { for (let i = 0; i < this.dateFormat.symbols.weekdays.length; i++) { let symbol = this.dateFormat.symbols.weekdays[i]; if (!symbol) { continue; // Ignore empty symbols (otherwise, pattern would match everything) } let re = new RegExp('^(' + strings.quote(symbol) + ')(.*)$', 'i'); let m = re.exec(parseContext.inputString); if (m) { // match found parseContext.matchInfo.weekday = Number(m[1]); parseContext.hints.weekday = i; parseContext.inputString = m[2]; return m[1]; } } // No match found so far. In analyze mode, check prefixes. if (parseContext.analyze) { for (let i = 0; i < this.dateFormat.symbols.weekdays.length; i++) { let symbol = this.dateFormat.symbols.weekdays[i]; let re = new RegExp('^(' + strings.quote(parseContext.inputString) + ')(.*)$', 'i'); let m = re.exec(symbol); if (m) { // match found parseContext.matchInfo.weekday = symbol; parseContext.hints.weekday = i; parseContext.inputString = ''; return m[1]; } } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.WEEKDAY, terms: ['EEE', 'EE', 'E'], dateFormat: this, formatFunction: function(formatContext, acceptedTerm) { return this.dateFormat.symbols.weekdaysShort[formatContext.inputDate.getDay()]; }, parseFunction: function(parseContext, acceptedTerm) { for (let i = 0; i < this.dateFormat.symbols.weekdaysShort.length; i++) { let symbol = this.dateFormat.symbols.weekdaysShort[i]; if (!symbol) { continue; // Ignore empty symbols (otherwise, pattern would match everything) } let re = new RegExp('^(' + strings.quote(symbol) + ')(.*)$', 'i'); let m = re.exec(parseContext.inputString); if (m) { // match found parseContext.matchInfo.weekday = Number(m[1]); parseContext.hints.weekday = i; parseContext.inputString = m[2]; return m[1]; } } // No match found so far. In analyze mode, check prefixes. if (parseContext.analyze) { for (let i = 0; i < this.dateFormat.symbols.weekdaysShort.length; i++) { let symbol = this.dateFormat.symbols.weekdaysShort[i]; let re = new RegExp('^(' + strings.quote(parseContext.inputString) + ')(.*)$', 'i'); let m = re.exec(symbol); if (m) { // match found parseContext.matchInfo.weekday = symbol; parseContext.hints.weekday = i; parseContext.inputString = ''; return m[1]; } } } return null; // no match found } }), // --- Hour (24h) --- new DateFormatPatternDefinition({ type: DateFormatPatternType.HOUR_24, terms: ['HH'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getHours(), 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.hours = Number(match); parseContext.matchInfo.hours = match; } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.HOUR_24, terms: ['H'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getHours()), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.hours = Number(match); parseContext.matchInfo.hours = match; } }), // --- Hour (12h) --- new DateFormatPatternDefinition({ type: DateFormatPatternType.HOUR_12, terms: ['hh'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => { if (formatContext.inputDate.getHours() % 12 === 0) { return '12'; // there is no hour '0' in 12-hour format } return strings.padZeroLeft(formatContext.inputDate.getHours() % 12, 2); }, parseRegExp: /^(10|11|12|0[1-9])(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.hours = Number(match) + (parseContext.hints.pm ? 12 : 0); parseContext.matchInfo.hours = match; }, parseFunction: (parseContext, acceptedTerm) => { // Special case! When regexp did not match and input is a single '0', predict '01' if (parseContext.analyze) { if (parseContext.inputString === '0') { parseContext.dateInfo.hours = 1; parseContext.matchInfo.hours = '01'; parseContext.inputString = ''; return parseContext.inputString; } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.HOUR_12, terms: ['h'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => { if (formatContext.inputDate.getHours() % 12 === 0) { return '12'; // there is no hour '0' in 12-hour format } return String(formatContext.inputDate.getHours() % 12); }, parseRegExp: /^(10|11|12|0?[1-9])(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.hours = Number(match) + (parseContext.hints.pm ? 12 : 0); parseContext.matchInfo.hours = match; } }), // --- AM/PM marker --- new DateFormatPatternDefinition({ type: DateFormatPatternType.AM_PM, terms: ['a'], dateFormat: this, formatFunction: function(formatContext, acceptedTerm) { if (formatContext.inputDate.getHours() < 12) { return this.dateFormat.symbols.am; } return this.dateFormat.symbols.pm; }, parseFunction: function(parseContext, acceptedTerm) { let re = new RegExp('^(' + strings.quote(this.dateFormat.symbols.am) + ')(.*)$', 'i'); let m = re.exec(parseContext.inputString); parseContext.matchInfo.ampm = null; if (m) { // match found parseContext.matchInfo.ampm = m[1]; parseContext.inputString = m[2]; parseContext.hints.am = true; parseContext.dateInfo.hours = parseContext.dateInfo.hours % 12; return m[1]; } re = new RegExp('^(' + strings.quote(this.dateFormat.symbols.pm) + ')(.*)$', 'i'); m = re.exec(parseContext.inputString); if (m) { // match found parseContext.matchInfo.ampm = m[1]; parseContext.inputString = m[2]; parseContext.hints.pm = true; parseContext.dateInfo.hours = (parseContext.dateInfo.hours % 12) + 12; return m[1]; } // No match found so far. In analyze mode, check prefixes. if (parseContext.analyze) { re = new RegExp('^(' + strings.quote(parseContext.inputString) + ')(.*)$', 'i'); m = re.exec(this.dateFormat.symbols.am); if (m) { parseContext.matchInfo.ampm = this.dateFormat.symbols.am; parseContext.inputString = ''; parseContext.hints.am = true; parseContext.dateInfo.hours = parseContext.dateInfo.hours % 12; return m[1]; } m = re.exec(this.dateFormat.symbols.pm); if (m) { parseContext.matchInfo.ampm = this.dateFormat.symbols.pm; parseContext.inputString = ''; parseContext.hints.pm = true; parseContext.dateInfo.hours = (parseContext.dateInfo.hours % 12) + 12; return m[1]; } } return null; // no match found } }), // --- Minute --- new DateFormatPatternDefinition({ type: DateFormatPatternType.MINUTE, terms: ['mm'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getMinutes(), 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.minutes = Number(match); parseContext.matchInfo.minutes = match; }, parseFunction: (parseContext, acceptedTerm) => { // Special case! When regexp did not match, check if input + '0' would make a // valid minutes value. If yes, predict this value. if (parseContext.analyze) { if (scout.isOneOf(parseContext.inputString, '0', '1', '2', '3', '4', '5')) { let tenMinutes = parseContext.inputString + '0'; parseContext.dateInfo.minutes = Number(tenMinutes); parseContext.matchInfo.minutes = tenMinutes; parseContext.inputString = ''; return parseContext.inputString; } } return null; // no match found } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.MINUTE, terms: ['m'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getMinutes()), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.minutes = Number(match); parseContext.matchInfo.minutes = match; } }), // --- Second --- new DateFormatPatternDefinition({ type: DateFormatPatternType.SECOND, terms: ['ss'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getSeconds(), 2), parseRegExp: /^(\d{2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.seconds = Number(match); parseContext.matchInfo.seconds = match; } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.SECOND, terms: ['s'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getSeconds()), parseRegExp: /^(\d{1,2})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.seconds = Number(match); parseContext.matchInfo.seconds = match; } }), // --- Millisecond --- new DateFormatPatternDefinition({ type: DateFormatPatternType.MILLISECOND, terms: ['SSS'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => strings.padZeroLeft(formatContext.inputDate.getMilliseconds(), 3), parseRegExp: /^(\d{3})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.milliseconds = Number(match); parseContext.matchInfo.milliseconds = match; } }), new DateFormatPatternDefinition({ type: DateFormatPatternType.MILLISECOND, terms: ['S'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => String(formatContext.inputDate.getMilliseconds()), parseRegExp: /^(\d{1,3})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { parseContext.dateInfo.milliseconds = Number(match); parseContext.matchInfo.milliseconds = match; } }), // --- Time zone --- new DateFormatPatternDefinition({ type: DateFormatPatternType.TIMEZONE, terms: ['Z'], dateFormat: this, formatFunction: (formatContext, acceptedTerm) => { let offset = Math.abs(formatContext.inputDate.getTimezoneOffset()), isNegative = offset !== formatContext.inputDate.getTimezoneOffset(); return (isNegative ? '-' : '+') + strings.padZeroLeft(Math.floor(offset / 60), 2) + strings.padZeroLeft(offset % 60, 2); }, parseRegExp: /^([+|-]\d{4})(.*)$/, applyMatchFunction: (parseContext, match, acceptedTerm) => { let offset = Number(match.substring(1, 3)) * 60 + Number(match.substring(3, 5)); if (match.charAt(0) === '-') { offset *= -1; } parseContext.dateInfo.timezone = offset; parseContext.matchInfo.timezone = match; } }) ]; // Build a map of pattern definitions by pattern type this._patternLibrary = {}; for (let i = 0; i < this._patternDefinitions.length; i++) { let patternDefinition = this._patternDefinitions[i]; let type = patternDefinition.type; if (type) { if (!this._patternLibrary[type]) { this._patternLibrary[type] = []; } this._patternLibrary[type].push(patternDefinition); } } this._compile(); } protected _compile() { // Build format, parse and analyze functions for all terms in the DateFormat's pattern. // A term is a continuous sequence of the same character. let re = /(.)\1*/g; let m: RegExpExecArray; while ((m = re.exec(this.pattern))) { let term = m[0]; this._terms.push(term); let termAccepted = false; for (let i = 0; i < this._patternDefinitions.length; i++) { let patternDefinition = this._patternDefinitions[i]; let acceptedTerm = patternDefinition.accept(term); if (acceptedTerm) { // 1. Create and install format function this._formatFunctions.push(patternDefinition.createFormatFunction(acceptedTerm)); // 2. Create and install parse function this._parseFunctions.push(patternDefinition.createParseFunction(acceptedTerm)); // 3. Create and install analyze functions let analyseFunctions = [patternDefinition.createParseFunction(acceptedTerm)]; if (this.lenient) { // In lenient mode, add all other parse functions of the same type let patternDefinitions = this._patternLibrary[patternDefinition.type]; for (let j = 0; j < patternDefinitions.length; j++) { if (patternDefinitions[j] !== patternDefinition) { analyseFunctions.push(patternDefinitions[j].createParseFunction(acceptedTerm)); } } } this._analyzeFunctions.push(analyseFunctions); // Term was processed, continue with next term termAccepted = true; break; } } // In case term was not accepted by any pattern definition, assume it is a constant string if (!termAccepted) { // 1. Create and install constant format function this._formatFunctions.push(this._createConstantStringFormatFunction(term)); // 2./3. Create and install parse and analyse functions let constantStringParseFunction = this._createConstantStringParseFunction(term); this._parseFunctions.push(constantStringParseFunction); this._analyzeFunctions.push([constantStringParseFunction]); } } } /** * Returns a format function for constant terms (e.g. all parts of a pattern that don't have a {@link DateFormatPatternDefinition}). */ protected _createConstantStringFormatFunction(term: string): (formatContext: DateFormatContext) => void { return formatContext => { formatContext.formattedString += term; }; } /** * Returns a parse function for constant terms (e.g. all parts of a pattern that don't * have a DateFormatPatternDefinition). */ protected _createConstantStringParseFunction(term: string): (parseContext: DateFormatParseContext) => boolean { return parseContext => { if (strings.startsWith(parseContext.inputString, term)) { parseContext.inputString = parseContext.inputString.substring(term.length); parseContext.parsedPattern += term; return true; } // In analyze mode, constant terms are optional (this supports "020318" --> "02.03.2018") return parseContext.analyze; }; } /** * Formats the given date according to the date pattern. If the date is missing, the * empty string is returned. */ format(date: Date, options: DateFormatFormatOptions = {}): string { if (!date) { return ''; } let formatContext = this._createFormatContext(date, options.analyzeInfo); // Apply all formatter functions for this DateFormat to the pattern to replace the // different terms with the corresponding value from the given date. for (let i = 0; i < this._formatFunctions.length; i++) { let formatFunction = this._formatFunctions[i]; formatFunction(formatContext); } return formatContext.formattedString; } /** * Analyzes the given string and returns an information object with all recognized information * for the current date format. */ analyze(text: string, startDate?: Date): DateFormatAnalyzeInfo { let analyzeInfo = this._createAnalyzeInfo(text); if (!text) { return analyzeInfo; } let parseContext = this._createParseContext(text); parseContext.analyze = true; // Mark context as "analyze mode" parseContext.startDate = startDate; let matchedPattern = ''; for (let i = 0; i < this._terms.length; i++) { if (parseContext.inputString.length > 0) { let analyzeFunctions = this._analyzeFunctions[i]; let parsed = false; for (let j = 0; j < analyzeFunctions.length; j++) { let analyzeFunction = analyzeFunctions[j]; if (analyzeFunction(parseContext)) { parsed = true; break; } } if (!parsed) { // Parsing failed analyzeInfo.error = true; return analyzeInfo; } matchedPattern = parseContext.parsedPattern; } else { // Input is fully consumed, now just add the remaining terms from the pattern parseContext.parsedPattern += this._terms[i]; } } if (parseContext.inputString.length > 0) { // There is still input, but the pattern has no more terms --> parsing failed analyzeInfo.error = true; return analyzeInfo; } // Try to generate a valid predicted date with the information retrieved so far startDate = this._prepareStartDate(startDate); // When weekday is included in pattern, try to find a suitable start date #235975 let dayInWeek = parseContext.hints.weekday; let dayInMonth = parseContext.dateInfo.day; if (dayInWeek !== undefined) { if (dayInMonth !== undefined && dayInMonth <= 31) { startDate = dates.shiftToNextDayAndDate(startDate, dayInWeek, dayInMonth); } else { startDate = dates.shiftToNextDayOfType(startDate, dayInWeek); } } let predictedDate = this._dateInfoToDate(parseContext.dateInfo, startDate, parseContext.hints); // Update analyzeInfo analyzeInfo.dateInfo = parseContext.dateInfo; analyzeInfo.matchInfo = parseContext.matchInfo; analyzeInfo.hints = parseContext.hints; analyzeInfo.parsedPattern = parseContext.parsedPattern; analyzeInfo.matchedPattern = matchedPattern; analyzeInfo.predictedDate = predictedDate; analyzeInfo.error = (!predictedDate); return analyzeInfo; } /** * Parses the given text with the current date format. If the text does not match exactly * with the pattern, `null` is returned. Otherwise, the parsed date is returned. * * The argument 'startDate' is optional. It may set the date where parsed information should * be applied to (e.g. relevant for 2-digit years). */ parse(text: string, startDate?: Date): Date { if (!text) { return null; } let parseContext = this._createParseContext(text); parseContext.startDate = startDate; for (let i = 0; i < this._parseFunctions.length; i++) { let parseFunction = this._parseFunctions[i]; if (!parseFunction(parseContext)) { return null; // Parsing failed } if (parseContext.inputString.length === 0) { break; // Everything parsed! } } if (parseContext.inputString.length > 0) { // Input remaining but no more parse functions available -> parsing failed return null; } // Build date from dateInfo let date = this._dateInfoToDate(parseContext.dateInfo, startDate); if (!date) { return null; // dateInfo could not be converted to a valid date -> parsing failed } // Handle hints if (parseContext.hints.weekday !== undefined) { if (date.getDay() !== parseContext.hints.weekday) { return null; // Date and weekday don't match -> parsing failed } } // Return valid date return date; } private _dateInfoToDate(dateInfo: DateFormatDateInfo, startDate?: Date, hints?: DateFormatHints): Date { if (!dateInfo) { return null; } // Default date startDate = this._prepareStartDate(startDate); // Apply date info (Start with "zero date", otherwise the date may become invalid // due to JavaScript's automatic date correction, e.g. dateInfo = { day: 11, month: 1 } // and startDate = 2015-07-29 would result in invalid date 2015-03-11, because February // 2015 does not have 29 days and is "corrected" to March.) let result = new Date(1970, 0, 1); let validDay = scout.nvl(dateInfo.day, startDate.getDate()); let validMonth = scout.nvl(dateInfo.month, startDate.getMonth()); let validYear = scout.nvl(dateInfo.year, startDate.getFullYear()); // When user entered the day but not (yet) the month, adjust month if possible to propose a valid date if (dateInfo.day && !numbers.isNumber(dateInfo.month)) { // If day "31" does not exist in the proposed month, use the next month if (dateInfo.day === 31) { let monthsWithThirtyOneDays = [0, 2, 4, 6, 7, 9, 11]; if (!scout.isOneOf(validMonth, monthsWithThirtyOneDays)) { validMonth = validMonth + 1; } } else if (dateInfo.day >= 29 && validMonth === 1) { // If day is "29" or "30" and month is february, use next month (except day is "29" and the year is a leap year) if (dateInfo.day > 29 || !dates.isLeapYear(validYear)) { validMonth = validMonth + 1; } } } // ensure valid day for selected month for dateInfo without day if (!dateInfo.day && (numbers.isNumber(dateInfo.month) || dateInfo.year)) { let lastOfMonth = dates.shift(new Date(validYear, validMonth + 1, 1), 0, 0, -1); validDay = Math.min(lastOfMonth.getDate(), startDate.getDate()); } result.setFullYear( validYear, validMonth, validDay ); result.setHours( scout.nvl(dateInfo.hours, startDate.getHours()), scout.nvl(dateInfo.minutes, startDate.getMinutes()), scout.nvl(dateInfo.seconds, startDate.getSeconds()), scout.nvl(dateInfo.milliseconds, startDate.getMilliseconds()) ); // Validate. A date is considered valid if the value from the dateInfo did // not change (JS date automatically converts illegal values, e.g. day 32 is // converted to first day of next month). if (!isValid(result.getFullYear(), dateInfo.year)) { return null; } if (!isValid(result.getMonth(), dateInfo.month)) { return null; } if (!isValid(result.getDate(), dateInfo.day)) { return null; } if (!isValid(result.getHours(), dateInfo.hours)) { return null; } if (!isValid(result.getMinutes(), dateInfo.minutes)) { return null; } if (!isValid(result.getSeconds(), dateInfo.seconds)) { return null; } if (!isValid(result.getMilliseconds(), dateInfo.milliseconds)) { return null; } if (!isValid(result.getDay(), hints?.weekday)) { return null; } // Adjust time zone if (numbers.isNumber(dateInfo.timezone)) { result.setMinutes(result.getMinutes() - result.getTimezoneOffset() + dateInfo.timezone); } return result; // ----- Helper functions ----- function isValid(value, expectedValue) { return objects.isNullOrUndefined(expectedValue) || expectedValue === value; } } /** * Returns the date where parsed information should be applied to. The given * startDate is used when specified, otherwise a new date is created (today). */ protected _prepareStartDate(startDate: Date): Date { if (startDate) { // It is important that we don't alter the argument 'startDate', but create an independent copy! return new Date(startDate.getTime()); } return dates.trunc(new Date()); // clear time } /** * Returns the "format context", an object that is initially filled with the input date and is then * passed through the various formatting functions. As the formatting progresses, the format context object * is updated accordingly. At the end of the process, the object contains the result. */ protected _createFormatContext(inputDate: Date, analyzeInfo: DateFormatAnalyzeInfo): DateFormatContext { return { inputDate: inputDate, analyzeInfo: analyzeInfo, formattedString: '' }; } /** * Returns the "parse context", an object that is initially filled with the input string and is then * passed through the various parsing functions. As the parsing progresses, the parse context object * is updated accordingly. At the end of the process, the object contains the result. */ protected _createParseContext(inputText: string): DateFormatParseContext { return { inputString: inputText, dateInfo: {}, matchInfo: {}, hints: {}, parsedPattern: '', analyze: false, startDate: null }; } protected _createAnalyzeInfo(inputText: string): DateFormatAnalyzeInfo { return { inputString: inputText, dateInfo: {}, matchInfo: {}, hints: {}, parsedPattern: '', matchedPattern: '', predictedDate: null, error: false }; } static ensure(locale: Locale, format: string | DateFormat): DateFormat { if (!format) { return format as DateFormat; } if (format instanceof DateFormat) { return format; } return new DateFormat(locale, format); } } export interface DateFormatOptions { /** * Relevant during analyze(). When this is true (default), terms of the same "pattern type" (e.g. "d" and "dd") will * also be considered. Otherwise, analyze() behaves like parse(), i.g. the pattern must match exactly. * Example: "2.10" will match the pattern "dd.MM.yyy" when lenient=true. If lenient is false, it won't match. */ lenient?: boolean; } export interface DateFormatFormatOptions { /** * The result of a previously analyzed user input when formatting it again. It helps the internal format functions * to adjust the length of an accepted term to match to the corresponding user input. * * Normally, it is not necessary to set this value. */ analyzeInfo?: DateFormatAnalyzeInfo; } export interface DateFormatMatchInfo { year?: string; /** one-based (January = '1') */ month?: string; week?: string; day?: string; weekday?: number; hours?: string; ampm?: string; minutes?: string; seconds?: string; milliseconds?: string; timezone?: string; } export interface DateFormatDateInfo { year?: number; /** zero-based (January = 0) */ month?: number; day?: number; hours?: number; minutes?: number; seconds?: number; milliseconds?: number; timezone?: number; } export interface DateFormatParseContext { /** * The original input for the parsing. This string will be consumed during the parse process, and will be empty at the end. */ inputString: string; /** * An object with all numeric date parts that could be parsed from the input string. * Unrecognized parts are undefined, all others are converted to numbers. * Those values may be directly used in the JavaScript Date() type (month is zero-based!). */ dateInfo: DateFormatDateInfo; /** * Similar to dateInfo, but the parts are defined as strings as they were parsed from the input. * While dateInfo may contain the year 1995, the matchInfo may contain "95". Also note that the month is "one-based", as opposed to dateInfo.month! */ matchInfo: DateFormatMatchInfo; /** * An object that contains further recognized date parts that are not needed to define the exact time. */ hints: DateFormatHints; /** * The pattern that was used to parse the input string. It contains only the part of the date format pattern that matches the input string. * Example: dateFormat="dd.MM.yyyy", inputString="14.2." --> parsedPattern="dd.M." */ parsedPattern: string; /** * A flag that indicates if the "analyze mode" is on. This is true when analyze() was called, and false when parse() was called. * It may alter the behavior of the parse functions, i.e. they will not fail in analyze mode when the pattern does not match exactly. */ analyze: boolean; /** * A date to be used as reference for date calculations. Is used for example when mapping a 2-digit year to a 4-digit year. */ startDate: Date; } export interface DateFormatHints { am?: boolean; pm?: boolean; /** * number 0-6; 0=sun, 1=mon, etc. */ weekday?: number; /** * number 1-53 */ weekInYear?: number; } export interface DateFormatContext { /** * The date to be formatted. */ inputDate: Date; /** * The result of a previously analyzed user input when formatting it again. It can be used by the internal format functions * to adjust the length of an accepted term to match to the corresponding user input. */ analyzeInfo: DateFormatAnalyzeInfo; /** * The result of the formatting. The string is initially empty. During the format process, the formatted parts will be appended * to the string until the final string is complete. */ formattedString: string; } export interface DateFormatAnalyzeInfo { /** * The original input for the analysis. */ inputString: string; /** * An object with all numeric date parts that could be parsed from the input string. Unrecognized parts are undefined, all others are converted to numbers. * Those values may be directly used in the JavaScript Date() type (month is zero-based!). */ dateInfo: DateFormatDateInfo; /** * Similar to dateInfo, but the parts are defined as strings as they were parsed from the input. * While dateInfo may contain the year 1995, the matchInfo may contain "95". Also note that the month is "one-based", as opposed to dateInfo.month! */ matchInfo: DateFormatMatchInfo; /** * An object that contains further recognized date parts that are not needed to define the exact time. */ hints: DateFormatHints; /** * The pattern that was used to parse the input. This may differ from the date format's pattern. * Example: dateFormat="dd.MM.YYYY", inputString="5.7.2015" --> parsedPattern="d.M.yyyy" */ parsedPattern: string; /** * The pattern that was recognized in the input. Unlike "parsedPattern", this may not be a full pattern. * Example: dateFormat="dd.MM.YYYY", inputString="5.7." --> parsedPattern="d.M.yyyy", matchedPattern="d.M." */ matchedPattern: string; /** * The date that could be predicted from the recognized inputs. * If the second method argument 'startDate' is set, this date is used as basis for this predicted date. Otherwise, 'today' is used. */ predictedDate: Date; /** * Boolean that indicates if analyzing the input was successful (e.g. if the pattern could be parsed and a date could be predicted). */ error: boolean; }