MediaWiki:Common.js

De Wikiromandie.org
Aller à la navigation Aller à la recherche

Note : après avoir publié vos modifications, il se peut que vous deviez forcer le rechargement complet du cache de votre navigateur pour voir les changements.

  • Firefox / Safari : maintenez la touche Maj (Shift) en cliquant sur le bouton Actualiser ou appuyez sur Ctrl + F5 ou Ctrl + R (⌘ + R sur un Mac).
  • Google Chrome : appuyez sur Ctrl + Maj + R (⌘ + Shift + R sur un Mac).
  •  Edge : maintenez la touche Ctrl en cliquant sur le bouton Actualiser ou pressez Ctrl + F5.
/* Tout JavaScript présent ici sera exécuté par tous les utilisateurs à chaque chargement de page. */

/**
 * Keep code in MediaWiki:Common.js to a minimum as it is unconditionally
 * loaded for all users on every wiki page. If possible create a gadget that is
 * enabled by default instead of adding it here (since gadgets are fully
 * optimized ResourceLoader modules with possibility to add dependencies etc.)
 *
 * Since Common.js isn't a gadget, there is no place to declare its
 * dependencies, so we have to lazy load them with mw.loader.using on demand and
 * then execute the rest in the callback. In most cases these dependencies will
 * be loaded (or loading) already and the callback will not be delayed. In case a
 * dependency hasn't arrived yet it'll make sure those are loaded before this.
 */

/* global mw, $ */
/* jshint strict:false, browser:true */

/**
 * MediaWiki:Common.js
 * 
 * Common JavaScript for all users on this wiki.
 */

