5 * @classdesc Provides various methods needed for formatting dates and times.
7 * @mixes OO.EventEmitter
10 * @description Create an instance of `mw.widgets.datetime.DateTimeFormatter`.
11 * @param {Object} [config] Configuration options
12 * @param {string} [config.format='@default'] May be a key from the
13 * {@link mw.widgets.datetime.DateTimeFormatter.formats}, or a format
14 * specification as defined by {@link mw.widgets.datetime.DateTimeFormatter#parseFieldSpec}
15 * and {@link mw.widgets.datetime.DateTimeFormatter#getFieldForTag}.
16 * @param {boolean} [config.local=false] Whether dates are local time or UTC
17 * @param {string[]} [config.fullZones] Time zone indicators. Array of 2 strings, for
19 * @param {string[]} [config.shortZones] Abbreviated time zone indicators. Array of 2
20 * strings, for UTC and local time.
21 * @param {Date} [config.defaultDate] Default date, for filling unspecified components.
22 * Defaults to the current date and time (with 0 milliseconds).
24 mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) {
25 this.constructor.static.setupDefaults();
27 config = Object.assign( {
30 fullZones: this.constructor.static.fullZones,
31 shortZones: this.constructor.static.shortZones
35 OO.EventEmitter.call( this );
38 if ( this.constructor.static.formats[ config.format ] ) {
39 this.format = this.constructor.static.formats[ config.format ];
41 this.format = config.format;
43 this.local = !!config.local;
44 this.fullZones = config.fullZones;
45 this.shortZones = config.shortZones;
46 if ( config.defaultDate instanceof Date ) {
47 this.defaultDate = config.defaultDate;
49 this.defaultDate = new Date();
51 this.defaultDate.setMilliseconds( 0 );
53 this.defaultDate.setUTCMilliseconds( 0 );
60 OO.initClass( mw.widgets.datetime.DateTimeFormatter );
61 OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );
66 * Default format specifications. See the {@link #format format} parameter.
71 * @name mw.widgets.datetime.DateTimeFormatter.formats
73 mw.widgets.datetime.DateTimeFormatter.static.formats = {};
76 * Default time zone indicators.
81 * @name mw.widgets.datetime.DateTimeFormatter.fullZones
83 mw.widgets.datetime.DateTimeFormatter.static.fullZones = null;
86 * Default abbreviated time zone indicators.
91 * @name mw.widgets.datetime.DateTimeFormatter.shortZones
93 mw.widgets.datetime.DateTimeFormatter.static.shortZones = null;
95 mw.widgets.datetime.DateTimeFormatter.static.setupDefaults = function () {
96 if ( !this.fullZones ) {
98 mw.msg( 'timezone-utc' ),
99 mw.msg( 'timezone-local' )
102 if ( !this.shortZones ) {
105 this.fullZones[ 1 ].slice( 0, 1 ).toUpperCase()
107 if ( this.shortZones[ 1 ] === 'Z' ) {
108 this.shortZones[ 1 ] = 'L';
116 * A `local` event is emitted when the 'local' flag is changed.
118 * @event mw.widgets.datetime.DateTimeFormatter.local
119 * @param {boolean} local Whether dates are local time
125 * Whether dates are in local time or UTC.
127 * @return {boolean} True if local time
129 mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
134 * Toggle whether dates are in local time or UTC.
136 * @param {boolean} [flag] Set the flag instead of toggling it
137 * @fires mw.widgets.datetime.DateTimeFormatter.local
139 * @return {mw.widgets.datetime.DateTimeFormatter}
141 mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) {
142 if ( flag === undefined ) {
147 if ( this.local !== flag ) {
149 this.emit( 'local', this.local );
155 * Get the default date.
159 mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () {
160 return new Date( this.defaultDate.getTime() );
164 * Fetch the field specification array for this object.
166 * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure.
170 mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () {
171 return this.parseFieldSpec( this.format );
175 * Parse a format string into a field specification.
177 * The input is a string containing tags formatted as ${tag|param|param...}
178 * (for editable fields) and $!{tag|param|param...} (for non-editable fields).
179 * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few
181 * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
183 * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
186 * Elements of the returned array are strings or objects. Strings are meant to
187 * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}.
190 * @param {string} format
193 mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) {
194 let m, last, tag, params, spec;
198 re = /(.*?)(\$(!?)\{([^}]+)\})/g;
201 while ( ( m = re.exec( format ) ) !== null ) {
204 if ( m[ 1 ] !== '' ) {
208 params = m[ 4 ].split( '|' );
209 tag = params.shift();
210 spec = this.getFieldForTag( tag, params );
212 if ( m[ 3 ] === '!' ) {
213 spec.editable = false;
220 if ( last < format.length ) {
221 ret.push( format.slice( last ) );
228 * @typedef {Object} mw.widgets.datetime.DateTimeFormatter~FieldSpecificationObject
229 * @property {string|null} component Date component corresponding to this field, if any.
230 * @property {boolean} editable Whether this field is editable.
231 * @property {string} type What kind of field this is:
232 * - 'static': The field is a static string; component will be null.
233 * - 'number': The field is generally numeric.
234 * - 'string': The field is generally textual.
235 * - 'boolean': The field is a boolean.
236 * - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}.
237 * Editing should directly call {@link #toggleLocal this.toggleLocal()}.
238 * @property {boolean} calendarComponent Whether this field is part of a calendar, e.g.
239 * part of the date instead of the time.
240 * @property {number} size Maximum number of characters in the field (when
241 * the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
242 * @property {Object.<string,number>} intercalarySize Map from
243 * 'intercalary' component values to overridden sizes.
244 * @property {string} value For type='static', the string to display.
245 * @property {function(Mixed): string} formatValue A function to format a
246 * component value as a display string.
247 * @property {function(string): Mixed} parseValue A function to parse a
248 * display string into a component value. If parsing fails, returns undefined.
252 * Turn a tag into a field specification object.
254 * Fields implemented here are:
255 * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
257 * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
259 * - ${zone|#}: Timezone offset, "+0000" format.
260 * - ${zone|:}: Timezone offset, "+00:00" format.
261 * - ${zone|short}: Timezone from 'shortZones' configuration setting.
262 * - ${zone|full}: Timezone from 'fullZones' configuration setting.
266 * @param {string} tag
267 * @param {string[]} params
268 * @return {FieldSpecificationObject} Field specification object, or null if the tag+params are unrecognized.
270 mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
275 case 'not-intercalary':
276 if ( params.length < 2 || !params[ 0 ] ) {
281 calendarComponent: false,
284 value: params.slice( 1 ).join( '|' ),
288 if ( tag === 'intercalary' ) {
289 spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
291 spec.size = spec.value.length;
292 spec.intercalarySize[ params[ 0 ] ] = 0;
297 switch ( params[ 0 ] ) {
300 c = params[ 0 ] === '#' ? '' : ':';
303 calendarComponent: false,
307 formatValue: function ( v ) {
310 o = new Date().getTimezoneOffset();
311 r = String( Math.abs( o ) % 60 );
312 while ( r.length < 2 ) {
315 r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
316 while ( r.length < 4 + c.length ) {
319 return ( o <= 0 ? '+' : '−' ) + r;
321 return '+00' + c + '00';
324 parseValue: function ( v ) {
326 v = String( v ).trim();
327 if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
328 return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
339 calendarComponent: false,
342 values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
343 formatValue: this.formatSpecValue,
344 parseValue: this.parseSpecValue
346 spec.size = Math.max.apply(
347 // eslint-disable-next-line no-jquery/no-map-util
348 null, $.map( spec.values, ( v ) => v.length )
360 * Format a value for a field specification.
362 * 'this' must be the field specification object. The intention is that you
363 * could just assign this function as the 'formatValue' for each field spec.
365 * Besides the publicly-documented fields, uses the following:
366 * - values: Enumerated values for the field
367 * - zeropad: Whether to pad the number with zeros.
373 mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
374 if ( v === undefined || v === null ) {
378 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
383 return this.values[ v ];
387 if ( this.zeropad ) {
388 while ( v.length < this.size ) {
396 * Parse a value for a field specification.
398 * 'this' must be the field specification object. The intention is that you
399 * could just assign this function as the 'parseValue' for each field spec.
401 * Besides the publicly-documented fields, uses the following:
402 * - values: Enumerated values for the field
406 * @return {number|string|null}
408 mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
415 if ( !this.values ) {
417 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
418 return isNaN( v ) ? undefined : !!v;
420 return isNaN( v ) ? undefined : v;
428 const re = new RegExp( '^\\s*' + mw.util.escapeRegExp( v ), 'i' );
429 for ( k in this.values ) {
431 if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
432 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
443 * Get components from a Date object.
445 * Most specific components are defined by the subclass. "Global" components
447 * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
448 * - zone: {number} Timezone offset in minutes.
451 * @param {Date|null} date
452 * @return {Object} Components
454 mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
455 // Should be overridden by subclass
457 zone: this.local ? date.getTimezoneOffset() : 0
462 * Get a Date object from components.
464 * @param {Object} components Date components
467 mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
468 // Should be overridden by subclass
475 * @param {Date|null} date To be adjusted
476 * @param {string} component To adjust
477 * @param {number} delta Adjustment amount
478 * @param {string} mode Adjustment mode:
479 * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
480 * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
481 * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
482 * @return {Date} Adjusted date
484 mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
485 // Should be overridden by subclass
490 * Get the column headings (weekday abbreviations) for a calendar grid.
492 * Null-valued columns are hidden if getCalendarData() returns no "day" object
493 * for all days in that column.
496 * @return {Array} string or null
498 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
499 // Should be overridden by subclass
504 * Test whether two dates are in the same calendar grid.
507 * @param {Date} date1
508 * @param {Date} date2
511 mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
512 // Should be overridden by subclass
513 return date1.getTime() === date2.getTime();
517 * Test whether the date parts of two Dates are equal.
519 * @param {Date} date1
520 * @param {Date} date2
523 mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
526 date1.getFullYear() === date2.getFullYear() &&
527 date1.getMonth() === date2.getMonth() &&
528 date1.getDate() === date2.getDate()
532 date1.getUTCFullYear() === date2.getUTCFullYear() &&
533 date1.getUTCMonth() === date2.getUTCMonth() &&
534 date1.getUTCDate() === date2.getUTCDate()
540 * Test whether the time parts of two Dates are equal.
542 * @param {Date} date1
543 * @param {Date} date2
546 mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
549 date1.getHours() === date2.getHours() &&
550 date1.getMinutes() === date2.getMinutes() &&
551 date1.getSeconds() === date2.getSeconds() &&
552 date1.getMilliseconds() === date2.getMilliseconds()
556 date1.getUTCHours() === date2.getUTCHours() &&
557 date1.getUTCMinutes() === date2.getUTCMinutes() &&
558 date1.getUTCSeconds() === date2.getUTCSeconds() &&
559 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
565 * Test whether toggleLocal() changes the date part.
570 mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
572 date.getUTCFullYear() !== date.getFullYear() ||
573 date.getUTCMonth() !== date.getMonth() ||
574 date.getUTCDate() !== date.getDate()
579 * Create a new Date by merging the date part from one with the time part from
582 * @param {Date} datepart
583 * @param {Date} timepart
586 mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
587 const ret = new Date( datepart.getTime() );
592 timepart.getMinutes(),
593 timepart.getSeconds(),
594 timepart.getMilliseconds()
598 timepart.getUTCHours(),
599 timepart.getUTCMinutes(),
600 timepart.getUTCSeconds(),
601 timepart.getUTCMilliseconds()
609 * @typedef {Object} mw.widgets.datetime.DateTimeFormatter~CalendarGridData
610 * @property {string} header String to display as the calendar header
611 * @property {string} monthComponent Component to adjust by ±1 to change months.
612 * @property {string} dayComponent Component to adjust by ±1 to change days.
613 * @property {string} [weekComponent] Component to adjust by ±1 to change
614 * weeks. If omitted, the dayComponent should be adjusted by ±the number of
615 * non-nullable columns returned by this.getCalendarHeadings() to change weeks.
616 * @property {Array} rows Array of arrays of "day" objects or null/undefined.
620 * Get data for a calendar grid.
623 * - display: {string} Display text for the day.
624 * - date: {Date} Date to use when the day is selected.
625 * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
626 * at the start and end of the month.
628 * In any one result object, 'extra' + 'display' will always be unique.
631 * @param {Date|null} current Current date
632 * @return {CalendarGridData} Data
634 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
635 // Should be overridden by subclass
638 monthComponent: 'month',