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