wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
src/mobile.languages.structured/util.js

Summary

Maintainability
A
3 hrs
Test Coverage
const
    mfUtils = require( '../mobile.startup/util' ),
    rtlLanguages = require( './rtlLanguages' );

/**
 * @typedef {Object} Language
 * @prop {string} autonym of language e.g. français
 * @prop {string} langname in the user's current language e.g French
 * @prop {string} title of the page in the language e.g. Espagne
 * @prop {string} dir (rtl or ltr)
 * @prop {string} url of the page
 *
 * @typedef {Object} SuggestedLanguage
 * @prop {string} autonym of language e.g. français
 * @prop {string} langname in the user's current language e.g French
 * @prop {string} title of the page in the language e.g. Espagne
 * @prop {string} dir (rtl or ltr)
 * @prop {string} url of the page
 * @prop {number} frequency of times the language has been used by the given user
 *
 * @typedef {Object} StructuredLanguages
 * @prop {Language[]} all languages that are available
 * @prop {SuggestedLanguage[]} suggested languages based on users browsing history
 * @ignore
 */

/**
 * Return the device language if it's in the list of article languages.
 * If the language is a variant of a general language, and if the article
 * is not available in that language, then return the general language
 * if article is available in it. For example, if the device language is
 * 'en-gb', and the article is only available in 'en', then return 'en'.
 *
 * @ignore
 * @param {Object[]} languages list of language objects as returned by the API
 * @param {string|undefined} deviceLanguage the device's primary language
 * @return {string|undefined} Return undefined if the article is not available in
 *  the (general or variant) device language
 */
function getDeviceLanguageOrParent( languages, deviceLanguage ) {
    let parentLanguage;

    const
        hasOwn = Object.prototype.hasOwnProperty,
        deviceLanguagesWithVariants = {};

    if ( !deviceLanguage ) {
        return;
    }

    // Are we dealing with a variant?
    const index = deviceLanguage.indexOf( '-' );
    if ( index !== -1 ) {
        parentLanguage = deviceLanguage.slice( 0, index );
    }

    languages.forEach( function ( language ) {
        if ( language.lang === parentLanguage || language.lang === deviceLanguage ) {
            deviceLanguagesWithVariants[ language.lang ] = true;
        }
    } );

    if ( hasOwn.call( deviceLanguagesWithVariants, deviceLanguage ) ) {
        // the device language is one of the available languages
        return deviceLanguage;
    } else if ( hasOwn.call( deviceLanguagesWithVariants, parentLanguage ) ) {
        // no device language, but the parent language is one of the available languages
        return parentLanguage;
    }
}

/**
 * Utility function for the structured language overlay
 *
 * @class util
 * @singleton
 * @private
 */