( function ( $, mw ) {
    'use strict';

    /* ==========================================================================
       1. Helper Utilities & Legacy Fixes
       ========================================================================== */

    /**
     * Deprecation fallback for window.addPortletLink
     */
    window.addPortletLink = function () {
        return mw.util.addPortletLink.apply( mw.util, arguments );
    };

    /**
     * Dynamically loads a script from the MediaWiki namespace
     */
    function withJS( page ) {
        if ( !page || !/^MediaWiki:/.test( page ) ) {
            return;
        }
        mw.loader.load( mw.config.get( 'wgScript' ) + '?title=' + encodeURIComponent( page ) + '&action=raw&ctype=text/javascript' );
    }

    /**
     * Dynamically loads a stylesheet from the MediaWiki namespace
     */
    function withCSS( page ) {
        if ( !page || !/^MediaWiki:/.test( page ) ) {
            return;
        }
        mw.loader.load( mw.config.get( 'wgScript' ) + '?title=' + encodeURIComponent( page ) + '&action=raw&ctype=text/css', 'text/css' );
    }

    /**
     * Open interwiki links in a new window/tab
     */
    function openInterwikiLinksInNewWindow( $content ) {
        $content.find( 'a.extiw, a.external' ).each( function () {
            var href = $( this ).attr( 'href' );
            if ( href && href.indexOf( mw.config.get( 'wgServer' ) ) === -1 ) {
                $( this ).attr( {
                    target: '_blank',
                    rel: 'noopener noreferrer'
                } );
            }
        } );
    }

    /**
     * Converts legacy collapsible tables into MediaWiki collapsible elements
     */
    function setupCollapsibleElements( $content ) {
        $content.find( 'table.collapsible' ).each( function () {
            var $table = $( this );
            $table.addClass( 'mw-collapsible' );
            if ( $table.hasClass( 'collapsed' ) ) {
                $table.addClass( 'mw-collapsed' );
            }
        } );
    }


    /* ==========================================================================
       2. Native MediaWiki Commons Image Resolver
       ========================================================================== */

    /**
     * Fetches scaled thumbnail URLs directly using native MediaWiki Action API.
     * Batches file titles into a single API request for performance.
     * 
     * @param {Array<string>} fileTitles Array of file titles or Special:FilePath URIs
     * @param {number} width Target thumbnail width in pixels
     * @return {jQuery.Promise} Resolves with a mapping object { cleanFileName: thumbUrl }
     */
    function resolveCommonsThumbnailsNative( fileTitles, width ) {
        width = width || 160;
        var deferred = $.Deferred();

        if ( !fileTitles || !fileTitles.length ) {
            return deferred.resolve( {} ).promise();
        }

        // Clean titles to standard 'File:Example.jpg' format
        var titleMap = {};
        var cleanTitles = [];

        $.each( fileTitles, function ( i, raw ) {
            var fileName = decodeURIComponent( raw ).replace( /^.*Special:FilePath\//, '' ).replace( /_/g, ' ' );
            var fullTitle = 'File:' + fileName;
            titleMap[ fullTitle ] = raw;
            if ( cleanTitles.indexOf( fullTitle ) === -1 ) {
                cleanTitles.push( fullTitle );
            }
        } );

        // Batch call Wikimedia Commons MediaWiki API natively via mw.ForeignApi / mw.Api
        var api = new mw.ForeignApi( 'https://commons.wikimedia.org/w/api.php' );

        api.get( {
            action: 'query',
            titles: cleanTitles.join( '|' ),
            prop: 'imageinfo',
            iiprop: 'url',
            iiurlwidth: width,
            formatversion: 2
        } ).done( function ( data ) {
            var urlMap = {};
            if ( data && data.query && data.query.pages ) {
                $.each( data.query.pages, function ( i, page ) {
                    if ( page.imageinfo && page.imageinfo[ 0 ] ) {
                        var info = page.imageinfo[ 0 ];
                        var origKey = titleMap[ page.title ];
                        urlMap[ origKey ] = info.thumburl || info.url;
                    }
                } );
            }
            deferred.resolve( urlMap );
        } ).fail( function () {
            deferred.resolve( {} ); // Fallback gracefully if API fails
        } );

        return deferred.promise();
    }


    /* ==========================================================================
       3. Wikidata SPARQL Table Engine
       ========================================================================== */

    var SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
    var SPARQL_MAX_PARALLEL = 2;
    var CACHE_FRESHNESS_MS = 2 * 60 * 1000;         // 2 Minutes (Fresh)
    var CACHE_EXPIRATION_MS = 6 * 60 * 60 * 1000;   // 6 Hours (Max TTL)

    var activeRequests = 0;
    var requestQueue = [];
    var inFlightPromises = {};

    /**
     * Compute a fast FNV-1a hash key for SPARQL string deduplication
     */
    function fnv1aHash( str ) {
        var hash = 0x811c9dc5;
        for ( var i = 0; i < str.length; i++ ) {
            hash ^= str.charCodeAt( i );
            hash += ( hash << 1 ) + ( hash << 4 ) + ( hash << 7 ) + ( hash << 8 ) + ( hash << 24 );
        }
        return 'wdsparql:' + ( hash >>> 0 ).toString( 36 );
    }

    /**
     * Safe storage write wrapper with automatic cache pruning
     */
    function setCache( key, val ) {
        try {
            var data = {
                timestamp: Date.now(),
                payload: val
            };
            localStorage.setItem( key, JSON.stringify( data ) );
        } catch ( e ) {
            // Prune old SPARQL cache keys if quota exceeded
            for ( var k in localStorage ) {
                if ( k.indexOf( 'wdsparql:' ) === 0 ) {
                    localStorage.removeItem( k );
                }
            }
        }
    }

    /**
     * Retrieve items from cache with timestamp metadata
     */
    function getCache( key ) {
        try {
            var raw = localStorage.getItem( key );
            if ( !raw ) return null;
            var parsed = JSON.parse( raw );
            var age = Date.now() - parsed.timestamp;
            if ( age > CACHE_EXPIRATION_MS ) {
                localStorage.removeItem( key );
                return null;
            }
            return {
                isFresh: age < CACHE_FRESHNESS_MS,
                payload: parsed.payload
            };
        } catch ( e ) {
            return null;
        }
    }

    /**
     * Process throttled SPARQL execution queue
     */
    function processQueue() {
        if ( activeRequests >= SPARQL_MAX_PARALLEL || requestQueue.length === 0 ) {
            return;
        }

        var task = requestQueue.shift();
        activeRequests++;

        var hashKey = task.hashKey;

        // Deduplicate in-flight network promises
        if ( inFlightPromises[ hashKey ] ) {
            inFlightPromises[ hashKey ].then( task.resolve ).catch( task.reject ).always( function () {
                activeRequests--;
                processQueue();
            } );
            return;
        }

        var promise = $.ajax( {
            url: SPARQL_ENDPOINT,
            data: { query: task.sparql },
            dataType: 'json',
            headers: { 'Accept': 'application/sparql-results+json' }
        } ).then( function ( data ) {
            setCache( hashKey, data );
            return data;
        } );

        inFlightPromises[ hashKey ] = promise;

        promise.done( task.resolve )
            .fail( function ( xhr, status, error ) {
                if ( xhr.status === 429 || xhr.status === 503 ) {
                    var retryAfter = parseInt( xhr.getResponseHeader( 'Retry-After' ), 10 ) || 5;
                    setTimeout( function () {
                        requestQueue.unshift( task );
                    }, retryAfter * 1000 );
                } else {
                    task.reject( error );
                }
            } )
            .always( function () {
                delete inFlightPromises[ hashKey ];
                activeRequests--;
                processQueue();
            } );
    }

    /**
     * Queue a SPARQL query request
     */
    function fetchSparql( sparql ) {
        var hashKey = fnv1aHash( sparql );
        var cached = getCache( hashKey );

        return $.Deferred( function ( deferred ) {
            if ( cached && cached.isFresh ) {
                deferred.resolve( cached.payload );
                return;
            }

            if ( cached && !cached.isFresh ) {
                deferred.resolve( cached.payload );
            }

            requestQueue.push( {
                sparql: sparql,
                hashKey: hashKey,
                resolve: function ( data ) {
                    if ( !cached ) deferred.resolve( data );
                },
                reject: function ( err ) {
                    if ( !cached ) deferred.reject( err );
                }
            } );

            processQueue();
        } ).promise();
    }

    /**
     * Renders bindings into a standard MediaWiki formatted wikitable using native image resolution
     */
    function renderSparqlTable( $container, data, title ) {
        var vars = data.head.vars;
        var bindings = data.results.bindings;

        // Collect all Commons file references for native API batch resolution
        var imageFiles = [];
        $.each( bindings, function ( i, row ) {
            $.each( vars, function ( j, v ) {
                var cell = row[ v ];
                if ( cell && cell.type === 'uri' && cell.value.indexOf( 'Special:FilePath/' ) !== -1 ) {
                    imageFiles.push( cell.value );
                }
            } );
        } );

        // Resolve images natively via MediaWiki API, then render DOM
        resolveCommonsThumbnailsNative( imageFiles, 160 ).done( function ( imageMap ) {
            var $table = $( '<table>' ).addClass( 'wikitable sortable wikidata-sparql-rendered' );

            if ( title ) {
                $table.append( $( '<caption>' ).text( title ) );
            }

            // Header
            var $thead = $( '<thead>' );
            var $headerRow = $( '<tr>' );
            $.each( vars, function ( i, v ) {
                $headerRow.append( $( '<th>' ).text( v ) );
            } );
            $thead.append( $headerRow );
            $table.append( $thead );

            // Body
            var $tbody = $( '<tbody>' );
            $.each( bindings, function ( i, row ) {
                var $row = $( '<tr>' );
                $.each( vars, function ( j, v ) {
                    var cell = row[ v ];
                    var $td = $( '<td>' );

                    if ( !cell ) {
                        $td.html( '&mdash;' );
                    } else if ( cell.type === 'uri' && cell.value.indexOf( 'Special:FilePath/' ) !== -1 ) {
                        var thumbUrl = imageMap[ cell.value ] || cell.value;
                        var $img = $( '<img>' ).attr( {
                            src: thumbUrl,
                            loading: 'lazy',
                            height: 80
                        } ).css( 'max-height', '80px' );

                        var $link = $( '<a>' ).attr( {
                            href: cell.value,
                            target: '_blank',
                            rel: 'noopener noreferrer'
                        } ).append( $img );

                        $td.append( $link );
                    } else if ( cell.type === 'uri' && cell.value.indexOf( 'http://www.wikidata.org/entity/' ) === 0 ) {
                        var qid = cell.value.replace( 'http://www.wikidata.org/entity/', '' );
                        var $link = $( '<a>' ).attr( {
                            href: cell.value,
                            target: '_blank',
                            rel: 'noopener noreferrer'
                        } ).text( qid );

                        $td.append( $link );
                    } else {
                        $td.text( cell.value );
                    }

                    $row.append( $td );
                } );
                $tbody.append( $row );
            } );

            $table.append( $tbody );
            $container.empty().append( $table );

            mw.loader.using( 'jquery.tablesorter', function () {
                $table.tablesorter();
            } );
        } );
    }

    /**
     * Initializes elements using the .wikidata-sparql-table class via IntersectionObserver
     */
    function initSparqlTables( $content ) {
        var $targets = $content.find( '.wikidata-sparql-table:not(.wd-sparql-initialized)' );
        if ( !$targets.length ) return;

        var observer = new IntersectionObserver( function ( entries, obs ) {
            entries.forEach( function ( entry ) {
                if ( entry.isIntersecting ) {
                    var $elem = $( entry.target );
                    $elem.addClass( 'wd-sparql-initialized' );
                    obs.unobserve( entry.target );

                    var sparql = $elem.attr( 'data-sparql' );
                    var title = $elem.attr( 'data-title' );

                    if ( !sparql ) return;

                    $elem.html( '<span class="mw-ajax-loader"></span> Loading Wikidata SPARQL results...' );

                    fetchSparql( sparql )
                        .done( function ( data ) {
                            renderSparqlTable( $elem, data, title );
                        } )
                        .fail( function () {
                            $elem.html( '<span class="error">Failed to load SPARQL results from Wikidata.</span>' );
                        } );
                }
            } );
        }, { rootMargin: '300px 0px' } );

        $targets.each( function () {
            observer.observe( this );
        } );
    }

    /* ==========================================================================
       4. Document Ready Hook
       ========================================================================== */

    mw.hook( 'wikipage.content' ).add( function ( $content ) {
        openInterwikiLinksInNewWindow( $content );
        setupCollapsibleElements( $content );
        initSparqlTables( $content );
    } );

}( jQuery, mediaWiki ) );

/* DO NOT ADD CODE BELOW THIS LINE */