Merge "ApiQueryInfo: fix query limits for testactions"
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets.datetime / DateTimeFormatter.js
1 /* eslint-disable no-restricted-properties */
2 ( function ( $, mw ) {
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 null, $.map( spec.values, function ( v ) { return v.length; } )
338 );
339 return spec;
340 }
341 return null;
342
343 default:
344 return null;
345 }
346 };
347
348 /**
349 * Format a value for a field specification
350 *
351 * 'this' must be the field specification object. The intention is that you
352 * could just assign this function as the 'formatValue' for each field spec.
353 *
354 * Besides the publicly-documented fields, uses the following:
355 * - values: Enumerated values for the field
356 * - zeropad: Whether to pad the number with zeros.
357 *
358 * @protected
359 * @param {Mixed} v
360 * @return {string}
361 */
362 mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
363 if ( v === undefined || v === null ) {
364 return '';
365 }
366
367 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
368 v = v ? 1 : 0;
369 }
370
371 if ( this.values ) {
372 return this.values[ v ];
373 }
374
375 v = String( v );
376 if ( this.zeropad ) {
377 while ( v.length < this.size ) {
378 v = '0' + v;
379 }
380 }
381 return v;
382 };
383
384 /**
385 * Parse a value for a field specification
386 *
387 * 'this' must be the field specification object. The intention is that you
388 * could just assign this function as the 'parseValue' for each field spec.
389 *
390 * Besides the publicly-documented fields, uses the following:
391 * - values: Enumerated values for the field
392 *
393 * @protected
394 * @param {string} v
395 * @return {number|string|null}
396 */
397 mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
398 var k, re;
399
400 if ( v === '' ) {
401 return null;
402 }
403
404 if ( !this.values ) {
405 v = +v;
406 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
407 return isNaN( v ) ? undefined : !!v;
408 } else {
409 return isNaN( v ) ? undefined : v;
410 }
411 }
412
413 if ( v.normalize ) {
414 v = v.normalize();
415 }
416 re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-^$\[\]])/g, '\\$1' ), 'i' ); // eslint-disable-line no-useless-escape
417 for ( k in this.values ) {
418 k = +k;
419 if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
420 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
421 return !!k;
422 } else {
423 return k;
424 }
425 }
426 }
427 return undefined;
428 };
429
430 /**
431 * Get components from a Date object
432 *
433 * Most specific components are defined by the subclass. "Global" components
434 * are:
435 * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
436 * - zone: {number} Timezone offset in minutes.
437 *
438 * @abstract
439 * @param {Date|null} date
440 * @return {Object} Components
441 */
442 mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
443 // Should be overridden by subclass
444 return {
445 zone: this.local ? date.getTimezoneOffset() : 0
446 };
447 };
448
449 /**
450 * Get a Date object from components
451 *
452 * @param {Object} components Date components
453 * @return {Date}
454 */
455 mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
456 // Should be overridden by subclass
457 return new Date();
458 };
459
460 /**
461 * Adjust a date
462 *
463 * @param {Date|null} date To be adjusted
464 * @param {string} component To adjust
465 * @param {number} delta Adjustment amount
466 * @param {string} mode Adjustment mode:
467 * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
468 * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
469 * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
470 * @return {Date} Adjusted date
471 */
472 mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /* , component, delta, mode */ ) {
473 // Should be overridden by subclass
474 return date;
475 };
476
477 /**
478 * Get the column headings (weekday abbreviations) for a calendar grid
479 *
480 * Null-valued columns are hidden if getCalendarData() returns no "day" object
481 * for all days in that column.
482 *
483 * @abstract
484 * @return {Array} string or null
485 */
486 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
487 // Should be overridden by subclass
488 return [];
489 };
490
491 /**
492 * Test whether two dates are in the same calendar grid
493 *
494 * @abstract
495 * @param {Date} date1
496 * @param {Date} date2
497 * @return {boolean}
498 */
499 mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
500 // Should be overridden by subclass
501 return date1.getTime() === date2.getTime();
502 };
503
504 /**
505 * Test whether the date parts of two Dates are equal
506 *
507 * @param {Date} date1
508 * @param {Date} date2
509 * @return {boolean}
510 */
511 mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
512 if ( this.local ) {
513 return (
514 date1.getFullYear() === date2.getFullYear() &&
515 date1.getMonth() === date2.getMonth() &&
516 date1.getDate() === date2.getDate()
517 );
518 } else {
519 return (
520 date1.getUTCFullYear() === date2.getUTCFullYear() &&
521 date1.getUTCMonth() === date2.getUTCMonth() &&
522 date1.getUTCDate() === date2.getUTCDate()
523 );
524 }
525 };
526
527 /**
528 * Test whether the time parts of two Dates are equal
529 *
530 * @param {Date} date1
531 * @param {Date} date2
532 * @return {boolean}
533 */
534 mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
535 if ( this.local ) {
536 return (
537 date1.getHours() === date2.getHours() &&
538 date1.getMinutes() === date2.getMinutes() &&
539 date1.getSeconds() === date2.getSeconds() &&
540 date1.getMilliseconds() === date2.getMilliseconds()
541 );
542 } else {
543 return (
544 date1.getUTCHours() === date2.getUTCHours() &&
545 date1.getUTCMinutes() === date2.getUTCMinutes() &&
546 date1.getUTCSeconds() === date2.getUTCSeconds() &&
547 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
548 );
549 }
550 };
551
552 /**
553 * Test whether toggleLocal() changes the date part
554 *
555 * @param {Date} date
556 * @return {boolean}
557 */
558 mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
559 return (
560 date.getUTCFullYear() !== date.getFullYear() ||
561 date.getUTCMonth() !== date.getMonth() ||
562 date.getUTCDate() !== date.getDate()
563 );
564 };
565
566 /**
567 * Create a new Date by merging the date part from one with the time part from
568 * another.
569 *
570 * @param {Date} datepart
571 * @param {Date} timepart
572 * @return {Date}
573 */
574 mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
575 var ret = new Date( datepart.getTime() );
576
577 if ( this.local ) {
578 ret.setHours(
579 timepart.getHours(),
580 timepart.getMinutes(),
581 timepart.getSeconds(),
582 timepart.getMilliseconds()
583 );
584 } else {
585 ret.setUTCHours(
586 timepart.getUTCHours(),
587 timepart.getUTCMinutes(),
588 timepart.getUTCSeconds(),
589 timepart.getUTCMilliseconds()
590 );
591 }
592
593 return ret;
594 };
595
596 /**
597 * Get data for a calendar grid
598 *
599 * A "day" object is:
600 * - display: {string} Display text for the day.
601 * - date: {Date} Date to use when the day is selected.
602 * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
603 * at the start and end of the month.
604 *
605 * In any one result object, 'extra' + 'display' will always be unique.
606 *
607 * @abstract
608 * @param {Date|null} current Current date
609 * @return {Object} Data
610 * @return {string} return.header String to display as the calendar header
611 * @return {string} return.monthComponent Component to adjust by ±1 to change months.
612 * @return {string} return.dayComponent Component to adjust by ±1 to change days.
613 * @return {string} [return.weekComponent] Component to adjust by ±1 to change
614 * weeks. If omitted, the dayComponent should be adjusted by ±the number of
615 * non-nullable columns returned by this.getCalendarHeadings() to change weeks.
616 * @return {Array} return.rows Array of arrays of "day" objects or null/undefined.
617 */
618 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
619 // Should be overridden by subclass
620 return {
621 header: '',
622 monthComponent: 'month',
623 dayComponent: 'day',
624 rows: []
625 };
626 };
627
628 }( jQuery, mediaWiki ) );