@eclipse-scout/core
Version:
Eclipse Scout runtime
1,194 lines (1,120 loc) • 48.2 kB
text/typescript
/*
* 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;
}