module.exports = {
    /**
     * Determine whether a language is LTR or RTL
     * This works around T74153 and T189036
     * and the fact that adding dir attribute to HTML in core
     * at time of writing is memory-intensive
     * (I7cd8a3117f49467e3ff26f35371459a667c71470)
     *
     * @memberof util
     * @instance
     * @param {Object} language with 'lang' key.
     * @return {Object} language with 'lang' key and new 'dir' key.
     */
    getDir: function ( language ) {
        const dir = rtlLanguages.indexOf( language.lang ) > -1 ? 'rtl' : 'ltr';
        return mfUtils.extend( {}, language, { dir } );
    },

    /**
     * Return two sets of languages: suggested and all (everything else)
     *
     * Suggested languages are the ones that the user has used before. This also
     * includes the user device's primary language. Suggested languages are ordered
     * by frequency in descending order. The device's language is always at the top.
     * This group also includes the variants.
     *
     * All languages are the languages that are not suggested.
     * Languages in this list are ordered in the lexicographical order of
     * their language names.
     *
     * @memberof util
     * @ignore
     * @instance
     * @param {Object[]} languages list of language objects as returned by the API
     * @param {Array|boolean} variants language variant objects or false if no variants exist
     * @param {Object} frequentlyUsedLanguages list of the frequently used languages
     * @param {boolean} showSuggestedLanguages
     * @param {string} [deviceLanguage] the device's primary language
     * @return {StructuredLanguages}
     */
    getStructuredLanguages: function (
        languages,
        variants,
        frequentlyUsedLanguages,
        showSuggestedLanguages,
        deviceLanguage
    ) {
        const hasOwn = Object.prototype.hasOwnProperty,
            self = this;

        let maxFrequency = 0,
            minFrequency = 0,
            suggestedLanguages = [],
            allLanguages = [];

        // Is the article available in the user's device language?
        deviceLanguage = getDeviceLanguageOrParent( languages, deviceLanguage );
        if ( deviceLanguage ) {
            Object.keys( frequentlyUsedLanguages ).forEach( function ( language ) {
                const frequency = frequentlyUsedLanguages[ language ];
                maxFrequency = maxFrequency < frequency ? frequency : maxFrequency;
                minFrequency = minFrequency > frequency ? frequency : minFrequency;
            } );

            // Make the device language the most frequently used one so that
            // it appears at the top of the list when sorted by frequency.
            frequentlyUsedLanguages[ deviceLanguage ] = maxFrequency + 1;
        }

        /**
         * @param {Object} language
         * @return {Object} which has 'dir' key.
         */
        function addLangDir( language ) {
            if ( language.dir ) {
                return language;
            } else {
                return self.getDir( language );
            }
        }

        // Separate languages into suggested and all languages.
        if ( showSuggestedLanguages ) {
            languages.map( addLangDir ).forEach( function ( language ) {
                if ( hasOwn.call( frequentlyUsedLanguages, language.lang ) ) {
                    language.frequency = frequentlyUsedLanguages[language.lang];
                    suggestedLanguages.push( language );
                } else {
                    allLanguages.push( language );
                }
            } );
        } else {
            allLanguages = languages.map( addLangDir );
        }

        // Add variants to the suggested languages list and assign the lowest
        // frequency because the variant hasn't been clicked on yet.
        // Note that the variants data doesn't contain the article title, thus
        // we cannot show it for the variants.
        if ( variants && showSuggestedLanguages ) {
            variants.map( addLangDir ).forEach( function ( variant ) {
                if ( hasOwn.call( frequentlyUsedLanguages, variant.lang ) ) {
                    variant.frequency = frequentlyUsedLanguages[variant.lang];
                } else {
                    variant.frequency = minFrequency - 1;
                }
                suggestedLanguages.push( variant );
            } );
        }

        // sort suggested languages in descending order by frequency
        suggestedLanguages = suggestedLanguages.sort( function ( a, b ) {
            return b.frequency - a.frequency;
        } );

        /**
         * Compare language names lexicographically
         *
         * @param {Object} a first language
         * @param {Object} b second language
         * @return {number} Comparison value, 1 or -1
         */
        function compareLanguagesByLanguageName( a, b ) {
            return a.autonym.toLocaleLowerCase() < b.autonym.toLocaleLowerCase() ? -1 : 1;
        }

        allLanguages = allLanguages.sort( compareLanguagesByLanguageName );
        return {
            suggested: suggestedLanguages,
            all: allLanguages
        };
    },

    /**
     * Return a map of frequently used languages on the current device.
     *
     * @memberof util
     * @instance
     * @return {Object}
     */
    getFrequentlyUsedLanguages: function () {
        const languageMap = mw.storage.get( 'langMap' );

        return languageMap ? JSON.parse( languageMap ) : {};
    },

    /**
     * Save the frequently used languages to the user's device
     *
     * @memberof util
     * @instance
     * @param {Object} languageMap
     */
    saveFrequentlyUsedLanguages: function ( languageMap ) {
        mw.storage.set( 'langMap', JSON.stringify( languageMap ) );
    },

    /**
     * Increment the current language usage by one and save it to the device.
     * Cap the result at 100.
     *
     * @memberof util
     * @instance
     * @param {string} languageCode
     * @param {Object} frequentlyUsedLanguages list of the frequently used languages
     */
    saveLanguageUsageCount: function ( languageCode, frequentlyUsedLanguages ) {
        let count = frequentlyUsedLanguages[ languageCode ] || 0;

        count += 1;
        // cap at 100 as this is enough data to work on
        frequentlyUsedLanguages[ languageCode ] = count > 100 ? 100 : count;
        this.saveFrequentlyUsedLanguages( frequentlyUsedLanguages );
    }
};