Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.widgets.datetime / DateTimeFormatter.js
bloba12037d34819ccd828d617ec39717b8b39c70236
1 ( function () {
3         /**
4          * @class
5          * @classdesc Provides various methods needed for formatting dates and times.
6          * @abstract
7          * @mixes OO.EventEmitter
8          *
9          * @constructor
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
18          *  UTC and local time.
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).
23          */
24         mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) {
25                 this.constructor.static.setupDefaults();
27                 config = Object.assign( {
28                         format: '@default',
29                         local: false,
30                         fullZones: this.constructor.static.fullZones,
31                         shortZones: this.constructor.static.shortZones
32                 }, config );
34                 // Mixin constructors
35                 OO.EventEmitter.call( this );
37                 // Properties
38                 if ( this.constructor.static.formats[ config.format ] ) {
39                         this.format = this.constructor.static.formats[ config.format ];
40                 } else {
41                         this.format = config.format;
42                 }
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;
48                 } else {
49                         this.defaultDate = new Date();
50                         if ( this.local ) {
51                                 this.defaultDate.setMilliseconds( 0 );
52                         } else {
53                                 this.defaultDate.setUTCMilliseconds( 0 );
54                         }
55                 }
56         };
58         /* Setup */
60         OO.initClass( mw.widgets.datetime.DateTimeFormatter );
61         OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );
63         /* Static */
65         /**
66          * Default format specifications. See the {@link #format format} parameter.
67          *
68          * @static
69          * @inheritable
70          * @type {Object}
71          * @name mw.widgets.datetime.DateTimeFormatter.formats
72          */
73         mw.widgets.datetime.DateTimeFormatter.static.formats = {};
75         /**
76          * Default time zone indicators.
77          *
78          * @static
79          * @inheritable
80          * @type {string[]}
81          * @name mw.widgets.datetime.DateTimeFormatter.fullZones
82          */
83         mw.widgets.datetime.DateTimeFormatter.static.fullZones = null;
85         /**
86          * Default abbreviated time zone indicators.
87          *
88          * @static
89          * @inheritable
90          * @type {string[]}
91          * @name mw.widgets.datetime.DateTimeFormatter.shortZones
92          */
93         mw.widgets.datetime.DateTimeFormatter.static.shortZones = null;
95         mw.widgets.datetime.DateTimeFormatter.static.setupDefaults = function () {
96                 if ( !this.fullZones ) {
97                         this.fullZones = [
98                                 mw.msg( 'timezone-utc' ),
99                                 mw.msg( 'timezone-local' )
100                         ];
101                 }
102                 if ( !this.shortZones ) {
103                         this.shortZones = [
104                                 'Z',
105                                 this.fullZones[ 1 ].slice( 0, 1 ).toUpperCase()
106                         ];
107                         if ( this.shortZones[ 1 ] === 'Z' ) {
108                                 this.shortZones[ 1 ] = 'L';
109                         }
110                 }
111         };
113         /* Events */
115         /**
116          * A `local` event is emitted when the 'local' flag is changed.
117          *
118          * @event mw.widgets.datetime.DateTimeFormatter.local
119          * @param {boolean} local Whether dates are local time
120          */
122         /* Methods */
124         /**
125          * Whether dates are in local time or UTC.
126          *
127          * @return {boolean} True if local time
128          */
129         mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
130                 return this.local;
131         };
133         /**
134          * Toggle whether dates are in local time or UTC.
135          *
136          * @param {boolean} [flag] Set the flag instead of toggling it
137          * @fires mw.widgets.datetime.DateTimeFormatter.local
138          * @chainable
139          * @return {mw.widgets.datetime.DateTimeFormatter}
140          */
141         mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) {
142                 if ( flag === undefined ) {
143                         flag = !this.local;
144                 } else {
145                         flag = !!flag;
146                 }
147                 if ( this.local !== flag ) {
148                         this.local = flag;
149                         this.emit( 'local', this.local );
150                 }
151                 return this;
152         };
154         /**
155          * Get the default date.
156          *
157          * @return {Date}
158          */
159         mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () {
160                 return new Date( this.defaultDate.getTime() );
161         };
163         /**
164          * Fetch the field specification array for this object.
165          *
166          * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure.
167          *
168          * @return {Array}
169          */
170         mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () {
171                 return this.parseFieldSpec( this.format );
172         };
174         /**
175          * Parse a format string into a field specification.
176          *
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
180          * are defined here:
181          * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
182          *   component is X.
183          * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
184          *   component is X.
185          *
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}.
188          *
189          * @protected
190          * @param {string} format
191          * @return {Array}
192          */
193         mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) {
194                 let m, last, tag, params, spec;
196                 const
197                         ret = [],
198                         re = /(.*?)(\$(!?)\{([^}]+)\})/g;
200                 last = 0;
201                 while ( ( m = re.exec( format ) ) !== null ) {
202                         last = re.lastIndex;
204                         if ( m[ 1 ] !== '' ) {
205                                 ret.push( m[ 1 ] );
206                         }
208                         params = m[ 4 ].split( '|' );
209                         tag = params.shift();
210                         spec = this.getFieldForTag( tag, params );
211                         if ( spec ) {
212                                 if ( m[ 3 ] === '!' ) {
213                                         spec.editable = false;
214                                 }
215                                 ret.push( spec );
216                         } else {
217                                 ret.push( m[ 2 ] );
218                         }
219                 }
220                 if ( last < format.length ) {
221                         ret.push( format.slice( last ) );
222                 }
224                 return ret;
225         };
227         /**
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.
249          */
251         /**
252          * Turn a tag into a field specification object.
253          *
254          * Fields implemented here are:
255          * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
256          *   component is X.
257          * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
258          *   component is X.
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.
263          *
264          * @protected
265          * @abstract
266          * @param {string} tag
267          * @param {string[]} params
268          * @return {FieldSpecificationObject} Field specification object, or null if the tag+params are unrecognized.
269          */
270         mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
271                 let c, spec = null;
273                 switch ( tag ) {
274                         case 'intercalary':
275                         case 'not-intercalary':
276                                 if ( params.length < 2 || !params[ 0 ] ) {
277                                         return null;
278                                 }
279                                 spec = {
280                                         component: null,
281                                         calendarComponent: false,
282                                         editable: false,
283                                         type: 'static',
284                                         value: params.slice( 1 ).join( '|' ),
285                                         size: 0,
286                                         intercalarySize: {}
287                                 };
288                                 if ( tag === 'intercalary' ) {
289                                         spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
290                                 } else {
291                                         spec.size = spec.value.length;
292                                         spec.intercalarySize[ params[ 0 ] ] = 0;
293                                 }
294                                 return spec;
296                         case 'zone':
297                                 switch ( params[ 0 ] ) {
298                                         case '#':
299                                         case ':':
300                                                 c = params[ 0 ] === '#' ? '' : ':';
301                                                 return {
302                                                         component: 'zone',
303                                                         calendarComponent: false,
304                                                         editable: true,
305                                                         type: 'toggleLocal',
306                                                         size: 5 + c.length,
307                                                         formatValue: function ( v ) {
308                                                                 let o, r;
309                                                                 if ( v ) {
310                                                                         o = new Date().getTimezoneOffset();
311                                                                         r = String( Math.abs( o ) % 60 );
312                                                                         while ( r.length < 2 ) {
313                                                                                 r = '0' + r;
314                                                                         }
315                                                                         r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
316                                                                         while ( r.length < 4 + c.length ) {
317                                                                                 r = '0' + r;
318                                                                         }
319                                                                         return ( o <= 0 ? '+' : '−' ) + r;
320                                                                 } else {
321                                                                         return '+00' + c + '00';
322                                                                 }
323                                                         },
324                                                         parseValue: function ( v ) {
325                                                                 let m;
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 );
329                                                                 } else {
330                                                                         return undefined;
331                                                                 }
332                                                         }
333                                                 };
335                                         case 'short':
336                                         case 'full':
337                                                 spec = {
338                                                         component: 'zone',
339                                                         calendarComponent: false,
340                                                         editable: true,
341                                                         type: 'toggleLocal',
342                                                         values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
343                                                         formatValue: this.formatSpecValue,
344                                                         parseValue: this.parseSpecValue
345                                                 };
346                                                 spec.size = Math.max.apply(
347                                                         // eslint-disable-next-line no-jquery/no-map-util
348                                                         null, $.map( spec.values, ( v ) => v.length )
349                                                 );
350                                                 return spec;
351                                 }
352                                 return null;
354                         default:
355                                 return null;
356                 }
357         };
359         /**
360          * Format a value for a field specification.
361          *
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.
364          *
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.
368          *
369          * @protected
370          * @param {any} v
371          * @return {string}
372          */
373         mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
374                 if ( v === undefined || v === null ) {
375                         return '';
376                 }
378                 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
379                         v = v ? 1 : 0;
380                 }
382                 if ( this.values ) {
383                         return this.values[ v ];
384                 }
386                 v = String( v );
387                 if ( this.zeropad ) {
388                         while ( v.length < this.size ) {
389                                 v = '0' + v;
390                         }
391                 }
392                 return v;
393         };
395         /**
396          * Parse a value for a field specification.
397          *
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.
400          *
401          * Besides the publicly-documented fields, uses the following:
402          * - values: Enumerated values for the field
403          *
404          * @protected
405          * @param {string} v
406          * @return {number|string|null}
407          */
408         mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
409                 let k;
411                 if ( v === '' ) {
412                         return null;
413                 }
415                 if ( !this.values ) {
416                         v = +v;
417                         if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
418                                 return isNaN( v ) ? undefined : !!v;
419                         } else {
420                                 return isNaN( v ) ? undefined : v;
421                         }
422                 }
424                 if ( v.normalize ) {
425                         v = v.normalize();
426                 }
428                 const re = new RegExp( '^\\s*' + mw.util.escapeRegExp( v ), 'i' );
429                 for ( k in this.values ) {
430                         k = +k;
431                         if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
432                                 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
433                                         return !!k;
434                                 } else {
435                                         return k;
436                                 }
437                         }
438                 }
439                 return undefined;
440         };
442         /**
443          * Get components from a Date object.
444          *
445          * Most specific components are defined by the subclass. "Global" components
446          * are:
447          * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
448          * - zone: {number} Timezone offset in minutes.
449          *
450          * @abstract
451          * @param {Date|null} date
452          * @return {Object} Components
453          */
454         mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
455                 // Should be overridden by subclass
456                 return {
457                         zone: this.local ? date.getTimezoneOffset() : 0
458                 };
459         };
461         /**
462          * Get a Date object from components.
463          *
464          * @param {Object} components Date components
465          * @return {Date}
466          */
467         mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
468                 // Should be overridden by subclass
469                 return new Date();
470         };
472         /**
473          * Adjust a date.
474          *
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
483          */
484         mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
485                 // Should be overridden by subclass
486                 return date;
487         };
489         /**
490          * Get the column headings (weekday abbreviations) for a calendar grid.
491          *
492          * Null-valued columns are hidden if getCalendarData() returns no "day" object
493          * for all days in that column.
494          *
495          * @abstract
496          * @return {Array} string or null
497          */
498         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
499                 // Should be overridden by subclass
500                 return [];
501         };
503         /**
504          * Test whether two dates are in the same calendar grid.
505          *
506          * @abstract
507          * @param {Date} date1
508          * @param {Date} date2
509          * @return {boolean}
510          */
511         mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
512                 // Should be overridden by subclass
513                 return date1.getTime() === date2.getTime();
514         };
516         /**
517          * Test whether the date parts of two Dates are equal.
518          *
519          * @param {Date} date1
520          * @param {Date} date2
521          * @return {boolean}
522          */
523         mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
524                 if ( this.local ) {
525                         return (
526                                 date1.getFullYear() === date2.getFullYear() &&
527                                 date1.getMonth() === date2.getMonth() &&
528                                 date1.getDate() === date2.getDate()
529                         );
530                 } else {
531                         return (
532                                 date1.getUTCFullYear() === date2.getUTCFullYear() &&
533                                 date1.getUTCMonth() === date2.getUTCMonth() &&
534                                 date1.getUTCDate() === date2.getUTCDate()
535                         );
536                 }
537         };
539         /**
540          * Test whether the time parts of two Dates are equal.
541          *
542          * @param {Date} date1
543          * @param {Date} date2
544          * @return {boolean}
545          */
546         mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
547                 if ( this.local ) {
548                         return (
549                                 date1.getHours() === date2.getHours() &&
550                                 date1.getMinutes() === date2.getMinutes() &&
551                                 date1.getSeconds() === date2.getSeconds() &&
552                                 date1.getMilliseconds() === date2.getMilliseconds()
553                         );
554                 } else {
555                         return (
556                                 date1.getUTCHours() === date2.getUTCHours() &&
557                                 date1.getUTCMinutes() === date2.getUTCMinutes() &&
558                                 date1.getUTCSeconds() === date2.getUTCSeconds() &&
559                                 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
560                         );
561                 }
562         };
564         /**
565          * Test whether toggleLocal() changes the date part.
566          *
567          * @param {Date} date
568          * @return {boolean}
569          */
570         mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
571                 return (
572                         date.getUTCFullYear() !== date.getFullYear() ||
573                         date.getUTCMonth() !== date.getMonth() ||
574                         date.getUTCDate() !== date.getDate()
575                 );
576         };
578         /**
579          * Create a new Date by merging the date part from one with the time part from
580          * another.
581          *
582          * @param {Date} datepart
583          * @param {Date} timepart
584          * @return {Date}
585          */
586         mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
587                 const ret = new Date( datepart.getTime() );
589                 if ( this.local ) {
590                         ret.setHours(
591                                 timepart.getHours(),
592                                 timepart.getMinutes(),
593                                 timepart.getSeconds(),
594                                 timepart.getMilliseconds()
595                         );
596                 } else {
597                         ret.setUTCHours(
598                                 timepart.getUTCHours(),
599                                 timepart.getUTCMinutes(),
600                                 timepart.getUTCSeconds(),
601                                 timepart.getUTCMilliseconds()
602                         );
603                 }
605                 return ret;
606         };
608         /**
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.
617          */
619         /**
620          * Get data for a calendar grid.
621          *
622          * A "day" object is:
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.
627          *
628          * In any one result object, 'extra' + 'display' will always be unique.
629          *
630          * @abstract
631          * @param {Date|null} current Current date
632          * @return {CalendarGridData} Data
633          */
634         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
635                 // Should be overridden by subclass
636                 return {
637                         header: '',
638                         monthComponent: 'month',
639                         dayComponent: 'day',
640                         rows: []
641                 };
642         };
644 }() );