wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js

Summary

Maintainability
F
3 days
Test Coverage
( function () {

    /**
     * A DateTimeFormatter for the Discordian calendar.
     *
     * @classdesc Provides various methods needed for formatting dates and times. This
     * implementation implements the [Discordian calendar](https://en.wikipedia.org/wiki/Discordian_calendar),
     * mainly for testing with something very different from the usual Gregorian
     * calendar.
     *
     * Being intended mainly for testing, niceties like i18n and better
     * configurability have been omitted.
     *
     * @class
     * @extends mw.widgets.datetime.DateTimeFormatter
     *
     * @constructor
     * @param {Object} [config] Configuration options
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter = function MwWidgetsDatetimeDiscordianDateTimeFormatter( config ) {
        config = $.extend( {}, config );

        // Parent constructor
        mw.widgets.datetime.DiscordianDateTimeFormatter.super.call( this, config );
    };

    /* Setup */

    OO.inheritClass( mw.widgets.datetime.DiscordianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter );

    /* Static */

    /**
     * Default format specifications.
     *
     * See the `format` parameter in {@link mw.widgets.datetime.DateTimeFormatter}.
     *
     * @memberof mw.widgets.datetime.DiscordianDateTimeFormatter
     * @type {Object.<string,string>}
     * @name formats
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.static.formats = {
        '@time': '${hour|0}:${minute|0}:${second|0}',
        '@date': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#}',
        '@datetime': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}',
        '@default': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}'
    };

    /* Methods */

    /**
     * Turn a tag into a field specification object.
     *
     * Additional fields implemented here are:
     * - ${year|#}: Year as a number
     * - ${season|#}: Season as a number
     * - ${season|full}: Season as a string
     * - ${day|#}: Day of the month as a number
     * - ${day|0}: Day of the month as a number with leading 0
     * - ${dow|full}: Day of the week as a string
     * - ${hour|#}: Hour as a number
     * - ${hour|0}: Hour as a number with leading 0
     * - ${minute|#}: Minute as a number
     * - ${minute|0}: Minute as a number with leading 0
     * - ${second|#}: Second as a number
     * - ${second|0}: Second as a number with leading 0
     * - ${millisecond|#}: Millisecond as a number
     * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits
     *
     * @protected
     * @param {string} tag
     * @param {string[]} params
     * @return {FieldSpecificationObject} Field specification object, or null if the tag+params are unrecognized.
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
        var spec = null;

        switch ( tag + '|' + params[ 0 ] ) {
            case 'year|#':
                spec = {
                    component: 'Year',
                    calendarComponent: true,
                    type: 'number',
                    size: 4,
                    zeropad: false
                };
                break;

            case 'season|#':
                spec = {
                    component: 'Season',
                    calendarComponent: true,
                    type: 'number',
                    size: 1,
                    intercalarySize: { 1: 0 },
                    zeropad: false
                };
                break;

            case 'season|full':
                spec = {
                    component: 'Season',
                    calendarComponent: true,
                    type: 'string',
                    intercalarySize: { 1: 0 },
                    values: {
                        1: 'Chaos',
                        2: 'Discord',
                        3: 'Confusion',
                        4: 'Bureaucracy',
                        5: 'The Aftermath'
                    }
                };
                break;

            case 'dow|full':
                spec = {
                    component: 'DOW',
                    calendarComponent: true,
                    editable: false,
                    type: 'string',
                    intercalarySize: { 1: 0 },
                    values: {
                        '-1': 'N/A',
                        0: 'Sweetmorn',
                        1: 'Boomtime',
                        2: 'Pungenday',
                        3: 'Prickle-Prickle',
                        4: 'Setting Orange'
                    }
                };
                break;

            case 'day|#':
            case 'day|0':
                spec = {
                    component: 'Day',
                    calendarComponent: true,
                    type: 'string',
                    size: 2,
                    intercalarySize: { 1: 13 },
                    zeropad: params[ 0 ] === '0',
                    formatValue: function ( v ) {
                        if ( v === 'tib' ) {
                            return 'St. Tib\'s Day';
                        }
                        return mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue.call( this, v );
                    },
                    parseValue: function ( v ) {
                        // eslint-disable-next-line security/detect-unsafe-regex
                        if ( /^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) {
                            return 'tib';
                        }
                        return mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue.call( this, v );
                    }
                };
                break;

            case 'hour|#':
            case 'hour|0':
            case 'minute|#':
            case 'minute|0':
            case 'second|#':
            case 'second|0':
                spec = {
                    component: tag.charAt( 0 ).toUpperCase() + tag.slice( 1 ),
                    calendarComponent: false,
                    type: 'number',
                    size: 2,
                    zeropad: params[ 0 ] === '0'
                };
                break;

            case 'millisecond|#':
            case 'millisecond|0':
                spec = {
                    component: 'Millisecond',
                    calendarComponent: false,
                    type: 'number',
                    size: 3,
                    zeropad: params[ 0 ] === '0'
                };
                break;

            default:
                return mw.widgets.datetime.DiscordianDateTimeFormatter.super.prototype.getFieldForTag.call( this, tag, params );
        }

        if ( spec ) {
            if ( spec.editable === undefined ) {
                spec.editable = true;
            }
            if ( spec.component !== 'Day' ) {
                spec.formatValue = this.formatSpecValue;
                spec.parseValue = this.parseSpecValue;
            }
            if ( spec.values ) {
                spec.size = Math.max.apply(
                    // eslint-disable-next-line no-jquery/no-map-util
                    null, $.map( spec.values, function ( v ) {
                        return v.length;
                    } )
                );
            }
        }

        return spec;
    };

    /**
     * Get components from a Date object.
     *
     * Components are:
     * - Year {number}
     * - Season {number} 1-5
     * - Day {number|string} 1-73 or 'tib'
     * - DOW {number} 0-4, or -1 on St. Tib's Day
     * - Hour {number} 0-23
     * - Minute {number} 0-59
     * - Second {number} 0-59
     * - Millisecond {number} 0-999
     * - intercalary {string} '1' on St. Tib's Day
     *
     * @param {Date|null} date
     * @return {Object} Components
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
        var ret, day, month,
            monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ];

        if ( !( date instanceof Date ) ) {
            date = this.defaultDate;
        }

        if ( this.local ) {
            day = date.getDate();
            month = date.getMonth();
            ret = {
                Year: date.getFullYear() + 1166,
                Hour: date.getHours(),
                Minute: date.getMinutes(),
                Second: date.getSeconds(),
                Millisecond: date.getMilliseconds(),
                zone: date.getTimezoneOffset()
            };
        } else {
            day = date.getUTCDate();
            month = date.getUTCMonth();
            ret = {
                Year: date.getUTCFullYear() + 1166,
                Hour: date.getUTCHours(),
                Minute: date.getUTCMinutes(),
                Second: date.getUTCSeconds(),
                Millisecond: date.getUTCMilliseconds(),
                zone: 0
            };
        }

        if ( month === 1 && day === 29 ) {
            ret.Season = 1;
            ret.Day = 'tib';
            ret.DOW = -1;
            ret.intercalary = '1';
        } else {
            day = monthDays[ month ] + day - 1;
            ret.Season = Math.floor( day / 73 ) + 1;
            ret.Day = ( day % 73 ) + 1;
            ret.DOW = day % 5;
            ret.intercalary = '';
        }

        return ret;
    };

    /**
     * @inheritdoc
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) {
        return this.getDateFromComponents(
            this.adjustComponentInternal(
                this.getComponentsFromDate( date ), component, delta, mode
            )
        );
    };

    /**
     * Adjust the components directly.
     *
     * @private
     * @param {Object} components Modified in place
     * @param {string} component
     * @param {number} delta
     * @param {string} mode
     * @return {Object} components
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponentInternal = function ( components, component, delta, mode ) {
        var i, min, max, range, next, preTib, postTib, wasTib;

        if ( delta === 0 ) {
            return components;
        }

        switch ( component ) {
            case 'Year':
                min = 1166;
                max = 11165;
                next = null;
                break;
            case 'Season':
                min = 1;
                max = 5;
                next = 'Year';
                break;
            case 'Week':
                if ( components.Day === 'tib' ) {
                    components.Day = 59; // Could choose either one...
                    components.Season = 1;
                }
                min = 1;
                max = 73;
                next = 'Season';
                break;
            case 'Day':
                min = 1;
                max = 73;
                next = 'Season';
                break;
            case 'Hour':
                min = 0;
                max = 23;
                next = 'Day';
                break;
            case 'Minute':
                min = 0;
                max = 59;
                next = 'Hour';
                break;
            case 'Second':
                min = 0;
                max = 59;
                next = 'Minute';
                break;
            case 'Millisecond':
                min = 0;
                max = 999;
                next = 'Second';
                break;
            default:
                return components;
        }

        switch ( mode ) {
            case 'overflow':
            case 'clip':
            case 'wrap':
        }

        if ( component === 'Day' ) {
            i = Math.abs( delta );
            delta = delta < 0 ? -1 : 1;
            preTib = delta > 0 ? 59 : 60;
            postTib = delta > 0 ? 60 : 59;
            while ( i-- > 0 ) {
                if ( components.Day === preTib && components.Season === 1 && this.isLeapYear( components.Year ) ) {
                    components.Day = 'tib';
                } else if ( components.Day === 'tib' ) {
                    components.Day = postTib;
                    components.Season = 1;
                } else {
                    components.Day += delta;
                    if ( components.Day < min ) {
                        switch ( mode ) {
                            case 'overflow':
                                components.Day = max;
                                this.adjustComponentInternal( components, 'Season', -1, mode );
                                break;
                            case 'wrap':
                                components.Day = max;
                                break;
                            case 'clip':
                                components.Day = min;
                                i = 0;
                                break;
                        }
                    }
                    if ( components.Day > max ) {
                        switch ( mode ) {
                            case 'overflow':
                                components.Day = min;
                                this.adjustComponentInternal( components, 'Season', 1, mode );
                                break;
                            case 'wrap':
                                components.Day = min;
                                break;
                            case 'clip':
                                components.Day = max;
                                i = 0;
                                break;
                        }
                    }
                }
            }
        } else {
            if ( component === 'Week' ) {
                component = 'Day';
                delta *= 5;
            }
            if ( components.Day === 'tib' ) {
                components.Season = 1;
            }
            switch ( mode ) {
                case 'overflow':
                    if ( components.Day === 'tib' && ( component === 'Season' || component === 'Year' ) ) {
                        components.Day = 59; // Could choose either one...
                        wasTib = true;
                    } else {
                        wasTib = false;
                    }
                    i = Math.abs( delta );
                    delta = delta < 0 ? -1 : 1;
                    while ( i-- > 0 ) {
                        components[ component ] += delta;
                        if ( components[ component ] < min ) {
                            components[ component ] = max;
                            components = this.adjustComponentInternal( components, next, -1, mode );
                        }
                        if ( components[ component ] > max ) {
                            components[ component ] = min;
                            components = this.adjustComponentInternal( components, next, 1, mode );
                        }
                    }
                    if ( wasTib && components.Season === 1 && this.isLeapYear( components.Year ) ) {
                        components.Day = 'tib';
                    }
                    break;
                case 'wrap':
                    range = max - min + 1;
                    components[ component ] += delta;
                    while ( components[ component ] < min ) {
                        components[ component ] += range;
                    }
                    while ( components[ component ] > max ) {
                        components[ component ] -= range;
                    }
                    break;
                case 'clip':
                    components[ component ] += delta;
                    if ( components[ component ] < min ) {
                        components[ component ] = min;
                    }
                    if ( components[ component ] > max ) {
                        components[ component ] = max;
                    }
                    break;
            }
            if ( components.Day === 'tib' &&
                ( components.Season !== 1 || !this.isLeapYear( components.Year ) )
            ) {
                components.Day = 59; // Could choose either one...
            }
        }

        return components;
    };

    /**
     * @inheritdoc
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) {
        var month, day, days,
            date = new Date(),
            monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ];

        components = $.extend( {}, this.getComponentsFromDate( null ), components );
        if ( components.Day === 'tib' ) {
            month = 1;
            day = 29;
        } else {
            days = components.Season * 73 + components.Day - 74;
            month = 0;
            while ( days >= monthDays[ month + 1 ] ) {
                month++;
            }
            day = days - monthDays[ month ] + 1;
        }

        if ( components.zone ) {
            // Can't just use the constructor because that's stupid about ancient years.
            date.setFullYear( components.Year - 1166, month, day );
            date.setHours( components.Hour, components.Minute, components.Second, components.Millisecond );
        } else {
            // Date.UTC() is stupid about ancient years too.
            date.setUTCFullYear( components.Year - 1166, month, day );
            date.setUTCHours( components.Hour, components.Minute, components.Second, components.Millisecond );
        }

        return date;
    };

    /**
     * Get whether the year is a leap year.
     *
     * @private
     * @param {number} year
     * @return {boolean}
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) {
        year -= 1166;
        if ( year % 4 ) {
            return false;
        } else if ( year % 100 ) {
            return true;
        }
        return ( year % 400 ) === 0;
    };

    /**
     * @inheritdoc
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () {
        return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ];
    };

    /**
     * @inheritdoc
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
        var components1 = this.getComponentsFromDate( date1 ),
            components2 = this.getComponentsFromDate( date2 );

        return components1.Year === components2.Year && components1.Season === components2.Season;
    };

    /**
     * @inheritdoc
     */
    mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date ) {
        var dt, components, season, i, row,
            ret = {
                dayComponent: 'Day',
                weekComponent: 'Week',
                monthComponent: 'Season'
            },
            seasons = [ 'Chaos', 'Discord', 'Confusion', 'Bureaucracy', 'The Aftermath' ],
            seasonStart = [ 0, -3, -1, -4, -2 ];

        if ( !( date instanceof Date ) ) {
            date = this.defaultDate;
        }

        components = this.getComponentsFromDate( date );
        components.Day = 1;
        season = components.Season;

        ret.header = seasons[ season - 1 ] + ' ' + components.Year;

        if ( seasonStart[ season - 1 ] ) {
            this.adjustComponentInternal( components, 'Day', seasonStart[ season - 1 ], 'overflow' );
        }

        ret.rows = [];
        do {
            row = [];
            for ( i = 0; i < 6; i++ ) {
                dt = this.getDateFromComponents( components );
                row[ i ] = {
                    display: components.Day === 'tib' ? 'Tib' : String( components.Day ),
                    date: dt,
                    extra: components.Season < season ? 'prev' : components.Season > season ? 'next' : null
                };

                this.adjustComponentInternal( components, 'Day', 1, 'overflow' );
                if ( components.Day !== 'tib' && i === 3 ) {
                    row[ ++i ] = null;
                }
            }

            ret.rows.push( row );
        } while ( components.Season === season );

        return ret;
    };

}() );