MediaWiki:Gadget-projects.js: Difference between revisions
MediaWiki interface page
More actions
m Removed protection from "MediaWiki:Gadget-projects.js" Â |
No edit summary |
||
| Line 1: | Line 1: | ||
/* ============================================================================= | /* ============================================================================= | ||
  Gadget-projects.js |   Gadget-projects.js v2.0 | ||
  |   Unified project hub — API-powered search, tag filtering, and pagination. | ||
  ============================================================================= */ |   ============================================================================= */ | ||
( function () { | ( function () { | ||
   |  | ||
   if ( mw.config.get( 'wgPageName' ) !== 'Projects' ) { return; } | |||
   $( function () { |    $( function () { | ||
     var $ |      var PAGE_SIZE = 24; | ||
     var |     var state = { | ||
     var |       search : '', | ||
      tag  : '', | |||
      sort : 'newest', | |||
      offset : 0, | |||
      done : false, | |||
      busy : false | |||
    }; | |||
 | |||
    var $wrapper = $( '.project-hub-wrapper' ); | |||
    if ( !$wrapper.length ) { return; } | |||
 | |||
    var $filters = $wrapper.find( '.project-hub-filters' ); | |||
    var $cloud  = $wrapper.find( '.project-hub-tagcloud' ); | |||
    var $grid  = $wrapper.find( '.project-hub-grid' ); | |||
     var $more  = $wrapper.find( '.project-hub-loadmore' ); | |||
     var $count = $( '<span>' ).addClass( 'project-hub-count' ); | |||
     // |      // ── Build filter controls ──────────────────────────────────────────── | ||
     $( ' |      var $search = $( '<input>' ).attr( { | ||
       |        type    : 'text', | ||
       |        placeholder : 'Search projects, tags, creators…', | ||
      'class'   : 'project-hub-search' | |||
     } ); |      } ); | ||
 |      var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append( | ||
      $( '<option>' ).val( 'newest' ).text( 'Newest first' ), | |||
     var $ |        $( '<option>' ).val( 'alpha' ).text( 'A – Z' ) | ||
       $( '<option>' ).val( 'alpha' ).text( 'A – Z | |||
     ); |      ); | ||
     |      $filters.append( $search, $sort, $count ); | ||
     $ |     // ── Wire events ────────────────────────────────────────────────────── | ||
    var debounce; | |||
     $search.on( 'input', function () { | |||
      clearTimeout( debounce ); | |||
      debounce = setTimeout( function () { | |||
        state.search = $search.val().trim(); | |||
        resetAndLoad(); | |||
      }, 350 ); | |||
    } ); | |||
     |      $sort.on( 'change', function () { | ||
      state.sort = $sort.val(); | |||
      resetAndLoad(); | |||
    } ); | |||
     $. |      $more.on( 'click', function () { | ||
       if ( !state.busy ) { fetchPage(); } | |||
       ( | |||
     } ); |      } ); | ||
     $. |     // Tag click — works for both cloud tags and card tags (delegated) | ||
     $( document ).on( 'click', '.js-tag-filter', function ( e ) { | |||
       e.stopPropagation(); | |||
       |        var tag = $( this ).data( 'tag' ); | ||
       |        if ( state.tag === tag ) { | ||
       |         state.tag = ''; | ||
        $( '.js-tag-filter' ).removeClass( 'is-active' ); | |||
       } else { | |||
         state.tag = tag; | |||
         $( '.js-tag-filter' ).removeClass( 'is-active' ); | |||
       |         $( this ).addClass( 'is-active' ); | ||
         |       } | ||
         |       state.search = ''; | ||
       |       $search.val( '' ); | ||
       resetAndLoad(); | |||
     } ); |      } ); | ||
     // |      // ── Initial load ───────────────────────────────────────────────────── | ||
     |      loadTagCloud(); | ||
    loadHubMeta(); | |||
    fetchPage(); | |||
       $ |     // ── Core helpers ───────────────────────────────────────────────────── | ||
    function resetAndLoad() { | |||
         var |       state.offset = 0; | ||
      state.done = false; | |||
      $grid.empty(); | |||
       $more.hide(); | |||
      fetchPage(); | |||
    } | |||
         |  | ||
    function buildWhere() { | |||
      var parts = []; | |||
      if ( state.search ) { | |||
         var q = state.search.replace( /'/g, "''" ); | |||
        parts.push( | |||
          "( name LIKE '%" + q + "%'" + | |||
          " OR tags LIKE '%" + q + "%'" + | |||
          " OR license LIKE '%" + q + "%'" + | |||
          " OR creator LIKE '%" + q + "%' )" | |||
        ); | |||
      } | |||
      if ( state.tag ) { | |||
        parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" ); | |||
      } | |||
      return parts.join( ' AND ' ); | |||
    } | |||
 | |||
    function orderBy() { | |||
      return state.sort === 'alpha' ? 'name ASC' : '_pageID DESC'; | |||
    } | |||
 | |||
    // ── Data fetching ──────────────────────────────────────────────────── | |||
    function fetchPage() { | |||
      if ( state.busy || state.done ) { return; } | |||
      state.busy = true; | |||
      $more.text( 'Loading…' ).show(); | |||
 | |||
      var params = { | |||
        action : 'cargoquery', | |||
        tables : 'Projects', | |||
        fields : '_pageName=pagename,name,status,license,creator,' + | |||
             'description,image,tags,' + | |||
             'field1label,field1value,field2label,field2value,' + | |||
             'field3label,field3value,field4label,field4value', | |||
        order_by : orderBy(), | |||
        limit  : PAGE_SIZE, | |||
        offset : state.offset, | |||
        format : 'json' | |||
      }; | |||
      var w = buildWhere(); | |||
      if ( w ) { params.where = w; } | |||
 | |||
      // Fetch total count on every first-page load | |||
      if ( state.offset === 0 ) { fetchTotal( w ); } | |||
 | |||
      $.getJSON( mw.util.wikiScript( 'api' ), params ) | |||
        .done( function ( data ) { | |||
          var rows   = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } ); | |||
          var prevOff = state.offset; | |||
          var cardBatch = []; | |||
 | |||
          rows.forEach( function ( row ) { | |||
            var $c = buildCard( row ); | |||
            $grid.append( $c ); | |||
            cardBatch.push( { $el: $c, page: row.pagename } ); | |||
          } ); | |||
 | |||
          enrichCards( cardBatch ); | |||
 | |||
          state.offset += rows.length; | |||
          state.done  = ( rows.length < PAGE_SIZE ); | |||
          state.busy  = false; | |||
 | |||
          $more.text( 'Load more projects' ).toggle( !state.done ); | |||
 | |||
          if ( rows.length === 0 && prevOff === 0 ) { | |||
            $grid.html( '<div class="project-hub-empty">No projects found.</div>' ); | |||
          } | |||
        } ) | |||
        .fail( function () { | |||
          state.busy = false; | |||
          $more.text( 'Load more projects' ).toggle( !state.done ); | |||
        } ); | |||
    } | |||
 | |||
    function fetchTotal( where ) { | |||
      var p = { | |||
        action : 'cargoquery', | |||
        tables : 'Projects', | |||
        fields : 'COUNT(*)=n', | |||
        limit : 1, | |||
        format : 'json' | |||
      }; | |||
      if ( where ) { p.where = where; } | |||
      $.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) { | |||
        var row = ( data.cargoquery || [] )[0]; | |||
         var n = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0; | |||
        $count.text( n + ' project' + ( n === 1 ? '' : 's' ) ); | |||
       } ); |        } ); | ||
    } | |||
       $ |     function loadTagCloud() { | ||
       $.getJSON( mw.util.wikiScript( 'api' ), { | |||
        action : 'cargoquery', | |||
        tables : 'Projects', | |||
        fields : 'tags', | |||
        limit : 500, | |||
        format : 'json' | |||
      } ).done( function ( data ) { | |||
        var freq = {}; | |||
        ( data.cargoquery || [] ).forEach( function ( r ) { | |||
          ( r.title.tags || '' ).split( ',' ).forEach( function ( t ) { | |||
            t = t.trim(); | |||
            if ( t ) { freq[ t ] = ( freq[ t ] || 0 ) + 1; } | |||
          } ); | |||
        } ); | |||
        var top = Object.keys( freq ) | |||
          .sort( function ( a, b ) { return freq[ b ] - freq[ a ]; } ) | |||
          .slice( 0, 25 ); | |||
        if ( !top.length ) { return; } | |||
        top.forEach( function ( tag ) { | |||
          $cloud.append( | |||
            $( '<span>' ) | |||
              .addClass( 'js-tag-filter' ) | |||
              .data( 'tag', tag ) | |||
              .text( tag ) | |||
          ); | |||
        } ); | |||
        $cloud.show(); | |||
      } ); | |||
    } | |||
    function loadHubMeta() { | |||
       |        mw.loader.using( 'mediawiki.api' ).then( function () { | ||
         |          new mw.Api().get( { | ||
          action : 'query', | |||
          list  : 'categorymembers', | |||
         |           cmtitle : 'Category:Projects', | ||
       |           cmsort : 'timestamp', | ||
          cmdir : 'desc', | |||
          cmlimit : 1, | |||
          cmtype : 'page', | |||
          cmprop : 'ids|title|timestamp', | |||
          format : 'json' | |||
        } ).done( function ( data ) { | |||
          var members = data.query && data.query.categorymembers; | |||
          if ( !members || !members.length ) { return; } | |||
          var name = members[ 0 ].title.replace( /^.*\//, '' ); | |||
          var date = members[ 0 ].timestamp | |||
            ? new Date( members[ 0 ].timestamp ).toLocaleDateString( | |||
              'en-GB', { day: 'numeric', month: 'short', year: 'numeric' } | |||
             ) | |||
            : '—'; | |||
          var str = name + ' — ' + date; | |||
          $( '#hub-meta-last-submission' ).text( str ); | |||
          $( '#hub-meta-last-edit' ).text( str ); | |||
         } ); | |||
       } ); | |||
     } |      } | ||
     |      // ── Card rendering ─────────────────────────────────────────────────── | ||
     |     function fileUrl( filename ) { | ||
      if ( !filename ) { return ''; } | |||
      return mw.config.get( 'wgServer' ) + | |||
        '/wiki/Special:FilePath/' + encodeURIComponent( filename ); | |||
     } | |||
 | |||
    function esc( s ) { return mw.html.escape( s || '' ); } | |||
 | |||
    function buildCard( row ) { | |||
      var page = row.pagename || ''; | |||
      var url = mw.util.getUrl( page ); | |||
      var name = row.name || page.replace( /^.*\//, '' ); | |||
 | |||
      var imgInner = row.image | |||
        ? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">' | |||
        : '<div class="project-card-image-fallback"></div>'; | |||
 | |||
      var badges = ''; | |||
      if ( row.status ) { | |||
        badges += '<span class="project-badge-status project-badge-status--' + | |||
          esc( row.status.toLowerCase() ) + '">' + esc( row.status ) + '</span>'; | |||
      } | |||
      if ( row.license ) { | |||
        badges += '<span class="project-badge-license">' + esc( row.license ) + '</span>'; | |||
      } | |||
 | |||
      var tagSpans = ''; | |||
      if ( row.tags ) { | |||
        row.tags.split( ',' ).forEach( function ( t ) { | |||
          t = t.trim(); | |||
          if ( t ) { | |||
            tagSpans += '<span class="project-card-tag js-tag-filter" data-tag="' + | |||
              esc( t ) + '">' + esc( t ) + '</span>'; | |||
          } | |||
        } ); | |||
      } | |||
      var extras = ''; | |||
      for ( var i = 1; i <= 4; i++ ) { | |||
        var lbl = row[ 'field' + i + 'label' ]; | |||
        var val = row[ 'field' + i + 'value' ]; | |||
        if ( lbl ) { | |||
          extras += '<div class="project-card-extra">' + | |||
            esc( lbl ) + ': <strong>' + esc( val ) + '</strong></div>'; | |||
        } | |||
      } | |||
      var html = [ | |||
        '<div class="project-card-wrap">', | |||
         '<div class="project-card">', | |||
          '<div class="project-card-image">', imgInner, '</div>', | |||
          '<div class="project-card-body">', | |||
           '<div class="project-card-title">', | |||
            '<a href="', url, '">', esc( name ), '</a>', | |||
           '</div>', | |||
           '<div class="project-card-badges">', badges, '</div>', | |||
           row.creator | |||
            ? '<div class="project-card-creator"><span class="project-card-label">By</span> ' + | |||
             esc( row.creator ) + '</div>' | |||
            : '', | |||
           extras, | |||
           tagSpans | |||
            ? '<div class="project-card-tags">' + tagSpans + '</div>' | |||
            : '', | |||
           '<div class="project-card-footer">', | |||
            '<span class="project-card-meta-item project-card-contributors">—</span>', | |||
            '<span class="project-card-meta-item project-card-updated">—</span>', | |||
           '</div>', | |||
          '</div>', | |||
          row.description | |||
           ? '<div class="project-card-description">' + esc( row.description ) + '</div>' | |||
           : '', | |||
         '</div>', | |||
        '</div>' | |||
      ].join( '' ); | |||
 | |||
      return $( html ); | |||
    } | |||
 | |||
    // ── Card metadata enrichment (batched API calls) ───────────────────── | |||
    function enrichCards( batch ) { | |||
      if ( !batch.length ) { return; } | |||
       var api = new mw.Api(); |        var api = new mw.Api(); | ||
 |        for ( var i = 0; i < batch.length; i += 20 ) { | ||
        ( function ( chunk ) { | |||
       |           api.get( { | ||
            action : 'query', | |||
            titles : chunk.map( function ( c ) { return c.page; } ).join( '|' ), | |||
            prop  : 'revisions|contributors', | |||
            rvprop : 'timestamp', | |||
            rvlimit : 1, | |||
            pclimit : 20, | |||
            format : 'json' | |||
          } ).done( function ( data ) { | |||
            if ( !data.query || !data.query.pages ) { return; } | |||
            $.each( data.query.pages, function ( _, page ) { | |||
              if ( page.missing !== undefined ) { return; } | |||
              var match = chunk.filter( function ( c ) { return c.page === page.title; } ); | |||
              if ( !match.length ) { return; } | |||
              var $el = match[ 0 ].$el; | |||
              var ts  = page.revisions && page.revisions[ 0 ] && | |||
                   page.revisions[ 0 ].timestamp || ''; | |||
                    |               var n  = ( page.contributors ? page.contributors.length : 0 ) + | ||
                    ( page.anoncontributors || 0 ); | |||
              var d  = ts | |||
                 ? new Date( ts ).toLocaleDateString( | |||
                  'en-GB', { year: 'numeric', month: 'short' } | |||
                 ) | |||
                 |                 : '—'; | ||
              $el.find( '.project-card-contributors' ) | |||
               .text( n + ( n === 1 ? ' contributor' : ' contributors' ) ) | |||
               .addClass( 'loaded' ); | |||
              $el.find( '.project-card-updated' ) | |||
               .text( 'Edited ' + d ) | |||
               .addClass( 'loaded' ); | |||
             } ); |              } ); | ||
           }( |            } ); | ||
        } )( batch.slice( i, i + 20 ) ); | |||
       } |        } | ||
    } | |||
  } ); // end document.ready | |||
} () ); | |||
}() ); | |||
Revision as of 00:41, 2 April 2026
/* =============================================================================
Gadget-projects.js v2.0
Unified project hub — API-powered search, tag filtering, and pagination.
============================================================================= */
( function () {
if ( mw.config.get( 'wgPageName' ) !== 'Projects' ) { return; }
$( function () {
var PAGE_SIZE = 24;
var state = {
search : '',
tag : '',
sort : 'newest',
offset : 0,
done : false,
busy : false
};
var $wrapper = $( '.project-hub-wrapper' );
if ( !$wrapper.length ) { return; }
var $filters = $wrapper.find( '.project-hub-filters' );
var $cloud = $wrapper.find( '.project-hub-tagcloud' );
var $grid = $wrapper.find( '.project-hub-grid' );
var $more = $wrapper.find( '.project-hub-loadmore' );
var $count = $( '<span>' ).addClass( 'project-hub-count' );
// ── Build filter controls ────────────────────────────────────────────
var $search = $( '<input>' ).attr( {
type : 'text',
placeholder : 'Search projects, tags, creators…',
'class' : 'project-hub-search'
} );
var $sort = $( '<select>' ).addClass( 'project-hub-select' ).append(
$( '<option>' ).val( 'newest' ).text( 'Newest first' ),
$( '<option>' ).val( 'alpha' ).text( 'A – Z' )
);
$filters.append( $search, $sort, $count );
// ── Wire events ──────────────────────────────────────────────────────
var debounce;
$search.on( 'input', function () {
clearTimeout( debounce );
debounce = setTimeout( function () {
state.search = $search.val().trim();
resetAndLoad();
}, 350 );
} );
$sort.on( 'change', function () {
state.sort = $sort.val();
resetAndLoad();
} );
$more.on( 'click', function () {
if ( !state.busy ) { fetchPage(); }
} );
// Tag click — works for both cloud tags and card tags (delegated)
$( document ).on( 'click', '.js-tag-filter', function ( e ) {
e.stopPropagation();
var tag = $( this ).data( 'tag' );
if ( state.tag === tag ) {
state.tag = '';
$( '.js-tag-filter' ).removeClass( 'is-active' );
} else {
state.tag = tag;
$( '.js-tag-filter' ).removeClass( 'is-active' );
$( this ).addClass( 'is-active' );
}
state.search = '';
$search.val( '' );
resetAndLoad();
} );
// ── Initial load ─────────────────────────────────────────────────────
loadTagCloud();
loadHubMeta();
fetchPage();
// ── Core helpers ─────────────────────────────────────────────────────
function resetAndLoad() {
state.offset = 0;
state.done = false;
$grid.empty();
$more.hide();
fetchPage();
}
function buildWhere() {
var parts = [];
if ( state.search ) {
var q = state.search.replace( /'/g, "''" );
parts.push(
"( name LIKE '%" + q + "%'" +
" OR tags LIKE '%" + q + "%'" +
" OR license LIKE '%" + q + "%'" +
" OR creator LIKE '%" + q + "%' )"
);
}
if ( state.tag ) {
parts.push( "tags HOLDS '" + state.tag.replace( /'/g, "''" ) + "'" );
}
return parts.join( ' AND ' );
}
function orderBy() {
return state.sort === 'alpha' ? 'name ASC' : '_pageID DESC';
}
// ── Data fetching ────────────────────────────────────────────────────
function fetchPage() {
if ( state.busy || state.done ) { return; }
state.busy = true;
$more.text( 'Loading…' ).show();
var params = {
action : 'cargoquery',
tables : 'Projects',
fields : '_pageName=pagename,name,status,license,creator,' +
'description,image,tags,' +
'field1label,field1value,field2label,field2value,' +
'field3label,field3value,field4label,field4value',
order_by : orderBy(),
limit : PAGE_SIZE,
offset : state.offset,
format : 'json'
};
var w = buildWhere();
if ( w ) { params.where = w; }
// Fetch total count on every first-page load
if ( state.offset === 0 ) { fetchTotal( w ); }
$.getJSON( mw.util.wikiScript( 'api' ), params )
.done( function ( data ) {
var rows = ( data.cargoquery || [] ).map( function ( r ) { return r.title; } );
var prevOff = state.offset;
var cardBatch = [];
rows.forEach( function ( row ) {
var $c = buildCard( row );
$grid.append( $c );
cardBatch.push( { $el: $c, page: row.pagename } );
} );
enrichCards( cardBatch );
state.offset += rows.length;
state.done = ( rows.length < PAGE_SIZE );
state.busy = false;
$more.text( 'Load more projects' ).toggle( !state.done );
if ( rows.length === 0 && prevOff === 0 ) {
$grid.html( '<div class="project-hub-empty">No projects found.</div>' );
}
} )
.fail( function () {
state.busy = false;
$more.text( 'Load more projects' ).toggle( !state.done );
} );
}
function fetchTotal( where ) {
var p = {
action : 'cargoquery',
tables : 'Projects',
fields : 'COUNT(*)=n',
limit : 1,
format : 'json'
};
if ( where ) { p.where = where; }
$.getJSON( mw.util.wikiScript( 'api' ), p ).done( function ( data ) {
var row = ( data.cargoquery || [] )[0];
var n = row ? ( parseInt( row.title.n, 10 ) || 0 ) : 0;
$count.text( n + ' project' + ( n === 1 ? '' : 's' ) );
} );
}
function loadTagCloud() {
$.getJSON( mw.util.wikiScript( 'api' ), {
action : 'cargoquery',
tables : 'Projects',
fields : 'tags',
limit : 500,
format : 'json'
} ).done( function ( data ) {
var freq = {};
( data.cargoquery || [] ).forEach( function ( r ) {
( r.title.tags || '' ).split( ',' ).forEach( function ( t ) {
t = t.trim();
if ( t ) { freq[ t ] = ( freq[ t ] || 0 ) + 1; }
} );
} );
var top = Object.keys( freq )
.sort( function ( a, b ) { return freq[ b ] - freq[ a ]; } )
.slice( 0, 25 );
if ( !top.length ) { return; }
top.forEach( function ( tag ) {
$cloud.append(
$( '<span>' )
.addClass( 'js-tag-filter' )
.data( 'tag', tag )
.text( tag )
);
} );
$cloud.show();
} );
}
function loadHubMeta() {
mw.loader.using( 'mediawiki.api' ).then( function () {
new mw.Api().get( {
action : 'query',
list : 'categorymembers',
cmtitle : 'Category:Projects',
cmsort : 'timestamp',
cmdir : 'desc',
cmlimit : 1,
cmtype : 'page',
cmprop : 'ids|title|timestamp',
format : 'json'
} ).done( function ( data ) {
var members = data.query && data.query.categorymembers;
if ( !members || !members.length ) { return; }
var name = members[ 0 ].title.replace( /^.*\//, '' );
var date = members[ 0 ].timestamp
? new Date( members[ 0 ].timestamp ).toLocaleDateString(
'en-GB', { day: 'numeric', month: 'short', year: 'numeric' }
)
: '—';
var str = name + ' — ' + date;
$( '#hub-meta-last-submission' ).text( str );
$( '#hub-meta-last-edit' ).text( str );
} );
} );
}
// ── Card rendering ───────────────────────────────────────────────────
function fileUrl( filename ) {
if ( !filename ) { return ''; }
return mw.config.get( 'wgServer' ) +
'/wiki/Special:FilePath/' + encodeURIComponent( filename );
}
function esc( s ) { return mw.html.escape( s || '' ); }
function buildCard( row ) {
var page = row.pagename || '';
var url = mw.util.getUrl( page );
var name = row.name || page.replace( /^.*\//, '' );
var imgInner = row.image
? '<img src="' + esc( fileUrl( row.image ) ) + '" alt="' + esc( name ) + '" loading="lazy">'
: '<div class="project-card-image-fallback"></div>';
var badges = '';
if ( row.status ) {
badges += '<span class="project-badge-status project-badge-status--' +
esc( row.status.toLowerCase() ) + '">' + esc( row.status ) + '</span>';
}
if ( row.license ) {
badges += '<span class="project-badge-license">' + esc( row.license ) + '</span>';
}
var tagSpans = '';
if ( row.tags ) {
row.tags.split( ',' ).forEach( function ( t ) {
t = t.trim();
if ( t ) {
tagSpans += '<span class="project-card-tag js-tag-filter" data-tag="' +
esc( t ) + '">' + esc( t ) + '</span>';
}
} );
}
var extras = '';
for ( var i = 1; i <= 4; i++ ) {
var lbl = row[ 'field' + i + 'label' ];
var val = row[ 'field' + i + 'value' ];
if ( lbl ) {
extras += '<div class="project-card-extra">' +
esc( lbl ) + ': <strong>' + esc( val ) + '</strong></div>';
}
}
var html = [
'<div class="project-card-wrap">',
'<div class="project-card">',
'<div class="project-card-image">', imgInner, '</div>',
'<div class="project-card-body">',
'<div class="project-card-title">',
'<a href="', url, '">', esc( name ), '</a>',
'</div>',
'<div class="project-card-badges">', badges, '</div>',
row.creator
? '<div class="project-card-creator"><span class="project-card-label">By</span> ' +
esc( row.creator ) + '</div>'
: '',
extras,
tagSpans
? '<div class="project-card-tags">' + tagSpans + '</div>'
: '',
'<div class="project-card-footer">',
'<span class="project-card-meta-item project-card-contributors">—</span>',
'<span class="project-card-meta-item project-card-updated">—</span>',
'</div>',
'</div>',
row.description
? '<div class="project-card-description">' + esc( row.description ) + '</div>'
: '',
'</div>',
'</div>'
].join( '' );
return $( html );
}
// ── Card metadata enrichment (batched API calls) ─────────────────────
function enrichCards( batch ) {
if ( !batch.length ) { return; }
var api = new mw.Api();
for ( var i = 0; i < batch.length; i += 20 ) {
( function ( chunk ) {
api.get( {
action : 'query',
titles : chunk.map( function ( c ) { return c.page; } ).join( '|' ),
prop : 'revisions|contributors',
rvprop : 'timestamp',
rvlimit : 1,
pclimit : 20,
format : 'json'
} ).done( function ( data ) {
if ( !data.query || !data.query.pages ) { return; }
$.each( data.query.pages, function ( _, page ) {
if ( page.missing !== undefined ) { return; }
var match = chunk.filter( function ( c ) { return c.page === page.title; } );
if ( !match.length ) { return; }
var $el = match[ 0 ].$el;
var ts = page.revisions && page.revisions[ 0 ] &&
page.revisions[ 0 ].timestamp || '';
var n = ( page.contributors ? page.contributors.length : 0 ) +
( page.anoncontributors || 0 );
var d = ts
? new Date( ts ).toLocaleDateString(
'en-GB', { year: 'numeric', month: 'short' }
)
: '—';
$el.find( '.project-card-contributors' )
.text( n + ( n === 1 ? ' contributor' : ' contributors' ) )
.addClass( 'loaded' );
$el.find( '.project-card-updated' )
.text( 'Edited ' + d )
.addClass( 'loaded' );
} );
} );
} )( batch.slice( i, i + 20 ) );
}
}
} ); // end document.ready
} () );