Merge "Revert "Gallery: Use intrinsic width for gallery to center caption""
[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 var statick = this.constructor[ 'static' ];
25
26 statick.setupDefaults();
27
28 config = $.extend( {
29 format: '@default',
30 local: false,
31 fullZones: statick.fullZones,
32 shortZones: statick.shortZones
33 }, config );
34
35 // Mixin constructors
36 OO.EventEmitter.call( this );
37
38 // Properties
39 if ( statick.formats[ config.format ] ) {
40 this.format = statick.formats[ config.format ];
41 } else {
42 this.format = config.format;
43 }
44 this.local = !!config.local;
45 this.fullZones = config.fullZones;
46 this.shortZones = config.shortZones;
47 if ( config.defaultDate instanceof Date ) {
48 this.defaultDate = config.defaultDate;
49 } else {
50 this.defaultDate = new Date();
51 if ( this.local ) {
52 this.defaultDate.setMilliseconds( 0 );
53 } else {
54 this.defaultDate.setUTCMilliseconds( 0 );
55 }
56 }
57 };
58
59 /* Setup */
60
61 OO.initClass( mw.widgets.datetime.DateTimeFormatter );
62 OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );
63
64 /* Static */
65
66 /**
67 * Default format specifications. See the {@link #format format} parameter.
68 *
69 * @static
70 * @inheritable
71 * @property {Object}
72 */
73 mw.widgets.datetime.DateTimeFormatter[ 'static' ].formats = {};
74
75 /**
76 * Default time zone indicators
77 *
78 * @static
79 * @inheritable
80 * @property {string[]}
81 */
82 mw.widgets.datetime.DateTimeFormatter[ 'static' ].fullZones = null;
83
84 /**
85 * Default abbreviated time zone indicators
86 *
87 * @static
88 * @inheritable
89 * @property {string[]}
90 */
91 mw.widgets.datetime.DateTimeFormatter[ 'static' ].shortZones = null;
92
93 mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults = function () {
94 if ( !this.fullZones ) {
95 this.fullZones = [
96 mw.msg( 'timezone-utc' ),
97 mw.msg( 'timezone-local' )
98 ];
99 }
100 if ( !this.shortZones ) {
101 this.shortZones = [
102 'Z',
103 this.fullZones[ 1 ].substr( 0, 1 ).toUpperCase()
104 ];
105 if ( this.shortZones[ 1 ] === 'Z' ) {
106 this.shortZones[ 1 ] = 'L';
107 }
108 }
109 };
110
111 /* Events */
112
113 /**
114 * A `local` event is emitted when the 'local' flag is changed.
115 *
116 * @event local
117 */
118
119 /* Methods */
120
121 /**
122 * Whether dates are in local time or UTC
123 *
124 * @return {boolean} True if local time
125 */
126 mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
127 return this.local;
128 };
129
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 {number} return.size Maximum number of characters in the field (when
249 * the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
250 * @return {Object.<string,number>} return.intercalarySize Map from
251 * 'intercalary' component values to overridden sizes.
252 * @return {string} return.value For type='static', the string to display.
253 * @return {function(Mixed): string} return.formatValue A function to format a
254 * component value as a display string.
255 * @return {function(string): Mixed} return.parseValue A function to parse a
256 * display string into a component value. If parsing fails, returns undefined.
257 */
258 mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
259 var c, spec = null;
260
261 switch ( tag ) {
262 case 'intercalary':
263 case 'not-intercalary':
264 if ( params.length < 2 || !params[ 0 ] ) {
265 return null;
266 }
267 spec = {
268 component: null,
269 editable: false,
270 type: 'static',
271 value: params.slice( 1 ).join( '|' ),
272 size: 0,
273 intercalarySize: {}
274 };
275 if ( tag === 'intercalary' ) {
276 spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
277 } else {
278 spec.size = spec.value.length;
279 spec.intercalarySize[ params[ 0 ] ] = 0;
280 }
281 return spec;
282
283 case 'zone':
284 switch ( params[ 0 ] ) {
285 case '#':
286 case ':':
287 c = params[ 0 ] === '#' ? '' : ':';
288 return {
289 component: 'zone',
290 editable: true,
291 type: 'toggleLocal',
292 size: 5 + c.length,
293 formatValue: function ( v ) {
294 var o, r;
295 if ( v ) {
296 o = new Date().getTimezoneOffset();
297 r = String( Math.abs( o ) % 60 );
298 while ( r.length < 2 ) {
299 r = '0' + r;
300 }
301 r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
302 while ( r.length < 4 + c.length ) {
303 r = '0' + r;
304 }
305 return ( o <= 0 ? '+' : '−' ) + r;
306 } else {
307 return '+00' + c + '00';
308 }
309 },
310 parseValue: function ( v ) {
311 var m;
312 v = String( v ).trim();
313 if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
314 return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
315 } else {
316 return undefined;
317 }
318 }
319 };
320
321 case 'short':
322 case 'full':
323 spec = {
324 component: 'zone',
325 editable: true,
326 type: 'toggleLocal',
327 values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
328 formatValue: this.formatSpecValue,
329 parseValue: this.parseSpecValue
330 };
331 spec.size = Math.max.apply(
332 null, $.map( spec.values, function ( v ) { return v.length; } )
333 );
334 return spec;
335 }
336 return null;
337
338 default:
339 return null;
340 }
341 };
342
343 /**
344 * Format a value for a field specification
345 *
346 * 'this' must be the field specification object. The intention is that you
347 * could just assign this function as the 'formatValue' for each field spec.
348 *
349 * Besides the publicly-documented fields, uses the following:
350 * - values: Enumerated values for the field
351 * - zeropad: Whether to pad the number with zeros.
352 *
353 * @protected
354 * @param {Mixed} v
355 * @return {string}
356 */
357 mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
358 if ( v === undefined || v === null ) {
359 return '';
360 }
361
362 if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
363 v = v ? 1 : 0;
364 }
365
366 if ( this.values ) {
367 return this.values[ v ];
368 }
369
370 v = String( v );
371 if ( this.zeropad ) {
372 while ( v.length < this.size ) {
373 v = '0' + v;
374 }
375 }
376 return v;
377 };
378
379 /**
380 * Parse a value for a field specification
381 *
382 * 'this' must be the field specification object. The intention is that you
383 * could just assign this function as the 'parseValue' for each field spec.
384 *
385 * Besides the publicly-documented fields, uses the following:
386 * - values: Enumerated values for the field
387 *
388 * @protected
389 * @param {string} v
390 * @return {number|string|null}
391 */
392 mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
393 var k, re;
394
395 if ( v === '' ) {
396 return null;
397 }
398
399 if ( !this.values ) {
400 v = +v;
401 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
402 return isNaN( v ) ? undefined : !!v;
403 } else {
404 return isNaN( v ) ? undefined : v;
405 }
406 }
407
408 if ( v.normalize ) {
409 v = v.normalize();
410 }
411 re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' );
412 for ( k in this.values ) {
413 k = +k;
414 if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
415 if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
416 return !!k;
417 } else {
418 return k;
419 }
420 }
421 }
422 return undefined;
423 };
424
425 /**
426 * Get components from a Date object
427 *
428 * Most specific components are defined by the subclass. "Global" components
429 * are:
430 * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
431 * - zone: {number} Timezone offset in minutes.
432 *
433 * @abstract
434 * @param {Date|null} date
435 * @return {Object} Components
436 */
437 mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
438 // Should be overridden by subclass
439 return {
440 zone: this.local ? date.getTimezoneOffset() : 0
441 };
442 };
443
444 /**
445 * Get a Date object from components
446 *
447 * @param {Object} components Date components
448 * @return {Date}
449 */
450 mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
451 // Should be overridden by subclass
452 return new Date();
453 };
454
455 /**
456 * Adjust a date
457 *
458 * @param {Date|null} date To be adjusted
459 * @param {string} component To adjust
460 * @param {number} delta Adjustment amount
461 * @param {string} mode Adjustment mode:
462 * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
463 * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
464 * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
465 * @return {Date} Adjusted date
466 */
467 mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /*, component, delta, mode */ ) {
468 // Should be overridden by subclass
469 return date;
470 };
471
472 /**
473 * Get the column headings (weekday abbreviations) for a calendar grid
474 *
475 * Null-valued columns are hidden if getCalendarData() returns no "day" object
476 * for all days in that column.
477 *
478 * @abstract
479 * @return {Array} string or null
480 */
481 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
482 // Should be overridden by subclass
483 return [];
484 };
485
486 /**
487 * Test whether two dates are in the same calendar grid
488 *
489 * @abstract
490 * @param {Date} date1
491 * @param {Date} date2
492 * @return {boolean}
493 */
494 mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
495 // Should be overridden by subclass
496 return date1.getTime() === date2.getTime();
497 };
498
499 /**
500 * Test whether the date parts of two Dates are equal
501 *
502 * @param {Date} date1
503 * @param {Date} date2
504 * @return {boolean}
505 */
506 mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
507 if ( this.local ) {
508 return (
509 date1.getFullYear() === date2.getFullYear() &&
510 date1.getMonth() === date2.getMonth() &&
511 date1.getDate() === date2.getDate()
512 );
513 } else {
514 return (
515 date1.getUTCFullYear() === date2.getUTCFullYear() &&
516 date1.getUTCMonth() === date2.getUTCMonth() &&
517 date1.getUTCDate() === date2.getUTCDate()
518 );
519 }
520 };
521
522 /**
523 * Test whether the time parts of two Dates are equal
524 *
525 * @param {Date} date1
526 * @param {Date} date2
527 * @return {boolean}
528 */
529 mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
530 if ( this.local ) {
531 return (
532 date1.getHours() === date2.getHours() &&
533 date1.getMinutes() === date2.getMinutes() &&
534 date1.getSeconds() === date2.getSeconds() &&
535 date1.getMilliseconds() === date2.getMilliseconds()
536 );
537 } else {
538 return (
539 date1.getUTCHours() === date2.getUTCHours() &&
540 date1.getUTCMinutes() === date2.getUTCMinutes() &&
541 date1.getUTCSeconds() === date2.getUTCSeconds() &&
542 date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
543 );
544 }
545 };
546
547 /**
548 * Test whether toggleLocal() changes the date part
549 *
550 * @param {Date} date
551 * @return {boolean}
552 */
553 mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
554 return (
555 date.getUTCFullYear() !== date.getFullYear() ||
556 date.getUTCMonth() !== date.getMonth() ||
557 date.getUTCDate() !== date.getDate()
558 );
559 };
560
561 /**
562 * Create a new Date by merging the date part from one with the time part from
563 * another.
564 *
565 * @param {Date} datepart
566 * @param {Date} timepart
567 * @return {Date}
568 */
569 mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
570 var ret = new Date( datepart.getTime() );
571
572 if ( this.local ) {
573 ret.setHours(
574 timepart.getHours(),
575 timepart.getMinutes(),
576 timepart.getSeconds(),
577 timepart.getMilliseconds()
578 );
579 } else {
580 ret.setUTCHours(
581 timepart.getUTCHours(),
582 timepart.getUTCMinutes(),
583 timepart.getUTCSeconds(),
584 timepart.getUTCMilliseconds()
585 );
586 }
587
588 return ret;
589 };
590
591 /**
592 * Get data for a calendar grid
593 *
594 * A "day" object is:
595 * - display: {string} Display text for the day.
596 * - date: {Date} Date to use when the day is selected.
597 * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
598 * at the start and end of the month.
599 *
600 * In any one result object, 'extra' + 'display' will always be unique.
601 *
602 * @abstract
603 * @param {Date|null} current Current date
604 * @return {Object} Data
605 * @return {string} return.header String to display as the calendar header
606 * @return {string} return.monthComponent Component to adjust by ±1 to change months.
607 * @return {string} return.dayComponent Component to adjust by ±1 to change days.
608 * @return {string} [return.weekComponent] Component to adjust by ±1 to change
609 * weeks. If omitted, the dayComponent should be adjusted by ±the number of
610 * non-nullable columns returned by this.getCalendarHeadings() to change weeks.
611 * @return {Array} return.rows Array of arrays of "day" objects or null/undefined.
612 */
613 mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
614 // Should be overridden by subclass
615 return {
616 header: '',
617 monthComponent: 'month',
618 dayComponent: 'day',
619 rows: []
620 };
621 };
622
623 }( jQuery, mediaWiki ) );