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