Merge "Added release notes for 'ContentHandler::runLegacyHooks' removal"
[mediawiki.git] / resources / src / mediawiki.widgets.datetime / DateTimeFormatter.js
blobad49a427480a3c04ac763c419b464f1a14f0fcf4
1 ( function ( $, mw ) {
3         /**
4          * Provides various methods needed for formatting dates and times.
5          *
6          * @class
7          * @abstract
8          * @mixins OO.EventEmitter
9          *
10          * @constructor
11          * @param {Object} [config] Configuration options
12          * @cfg {string} [format='@default'] May be a key from the {@link #static-formats static formats},
13          *  or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec}
14          *  and {@link #method-getFieldForTag getFieldForTag}.
15          * @cfg {boolean} [local=false] Whether dates are local time or UTC
16          * @cfg {string[]} [fullZones] Time zone indicators. Array of 2 strings, for
17          *  UTC and local time.
18          * @cfg {string[]} [shortZones] Abbreviated time zone indicators. Array of 2
19          *  strings, for UTC and local time.
20          * @cfg {Date} [defaultDate] Default date, for filling unspecified components.
21          *  Defaults to the current date and time (with 0 milliseconds).
22          */
23         mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) {
24                 this.constructor.static.setupDefaults();
26                 config = $.extend( {
27                         format: '@default',
28                         local: false,
29                         fullZones: this.constructor.static.fullZones,
30                         shortZones: this.constructor.static.shortZones
31                 }, config );
33                 // Mixin constructors
34                 OO.EventEmitter.call( this );
36                 // Properties
37                 if ( this.constructor.static.formats[ config.format ] ) {
38                         this.format = this.constructor.static.formats[ config.format ];
39                 } else {
40                         this.format = config.format;
41                 }
42                 this.local = !!config.local;
43                 this.fullZones = config.fullZones;
44                 this.shortZones = config.shortZones;
45                 if ( config.defaultDate instanceof Date ) {
46                         this.defaultDate = config.defaultDate;
47                 } else {
48                         this.defaultDate = new Date();
49                         if ( this.local ) {
50                                 this.defaultDate.setMilliseconds( 0 );
51                         } else {
52                                 this.defaultDate.setUTCMilliseconds( 0 );
53                         }
54                 }
55         };
57         /* Setup */
59         OO.initClass( mw.widgets.datetime.DateTimeFormatter );
60         OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );
62         /* Static */
64         /**
65          * Default format specifications. See the {@link #format format} parameter.
66          *
67          * @static
68          * @inheritable
69          * @property {Object}
70          */
71         mw.widgets.datetime.DateTimeFormatter.static.formats = {};
73         /**
74          * Default time zone indicators
75          *
76          * @static
77          * @inheritable
78          * @property {string[]}
79          */
80         mw.widgets.datetime.DateTimeFormatter.static.fullZones = null;
82         /**
83          * Default abbreviated time zone indicators
84          *
85          * @static
86          * @inheritable
87          * @property {string[]}
88          */
89         mw.widgets.datetime.DateTimeFormatter.static.shortZones = null;
91         mw.widgets.datetime.DateTimeFormatter.static.setupDefaults = function () {
92                 if ( !this.fullZones ) {
93                         this.fullZones = [
94                                 mw.msg( 'timezone-utc' ),
95                                 mw.msg( 'timezone-local' )
96                         ];
97                 }
98                 if ( !this.shortZones ) {
99                         this.shortZones = [
100                                 'Z',
101                                 this.fullZones[ 1 ].substr( 0, 1 ).toUpperCase()
102                         ];
103                         if ( this.shortZones[ 1 ] === 'Z' ) {
104                                 this.shortZones[ 1 ] = 'L';
105                         }
106                 }
107         };
109         /* Events */
111         /**
112          * A `local` event is emitted when the 'local' flag is changed.
113          *
114          * @event local
115          */
117         /* Methods */
119         /**
120          * Whether dates are in local time or UTC
121          *
122          * @return {boolean} True if local time
123          */
124         mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
125                 return this.local;
126         };
128         // eslint-disable-next-line valid-jsdoc
129         /**
130          * Toggle whether dates are in local time or UTC
131          *
132          * @param {boolean} [flag] Set the flag instead of toggling it
133          * @fires local
134          * @chainable
135          */
136         mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) {
137                 if ( flag === undefined ) {
138                         flag = !this.local;
139                 } else {
140                         flag = !!flag;
141                 }
142                 if ( this.local !== flag ) {
143                         this.local = flag;
144                         this.emit( 'local', this.local );
145                 }
146                 return this;
147         };
149         /**
150          * Get the default date
151          *
152          * @return {Date}
153          */
154         mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () {
155                 return new Date( this.defaultDate.getTime() );
156         };
158         /**
159          * Fetch the field specification array for this object.
160          *
161          * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure.
162          *
163          * @return {Array}
164          */
165         mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () {
166                 return this.parseFieldSpec( this.format );
167         };
169         /**
170          * Parse a format string into a field specification
171          *
172          * The input is a string containing tags formatted as ${tag|param|param...}
173          * (for editable fields) and $!{tag|param|param...} (for non-editable fields).
174          * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few
175          * are defined here:
176          * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
177          *   component is X.
178          * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
179          *   component is X.
180          *
181          * Elements of the returned array are strings or objects. Strings are meant to
182          * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}.
183          *
184          * @protected
185          * @param {string} format
186          * @return {Array}
187          */
188         mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) {
189                 var m, last, tag, params, spec,
190                         ret = [],
191                         re = /(.*?)(\$(!?)\{([^}]+)\})/g;
193                 last = 0;
194                 while ( ( m = re.exec( format ) ) !== null ) {
195                         last = re.lastIndex;
197                         if ( m[ 1 ] !== '' ) {
198                                 ret.push( m[ 1 ] );
199                         }
201                         params = m[ 4 ].split( '|' );
202                         tag = params.shift();
203                         spec = this.getFieldForTag( tag, params );
204                         if ( spec ) {
205                                 if ( m[ 3 ] === '!' ) {
206                                         spec.editable = false;
207                                 }
208                                 ret.push( spec );
209                         } else {
210                                 ret.push( m[ 2 ] );
211                         }
212                 }
213                 if ( last < format.length ) {
214                         ret.push( format.substr( last ) );
215                 }
217                 return ret;
218         };
220         /**
221          * Turn a tag into a field specification object
222          *
223          * Fields implemented here are:
224          * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
225          *   component is X.
226          * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
227          *   component is X.
228          * - ${zone|#}: Timezone offset, "+0000" format.
229          * - ${zone|:}: Timezone offset, "+00:00" format.
230          * - ${zone|short}: Timezone from 'shortZones' configuration setting.
231          * - ${zone|full}: Timezone from 'fullZones' configuration setting.
232          *
233          * @protected
234          * @abstract
235          * @param {string} tag
236          * @param {string[]} params
237          * @return {Object|null} Field specification object, or null if the tag+params are unrecognized.
238          * @return {string|null} return.component Date component corresponding to this field, if any.
239          * @return {boolean} return.editable Whether this field is editable.
240          * @return {string} return.type What kind of field this is:
241          *  - 'static': The field is a static string; component will be null.
242          *  - 'number': The field is generally numeric.
243          *  - 'string': The field is generally textual.
244          *  - 'boolean': The field is a boolean.
245          *  - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}.
246          *    Editing should directly call {@link #toggleLocal this.toggleLocal()}.
247          * @return {boolean} return.calendarComponent Whether this field is part of a calendar, e.g.
248          *  part of the date instead of the time.
249          * @return {number} return.size Maximum number of characters in the field (when
250          *  the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
251          * @return {Object.<string,number>} return.intercalarySize Map from
252          *  'intercalary' component values to overridden sizes.
253          * @return {string} return.value For type='static', the string to display.
254          * @return {function(Mixed): string} return.formatValue A function to format a
255          *  component value as a display string.
256          * @return {function(string): Mixed} return.parseValue A function to parse a
257          *  display string into a component value. If parsing fails, returns undefined.
258          */
259         mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
260                 var c, spec = null;
262                 switch ( tag ) {
263                         case 'intercalary':
264                         case 'not-intercalary':
265                                 if ( params.length < 2 || !params[ 0 ] ) {
266                                         return null;
267                                 }
268                                 spec = {
269                                         component: null,
270                                         calendarComponent: false,
271                                         editable: false,
272                                         type: 'static',
273                                         value: params.slice( 1 ).join( '|' ),
274                                         size: 0,
275                                         intercalarySize: {}
276                                 };
277                                 if ( tag === 'intercalary' ) {
278                                         spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
279                                 } else {
280                                         spec.size = spec.value.length;
281                                         spec.intercalarySize[ params[ 0 ] ] = 0;
282                                 }
283                                 return spec;
285                         case 'zone':
286                                 switch ( params[ 0 ] ) {
287                                         case '#':
288                                         case ':':
289                                                 c = params[ 0 ] === '#' ? '' : ':';
290                                                 return {
291                                                         component: 'zone',
292                                                         calendarComponent: false,
293                                                         editable: true,
294                                                         type: 'toggleLocal',
295                                                         size: 5 + c.length,
296                                                         formatValue: function ( v ) {
297                                                                 var o, r;
298                                                                 if ( v ) {
299                                                                         o = new Date().getTimezoneOffset();
300                                                                         r = String( Math.abs( o ) % 60 );
301                                                                         while ( r.length < 2 ) {
302                                                                                 r = '0' + r;
303                                                                         }
304                                                                         r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
305                                                                         while ( r.length < 4 + c.length ) {
306                                                                                 r = '0' + r;
307                                                                         }
308                                                                         return ( o <= 0 ? '+' : '−' ) + r;
309                                                                 } else {
310                                                                         return '+00' + c + '00';
311                                                                 }
312                                                         },
313                                                         parseValue: function ( v ) {
314                                                                 var m;
315                                                                 v = String( v ).trim();
316                                                                 if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
317                                                                         return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
318                                                                 } else {
319                                                                         return undefined;
320                                                                 }
321                                                         }
322                                                 };
324                                         case 'short':
325                                         case 'full':
326                                                 spec = {
327                                                         component: 'zone',
328                                                         calendarComponent: false,
329                                                         editable: true,
330                                                         type: 'toggleLocal',
331                                                         values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
332                                                         formatValue: this.formatSpecValue,
333                                                         parseValue: this.parseSpecValue
334                                                 };
335                                                 spec.size = Math.max.apply(
336                                                         null, $.map( spec.values, function ( v ) { return v.length; } )
337                                                 );
338                                                 return spec;
339                                 }
340                                 return null;
342                         default:
343                                 return null;
344                 }
345         };
347         /**
348          * Format a value for a field specification
349          *
350          * 'this' must be the field specification object. The intention is that you
351          * could just assign this function as the 'formatValue' for each field spec.
352          *
353          * Besides the publicly-documented fields, uses the following:
354          * - values: Enumerated values for the field
355          * - zeropad: Whether to pad the number with zeros.
356          *
357          * @protected
358          * @param {Mixed} v
359          * @return {string}
360          */
361         mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
362                 if ( v === undefined || v === null ) {
363                         return '';
364                 }
366                 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
367                         v = v ? 1 : 0;
368                 }
370                 if ( this.values ) {
371                         return this.values[ v ];
372                 }
374                 v = String( v );
375                 if ( this.zeropad ) {
376                         while ( v.length < this.size ) {
377                                 v = '0' + v;
378                         }
379                 }
380                 return v;
381         };
383         /**
384          * Parse a value for a field specification
385          *
386          * 'this' must be the field specification object. The intention is that you
387          * could just assign this function as the 'parseValue' for each field spec.
388          *
389          * Besides the publicly-documented fields, uses the following:
390          * - values: Enumerated values for the field
391          *
392          * @protected
393          * @param {string} v
394          * @return {number|string|null}
395          */
396         mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
397                 var k, re;
399                 if ( v === '' ) {
400                         return null;
401                 }
403                 if ( !this.values ) {
404                         v = +v;
405                         if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
406                                 return isNaN( v ) ? undefined : !!v;
407                         } else {
408                                 return isNaN( v ) ? undefined : v;
409                         }
410                 }
412                 if ( v.normalize ) {
413                         v = v.normalize();
414                 }
415                 re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' );
416                 for ( k in this.values ) {
417                         k = +k;
418                         if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
419                                 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
420                                         return !!k;
421                                 } else {
422                                         return k;
423                                 }
424                         }
425                 }
426                 return undefined;
427         };
429         /**
430          * Get components from a Date object
431          *
432          * Most specific components are defined by the subclass. "Global" components
433          * are:
434          * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
435          * - zone: {number} Timezone offset in minutes.
436          *
437          * @abstract
438          * @param {Date|null} date
439          * @return {Object} Components
440          */
441         mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
442                 // Should be overridden by subclass
443                 return {
444                         zone: this.local ? date.getTimezoneOffset() : 0
445                 };
446         };
448         /**
449          * Get a Date object from components
450          *
451          * @param {Object} components Date components
452          * @return {Date}
453          */
454         mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
455                 // Should be overridden by subclass
456                 return new Date();
457         };
459         /**
460          * Adjust a date
461          *
462          * @param {Date|null} date To be adjusted
463          * @param {string} component To adjust
464          * @param {number} delta Adjustment amount
465          * @param {string} mode Adjustment mode:
466          *  - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
467          *  - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
468          *  - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
469          * @return {Date} Adjusted date
470          */
471         mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
472                 // Should be overridden by subclass
473                 return date;
474         };
476         /**
477          * Get the column headings (weekday abbreviations) for a calendar grid
478          *
479          * Null-valued columns are hidden if getCalendarData() returns no "day" object
480          * for all days in that column.
481          *
482          * @abstract
483          * @return {Array} string or null
484          */
485         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
486                 // Should be overridden by subclass
487                 return [];
488         };
490         /**
491          * Test whether two dates are in the same calendar grid
492          *
493          * @abstract
494          * @param {Date} date1
495          * @param {Date} date2
496          * @return {boolean}
497          */
498         mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
499                 // Should be overridden by subclass
500                 return date1.getTime() === date2.getTime();
501         };
503         /**
504          * Test whether the date parts of two Dates are equal
505          *
506          * @param {Date} date1
507          * @param {Date} date2
508          * @return {boolean}
509          */
510         mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
511                 if ( this.local ) {
512                         return (
513                                 date1.getFullYear() === date2.getFullYear() &&
514                                 date1.getMonth() === date2.getMonth() &&
515                                 date1.getDate() === date2.getDate()
516                         );
517                 } else {
518                         return (
519                                 date1.getUTCFullYear() === date2.getUTCFullYear() &&
520                                 date1.getUTCMonth() === date2.getUTCMonth() &&
521                                 date1.getUTCDate() === date2.getUTCDate()
522                         );
523                 }
524         };
526         /**
527          * Test whether the time parts of two Dates are equal
528          *
529          * @param {Date} date1
530          * @param {Date} date2
531          * @return {boolean}
532          */
533         mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
534                 if ( this.local ) {
535                         return (
536                                 date1.getHours() === date2.getHours() &&
537                                 date1.getMinutes() === date2.getMinutes() &&
538                                 date1.getSeconds() === date2.getSeconds() &&
539                                 date1.getMilliseconds() === date2.getMilliseconds()
540                         );
541                 } else {
542                         return (
543                                 date1.getUTCHours() === date2.getUTCHours() &&
544                                 date1.getUTCMinutes() === date2.getUTCMinutes() &&
545                                 date1.getUTCSeconds() === date2.getUTCSeconds() &&
546                                 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
547                         );
548                 }
549         };
551         /**
552          * Test whether toggleLocal() changes the date part
553          *
554          * @param {Date} date
555          * @return {boolean}
556          */
557         mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
558                 return (
559                         date.getUTCFullYear() !== date.getFullYear() ||
560                         date.getUTCMonth() !== date.getMonth() ||
561                         date.getUTCDate() !== date.getDate()
562                 );
563         };
565         /**
566          * Create a new Date by merging the date part from one with the time part from
567          * another.
568          *
569          * @param {Date} datepart
570          * @param {Date} timepart
571          * @return {Date}
572          */
573         mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
574                 var ret = new Date( datepart.getTime() );
576                 if ( this.local ) {
577                         ret.setHours(
578                                 timepart.getHours(),
579                                 timepart.getMinutes(),
580                                 timepart.getSeconds(),
581                                 timepart.getMilliseconds()
582                         );
583                 } else {
584                         ret.setUTCHours(
585                                 timepart.getUTCHours(),
586                                 timepart.getUTCMinutes(),
587                                 timepart.getUTCSeconds(),
588                                 timepart.getUTCMilliseconds()
589                         );
590                 }
592                 return ret;
593         };
595         /**
596          * Get data for a calendar grid
597          *
598          * A "day" object is:
599          * - display: {string} Display text for the day.
600          * - date: {Date} Date to use when the day is selected.
601          * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
602          *   at the start and end of the month.
603          *
604          * In any one result object, 'extra' + 'display' will always be unique.
605          *
606          * @abstract
607          * @param {Date|null} current Current date
608          * @return {Object} Data
609          * @return {string} return.header String to display as the calendar header
610          * @return {string} return.monthComponent Component to adjust by ±1 to change months.
611          * @return {string} return.dayComponent Component to adjust by ±1 to change days.
612          * @return {string} [return.weekComponent] Component to adjust by ±1 to change
613          *   weeks. If omitted, the dayComponent should be adjusted by ±the number of
614          *   non-nullable columns returned by this.getCalendarHeadings() to change weeks.
615          * @return {Array} return.rows Array of arrays of "day" objects or null/undefined.
616          */
617         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
618                 // Should be overridden by subclass
619                 return {
620                         header: '',
621                         monthComponent: 'month',
622                         dayComponent: 'day',
623                         rows: []
624                 };
625         };
627 }( jQuery, mediaWiki ) );