Avoid pointless use of isset() in LBFactoryMulti()
[mediawiki.git] / resources / src / mediawiki.widgets.datetime / DateTimeFormatter.js
blob1793849368ef342e56c58ae309a9e677a3d9a8b7
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 {number} return.size Maximum number of characters in the field (when
248          *  the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
249          * @return {Object.<string,number>} return.intercalarySize Map from
250          *  'intercalary' component values to overridden sizes.
251          * @return {string} return.value For type='static', the string to display.
252          * @return {function(Mixed): string} return.formatValue A function to format a
253          *  component value as a display string.
254          * @return {function(string): Mixed} return.parseValue A function to parse a
255          *  display string into a component value. If parsing fails, returns undefined.
256          */
257         mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
258                 var c, spec = null;
260                 switch ( tag ) {
261                         case 'intercalary':
262                         case 'not-intercalary':
263                                 if ( params.length < 2 || !params[ 0 ] ) {
264                                         return null;
265                                 }
266                                 spec = {
267                                         component: null,
268                                         editable: false,
269                                         type: 'static',
270                                         value: params.slice( 1 ).join( '|' ),
271                                         size: 0,
272                                         intercalarySize: {}
273                                 };
274                                 if ( tag === 'intercalary' ) {
275                                         spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
276                                 } else {
277                                         spec.size = spec.value.length;
278                                         spec.intercalarySize[ params[ 0 ] ] = 0;
279                                 }
280                                 return spec;
282                         case 'zone':
283                                 switch ( params[ 0 ] ) {
284                                         case '#':
285                                         case ':':
286                                                 c = params[ 0 ] === '#' ? '' : ':';
287                                                 return {
288                                                         component: 'zone',
289                                                         editable: true,
290                                                         type: 'toggleLocal',
291                                                         size: 5 + c.length,
292                                                         formatValue: function ( v ) {
293                                                                 var o, r;
294                                                                 if ( v ) {
295                                                                         o = new Date().getTimezoneOffset();
296                                                                         r = String( Math.abs( o ) % 60 );
297                                                                         while ( r.length < 2 ) {
298                                                                                 r = '0' + r;
299                                                                         }
300                                                                         r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
301                                                                         while ( r.length < 4 + c.length ) {
302                                                                                 r = '0' + r;
303                                                                         }
304                                                                         return ( o <= 0 ? '+' : '−' ) + r;
305                                                                 } else {
306                                                                         return '+00' + c + '00';
307                                                                 }
308                                                         },
309                                                         parseValue: function ( v ) {
310                                                                 var m;
311                                                                 v = String( v ).trim();
312                                                                 if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
313                                                                         return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
314                                                                 } else {
315                                                                         return undefined;
316                                                                 }
317                                                         }
318                                                 };
320                                         case 'short':
321                                         case 'full':
322                                                 spec = {
323                                                         component: 'zone',
324                                                         editable: true,
325                                                         type: 'toggleLocal',
326                                                         values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
327                                                         formatValue: this.formatSpecValue,
328                                                         parseValue: this.parseSpecValue
329                                                 };
330                                                 spec.size = Math.max.apply(
331                                                         null, $.map( spec.values, function ( v ) { return v.length; } )
332                                                 );
333                                                 return spec;
334                                 }
335                                 return null;
337                         default:
338                                 return null;
339                 }
340         };
342         /**
343          * Format a value for a field specification
344          *
345          * 'this' must be the field specification object. The intention is that you
346          * could just assign this function as the 'formatValue' for each field spec.
347          *
348          * Besides the publicly-documented fields, uses the following:
349          * - values: Enumerated values for the field
350          * - zeropad: Whether to pad the number with zeros.
351          *
352          * @protected
353          * @param {Mixed} v
354          * @return {string}
355          */
356         mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
357                 if ( v === undefined || v === null ) {
358                         return '';
359                 }
361                 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
362                         v = v ? 1 : 0;
363                 }
365                 if ( this.values ) {
366                         return this.values[ v ];
367                 }
369                 v = String( v );
370                 if ( this.zeropad ) {
371                         while ( v.length < this.size ) {
372                                 v = '0' + v;
373                         }
374                 }
375                 return v;
376         };
378         /**
379          * Parse a value for a field specification
380          *
381          * 'this' must be the field specification object. The intention is that you
382          * could just assign this function as the 'parseValue' for each field spec.
383          *
384          * Besides the publicly-documented fields, uses the following:
385          * - values: Enumerated values for the field
386          *
387          * @protected
388          * @param {string} v
389          * @return {number|string|null}
390          */
391         mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
392                 var k, re;
394                 if ( v === '' ) {
395                         return null;
396                 }
398                 if ( !this.values ) {
399                         v = +v;
400                         if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
401                                 return isNaN( v ) ? undefined : !!v;
402                         } else {
403                                 return isNaN( v ) ? undefined : v;
404                         }
405                 }
407                 if ( v.normalize ) {
408                         v = v.normalize();
409                 }
410                 re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' );
411                 for ( k in this.values ) {
412                         k = +k;
413                         if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
414                                 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
415                                         return !!k;
416                                 } else {
417                                         return k;
418                                 }
419                         }
420                 }
421                 return undefined;
422         };
424         /**
425          * Get components from a Date object
426          *
427          * Most specific components are defined by the subclass. "Global" components
428          * are:
429          * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
430          * - zone: {number} Timezone offset in minutes.
431          *
432          * @abstract
433          * @param {Date|null} date
434          * @return {Object} Components
435          */
436         mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
437                 // Should be overridden by subclass
438                 return {
439                         zone: this.local ? date.getTimezoneOffset() : 0
440                 };
441         };
443         /**
444          * Get a Date object from components
445          *
446          * @param {Object} components Date components
447          * @return {Date}
448          */
449         mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
450                 // Should be overridden by subclass
451                 return new Date();
452         };
454         /**
455          * Adjust a date
456          *
457          * @param {Date|null} date To be adjusted
458          * @param {string} component To adjust
459          * @param {number} delta Adjustment amount
460          * @param {string} mode Adjustment mode:
461          *  - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
462          *  - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
463          *  - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
464          * @return {Date} Adjusted date
465          */
466         mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
467                 // Should be overridden by subclass
468                 return date;
469         };
471         /**
472          * Get the column headings (weekday abbreviations) for a calendar grid
473          *
474          * Null-valued columns are hidden if getCalendarData() returns no "day" object
475          * for all days in that column.
476          *
477          * @abstract
478          * @return {Array} string or null
479          */
480         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
481                 // Should be overridden by subclass
482                 return [];
483         };
485         /**
486          * Test whether two dates are in the same calendar grid
487          *
488          * @abstract
489          * @param {Date} date1
490          * @param {Date} date2
491          * @return {boolean}
492          */
493         mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
494                 // Should be overridden by subclass
495                 return date1.getTime() === date2.getTime();
496         };
498         /**
499          * Test whether the date parts of two Dates are equal
500          *
501          * @param {Date} date1
502          * @param {Date} date2
503          * @return {boolean}
504          */
505         mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
506                 if ( this.local ) {
507                         return (
508                                 date1.getFullYear() === date2.getFullYear() &&
509                                 date1.getMonth() === date2.getMonth() &&
510                                 date1.getDate() === date2.getDate()
511                         );
512                 } else {
513                         return (
514                                 date1.getUTCFullYear() === date2.getUTCFullYear() &&
515                                 date1.getUTCMonth() === date2.getUTCMonth() &&
516                                 date1.getUTCDate() === date2.getUTCDate()
517                         );
518                 }
519         };
521         /**
522          * Test whether the time parts of two Dates are equal
523          *
524          * @param {Date} date1
525          * @param {Date} date2
526          * @return {boolean}
527          */
528         mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
529                 if ( this.local ) {
530                         return (
531                                 date1.getHours() === date2.getHours() &&
532                                 date1.getMinutes() === date2.getMinutes() &&
533                                 date1.getSeconds() === date2.getSeconds() &&
534                                 date1.getMilliseconds() === date2.getMilliseconds()
535                         );
536                 } else {
537                         return (
538                                 date1.getUTCHours() === date2.getUTCHours() &&
539                                 date1.getUTCMinutes() === date2.getUTCMinutes() &&
540                                 date1.getUTCSeconds() === date2.getUTCSeconds() &&
541                                 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
542                         );
543                 }
544         };
546         /**
547          * Test whether toggleLocal() changes the date part
548          *
549          * @param {Date} date
550          * @return {boolean}
551          */
552         mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
553                 return (
554                         date.getUTCFullYear() !== date.getFullYear() ||
555                         date.getUTCMonth() !== date.getMonth() ||
556                         date.getUTCDate() !== date.getDate()
557                 );
558         };
560         /**
561          * Create a new Date by merging the date part from one with the time part from
562          * another.
563          *
564          * @param {Date} datepart
565          * @param {Date} timepart
566          * @return {Date}
567          */
568         mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
569                 var ret = new Date( datepart.getTime() );
571                 if ( this.local ) {
572                         ret.setHours(
573                                 timepart.getHours(),
574                                 timepart.getMinutes(),
575                                 timepart.getSeconds(),
576                                 timepart.getMilliseconds()
577                         );
578                 } else {
579                         ret.setUTCHours(
580                                 timepart.getUTCHours(),
581                                 timepart.getUTCMinutes(),
582                                 timepart.getUTCSeconds(),
583                                 timepart.getUTCMilliseconds()
584                         );
585                 }
587                 return ret;
588         };
590         /**
591          * Get data for a calendar grid
592          *
593          * A "day" object is:
594          * - display: {string} Display text for the day.
595          * - date: {Date} Date to use when the day is selected.
596          * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
597          *   at the start and end of the month.
598          *
599          * In any one result object, 'extra' + 'display' will always be unique.
600          *
601          * @abstract
602          * @param {Date|null} current Current date
603          * @return {Object} Data
604          * @return {string} return.header String to display as the calendar header
605          * @return {string} return.monthComponent Component to adjust by ±1 to change months.
606          * @return {string} return.dayComponent Component to adjust by ±1 to change days.
607          * @return {string} [return.weekComponent] Component to adjust by ±1 to change
608          *   weeks. If omitted, the dayComponent should be adjusted by ±the number of
609          *   non-nullable columns returned by this.getCalendarHeadings() to change weeks.
610          * @return {Array} return.rows Array of arrays of "day" objects or null/undefined.
611          */
612         mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
613                 // Should be overridden by subclass
614                 return {
615                         header: '',
616                         monthComponent: 'month',
617                         dayComponent: 'day',
618                         rows: []
619                 };
620         };
622 }( jQuery, mediaWiki ) );