User

Difference between revisions of "Seb35/bluell.js"

< User:Seb35

(ajout d’un premier élément vide également pour les ComboBoxInputWidget afin que l’utilisateur ait un moyen de désélectionner facilement (même si sur ce type de widget c’est déjà possible avec Ctrl+A Suppr, mais pas forcément intuitif))
 
(160 intermediate revisions by the same user not shown)
Line 1: Line 1:
 
// Development in final phase of the interface for displaying contributions
 
// Development in final phase of the interface for displaying contributions
 
$( function (){
 
$( function (){
if( mw.config.get( 'wgPageName' ) !== 'User:Nicolas_NALLET' ) {
+
if( !/^LinguaLibre:Explore_the_sound_library(\/[a-z_-]+)?$/.test( mw.config.get( 'wgPageName' ) ) ) {
 
return;
 
return;
 
}
 
}
const userLanguage = mw.config.get( 'wgUserLanguage' );
+
 
 +
// Parameters
 +
const nbTotalResults = 100;  // Total number of results requested to SPARQL endpoint
 +
const nbResultsPerPage = 10; // Number of results displayed per page
 +
 
 +
// User messages to be translated
 
const messages = {
 
const messages = {
'msg-no-results': {
+
'msg-no-results': 'No results.',
en: 'No results.',
+
'msg-error': 'Error',
de: 'Keine Ergebnisse.',
+
'msg-no-record-in-language': 'We don’t have any recordings in this language yet. If you speak it, [[$1|please start recording here]]!',
fr: 'Pas de résultat.',
+
'msg-other-records': '&nbsp;(and many other results)',
//qqq: 'Message when there are no results.',
+
'button-gosearch': 'Search',
},
+
'button-gosearch-last-records': 'Last records',
'button-gosearch': {
+
'button-resetsearch': 'Reset',
en: 'Search',
+
'button-datasets': 'Datasets',
de: 'Suchen',
+
'placeholder': 'Select or type',
fr: 'Rechercher',
 
//qqq: 'Button where the user clicks to search.',
 
},
 
'button-resetsearch': {
 
en: 'Reset',
 
de: 'Löschen',
 
fr: 'Effacer',
 
//qqq: 'Button where the user clicks to reset the form.',
 
},
 
'placeholder-speaker': {
 
en: '👤 Speaker',
 
fr: '👤 Locuteur',
 
//qqq: 'Placeholder for the field Speaker.',
 
},
 
'placeholder-gender': {
 
en: '♀️ ♂️ Speaker\'s gender',
 
fr: '♀️ ♂️ Genre du locuteur',
 
//qqq: 'Placeholder for the field Gender.',
 
},
 
'placeholder-language': {
 
en: '🏳️ Language',
 
fr: '🏳️ Langue',
 
//qqq: 'Placeholder for the field Language.',
 
},
 
'placeholder-proficiency': {
 
en: '🥇 Level of proficiency',
 
fr: '🥇 Niveau de compétence',
 
//qqq: 'Placeholder for the field Proficiency.',
 
},
 
 
};
 
};
 +
 +
function i18n( msg, getjQuery ) {
 +
var element = $( '#' + msg );
 +
if( element.length && element.text() !== messages[msg] ) {
 +
return getjQuery ? element.clone() : element.text();
 +
}
 +
return getjQuery ? $( '<span>' + messages[msg] + '</span>' ) : messages[msg];
 +
}
 +
 +
const userLanguage = mw.config.get( 'wgPageContentLanguage' );
 
mw.loader.using( ['oojs', 'oojs-ui', 'mediawiki.api', 'ext.recordWizard.wikibase'], function () {
 
mw.loader.using( ['oojs', 'oojs-ui', 'mediawiki.api', 'ext.recordWizard.wikibase'], function () {
  
Line 60: Line 46:
 
};
 
};
 
const htmlElements = {
 
const htmlElements = {
'speaker': '#filteruser',
+
'speaker': '#sndlib-filteruser',
'gender': '#filtergender',
+
'gender': '#sndlib-filtergender',
'language': '#filterlanguage',
+
'language': '#sndlib-filterlanguage',
'proficiency': '#filterlevelofproficiency',
+
'proficiency': '#sndlib-filterlevelofproficiency',
 
};
 
};
 
const unknown = {};
 
const unknown = {};
  
var request = {
+
request = {
 
'speaker': '',
 
'speaker': '',
 
'gender': '',
 
'gender': '',
 
'language': '',
 
'language': '',
 
'proficiency': '',
 
'proficiency': '',
}, lastRequest = {
+
};
'speaker': '',
+
speakersCriteria = [];
'gender': '',
+
list = {
'language': '',
+
'speaker': null,
'proficiency': '',
+
'gender': null,
}, speakersCriteria = [];
+
'language': null,
const list = {
+
'proficiency': null,
 +
};
 +
mapping = {
 
'speaker': null,
 
'speaker': null,
 
'gender': null,
 
'gender': null,
 
'language': null,
 
'language': null,
 
'proficiency': null,
 
'proficiency': null,
}, mapping = {
+
};
 +
automaticField = {
 
'speaker': null,
 
'speaker': null,
 
'gender': null,
 
'gender': null,
Line 100: Line 89:
 
rawValue = rawValue.trim();
 
rawValue = rawValue.trim();
 
if( !rawValue ) {
 
if( !rawValue ) {
return '';
+
return null;
 
} else if( /^Q[0-9]+$/.test( rawValue ) ) {
 
} else if( /^Q[0-9]+$/.test( rawValue ) ) {
 
return rawValue;
 
return rawValue;
Line 124: Line 113:
 
// Returns the list of items of a given type for a restricted list of Qids
 
// Returns the list of items of a given type for a restricted list of Qids
 
function getListSelectorFromQids( type, qids ) {
 
function getListSelectorFromQids( type, qids ) {
const indexes = qids.map( function( x ) {
+
const indexes = dedupe( qids.map( function( x ) {
 
return mapping[type][x];
 
return mapping[type][x];
} ).sort();
+
} ).sort( function( a, b ) { return a-b; } ) );
return indexes.map( function( x ) {
+
const r = indexes.map( function( x ) {
 
return list[type][x];
 
return list[type][x];
 
} );
 
} );
 +
return r;
 
}
 
}
  
 
// Initialise a selector and keep the list in memory the result for later reuse
 
// Initialise a selector and keep the list in memory the result for later reuse
function updateSelector( type ) {
+
function updateSelector( type, force ) {
return function( values ) {
+
return function( values, emptyValue ) {
 +
 
 +
if( emptyValue === undefined ) {
 +
emptyValue = true;
 +
}
 +
 
 +
console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values,'+(emptyValue===true?'true':(emptyValue===null?'null':(emptyValue===false?'false':'autre')))+')' );
  
 
const widgetType = type === 'speaker' || type === 'language' ? 'ComboBoxInputWidget' : 'DropdownWidget';
 
const widgetType = type === 'speaker' || type === 'language' ? 'ComboBoxInputWidget' : 'DropdownWidget';
Line 143: Line 139:
 
// We dedupe because the 'speaker' (only) has duplicate (ordered) values
 
// We dedupe because the 'speaker' (only) has duplicate (ordered) values
 
const v = values.results.bindings;
 
const v = values.results.bindings;
var emptyItem = [ {} ];
+
list[type] = dedupe( v, function( x ) { return x[type].value; } );
emptyItem[0][type] = { value: '' };
+
if( widgetType === 'DropdownWidget' ) {
emptyItem[0][type+'Label'] = { value: ' ' };
+
var emptyItem = [ {} ];
list[type] = emptyItem.concat( dedupe( v, function( x ) { return x[type].value; } ) );
+
emptyItem[0][type] = { value: '' };
 +
emptyItem[0][type+'Label'] = { value: ' ' };
 +
list[type] = emptyItem.concat( list[type] );
 +
}
 +
values = list[type];
  
 
// Save the mapping Qxx → index to update the list from a list of Qxx (see doQuery)
 
// Save the mapping Qxx → index to update the list from a list of Qxx (see doQuery)
console.log( type, list[type] );
+
console.debug( type, list[type] );
 
mapping[type] = list[type].reduce( function( o, x, i ) {
 
mapping[type] = list[type].reduce( function( o, x, i ) {
 
o[ x[type].value.substr( 31 ) ] = i;
 
o[ x[type].value.substr( 31 ) ] = i;
Line 166: Line 166:
 
} );
 
} );
 
}
 
}
 +
}
 +
 +
// Never update manual selectors (except if the user forces the reset)
 +
if( !force && getQValue( type ) && automaticField[type] === false ) {
 +
console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): abort1' );
 +
return;
 +
}
 +
 +
if( force || values === null ) {
 +
values = list[type];
 +
}
 +
 +
// If there are at least one unknown value, reset the selector
 +
else if( values.filter( function( x ) { return x === undefined; } ).length ) {
 +
values = list[type];
 +
console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): abort2' );
 +
}
 +
 +
// For dropdowns, add an empty item if there is none
 +
if( widgetType === 'DropdownWidget' && values.length && values[0][type].value ) {
 +
var emptyItem = [ {} ];
 +
emptyItem[0][type] = { value: '' };
 +
emptyItem[0][type+'Label'] = { value: ' ' };
 +
values = emptyItem.concat( values );
 
}
 
}
  
 
// Create the OOUI selector
 
// Create the OOUI selector
 
oouiSelectors[type] = new OO.ui[widgetType]( {
 
oouiSelectors[type] = new OO.ui[widgetType]( {
placeholder: messages['placeholder-'+type][userLanguage] ? messages['placeholder-'+type][userLanguage] : messages['placeholder-'+type].en,
+
placeholder: i18n( 'placeholder' ),
 
menu: {
 
menu: {
 
filterFromInput: true,
 
filterFromInput: true,
items: list[type].map( function( x, i ) {
+
items: values.map( function( x ) {
 
return new OO.ui.MenuOptionWidget( {
 
return new OO.ui.MenuOptionWidget( {
data: widgetType === 'ComboBoxInputWidget' && i ? x[ type + 'Label' ].value + " (" + x[type].value.substr( 31 ) + ")" : x[type].value.substr( 31 ),
+
data: widgetType === 'ComboBoxInputWidget' ? x[ type + 'Label' ].value + " (" + x[type].value.substr( 31 ) + ")" : x[type].value.substr( 31 ),
 
label: x[ type + 'Label' ].value
 
label: x[ type + 'Label' ].value
 
} );
 
} );
Line 181: Line 205:
 
}
 
}
 
} );
 
} );
oouiSelectors[type].getMenu().items[0].toggle( false );
+
if( widgetType === 'DropdownWidget' && values.length > 2 ) {
 +
oouiSelectors[type].getMenu().items[0].toggle( false );
 +
}
  
if( values.length === 1 ) {
+
if( widgetType === 'ComboBoxInputWidget' && values.length === 1 && automaticField[type] !== false ) {
if( widgetType === 'ComboBoxInputWidget' ) {
+
oouiSelectors[type].setValue( values[0][ type + 'Label' ].value + " (" + values[0][type].value.substr( 31 ) + ")" );
oouiSelectors[type].setValue( v[0][ type + 'Label' ].value + " (" + v[0][type].value.substr( 31 ) + ")" );
+
// For dropdown, when there is one real value, there is also the empty value as first option
} else {
+
} else if( widgetType === 'DropdownWidget' && values.length === 2 && automaticField[type] !== false ) {
oouiSelectors[type].getMenu().selectItemByData( v[0][ type + 'Label' ].value + " (" + v[0][type].value.substr( 31 ) + ")" );
+
oouiSelectors[type].getMenu().selectItemByData( values[1][type].value.substr( 31 ) );
}
 
 
}
 
}
  
Line 194: Line 219:
 
oouiSelectors[type].$element
 
oouiSelectors[type].$element
 
);
 
);
 +
if( !force ) {
 +
console.debug( '!force', type, getQValue( type ), automaticField[type] );
 +
if( automaticField[type] !== false ) {
 +
automaticField[type] = getQValue( type ) ? true : emptyValue;
 +
}
 +
console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): on passe en automatique' );
 +
} else {
 +
automaticField[type] = null;
 +
console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): on passe en manuel' );
 +
}
  
//if( widgetType === 'ComboBoxInputWidget' ) {
+
if( widgetType === 'ComboBoxInputWidget' ) {
// oouiSelectors[type].on( 'change', onChange );
+
oouiSelectors[type].on( 'change', function() {
//} else {
+
if( getQValue( type ) === '' ) {
if( widgetType === 'DropdownWidget' ) {
+
return;
 +
}
 +
onChange( type );
 +
} );
 +
} else {
 
oouiSelectors[type].getMenu().on( 'select', function( item ) {
 
oouiSelectors[type].getMenu().on( 'select', function( item ) {
 
if( item.getData() === '' ) {
 
if( item.getData() === '' ) {
Line 205: Line 244:
 
oouiSelectors[type].getMenu().items[0].toggle( true );
 
oouiSelectors[type].getMenu().items[0].toggle( true );
 
}
 
}
 +
onChange( type );
 
} );
 
} );
 
}
 
}
  
 
};
 
};
 +
}
 +
 +
function onChange( type ) {
 +
 +
// Logique ternaire pour les sélecteurs :
 +
// - null := sélecteur indéterminé, pouvant être réduit sous l’influence d’autres sélecteurs
 +
// - false := sélecteur manuel, n’étant jamais réduit sous l’influence d’autres sélecteurs et étant toujours étendus (=comprend toutes les valeurs possibles)
 +
// - true := sélecteur automatique, étant toujours réduit sous l’influence d’autres sélecteurs
 +
 +
console.debug( 'onChange('+type+')' );
 +
var state = {
 +
speaker: getQValue( 'speaker' ),
 +
gender: getQValue( 'gender' ),
 +
language: getQValue( 'language' ),
 +
proficiency: getQValue( 'proficiency' ),
 +
};
 +
var nbFilled = 1, emptyValue = true;
 +
if( !state.speaker && !state.gender && !state.language && !state.proficiency ) {
 +
reset();
 +
return;
 +
} else if( state[type] ) {
 +
if( state[type] != request[type] ) {
 +
automaticField[type] = false;
 +
console.debug( 'onChange('+type+'): on passe en manuel' );
 +
}
 +
} else {
 +
automaticField[type] = null;
 +
emptyValue = null;
 +
nbFilled = ( automaticField.language === false && state.language ? 1 : 0 )
 +
+ ( automaticField.speaker === false && state.speaker ? 1 : 0 )
 +
+ ( automaticField.gender === false && state.gender ? 1 : 0 )
 +
+ ( automaticField.proficiency === false && state.proficiency ? 1 : 0 );
 +
if( nbFilled >= 1 ) {
 +
automaticField[type] = false;
 +
emptyValue = true;
 +
if( type !== 'speaker' && !state.speaker && automaticField.speaker !== false ) {
 +
automaticField.speaker = emptyValue;
 +
}
 +
if( type !== 'gender' && !state.gender && automaticField.gender !== false ) {
 +
automaticField.gender = emptyValue;
 +
}
 +
if( type !== 'language' && !state.language && automaticField.language !== false ) {
 +
automaticField.language = emptyValue;
 +
}
 +
if( type !== 'proficiency' && !state.proficiency && automaticField.proficiency !== false ) {
 +
automaticField.proficiency = emptyValue;
 +
}
 +
} else {
 +
emptyValue = null;
 +
if( !state.speaker ) {
 +
automaticField.speaker = emptyValue;
 +
}
 +
if( !state.gender ) {
 +
automaticField.gender = emptyValue;
 +
}
 +
if( !state.language ) {
 +
automaticField.language = emptyValue;
 +
}
 +
if( !state.proficiency ) {
 +
automaticField.proficiency = emptyValue;
 +
}
 +
}
 +
}
 +
console.debug( nbFilled, state[type], request[type], emptyValue, automaticField );
 +
 +
// Do not reduce fields above one change by the user, else it leads to a bad User Experience
 +
// because fields change themselves with a hardly-understandable logic
 +
//var nbFilled = ( state.language ? 1 : 0 ) + ( state.speaker ? 1 : 0 ) + ( state.gender ? 1 : 0 ) + ( state.proficiency ? 1 : 0 );
 +
//if( nbFilled > 1 ) {
 +
// return;
 +
//}
 +
 +
var potentialSpeakers = speakersCriteria;
 +
 +
if( state.language && automaticField.language === false ) {
 +
potentialSpeakers = reduceSpeakers( 2, state.language, potentialSpeakers );
 +
}
 +
if( state.speaker && automaticField.speaker === false ) {
 +
potentialSpeakers = reduceSpeakers( 0, state.speaker, potentialSpeakers );
 +
}
 +
if( state.gender && automaticField.gender === false ) {
 +
potentialSpeakers = reduceSpeakers( 1, state.gender, potentialSpeakers );
 +
}
 +
if( state.proficiency && automaticField.proficiency === false ) {
 +
potentialSpeakers = reduceSpeakers( 3, state.proficiency, potentialSpeakers );
 +
}
 +
 +
// Language where there are no records
 +
if( potentialSpeakers.length === 0 && !state.speaker && !state.gender && state.language && !state.proficiency ) {
 +
var msg = i18n( 'msg-no-record-in-language', true ), link = $( 'a', msg ), text = link.text();
 +
link.replaceWith( $( '<a href="'+mw.config.get( 'wgArticlePath').replace( /\$1/, 'Special:RecordWizard' )+'" title="Special:RecordWizard">'+text+'</a>' ) );
 +
$( '#sndlib-audioresults' ).html( '' );
 +
$( '#sndlib-audioPages' ).html( '' );
 +
$( '#sndlib-messages' ).html( msg );
 +
$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );
 +
} else {
 +
var listSpeaker = getListSelectorFromQids( 'speaker', dedupe( potentialSpeakers.map( function( x ) { return x[0]; } ) ) );
 +
var listGender = getListSelectorFromQids( 'gender', dedupe( potentialSpeakers.map( function( x ) { return x[1]; } ) ) );
 +
var listLanguage = getListSelectorFromQids( 'language', dedupe( potentialSpeakers.map( function( x ) { return x[2]; } ) ) );
 +
var listProficiency = getListSelectorFromQids( 'proficiency', dedupe( potentialSpeakers.map( function( x ) { return x[3]; } ) ) );
 +
 +
// Update the selectors with the restricted values
 +
updateSelector( 'speaker' )( listSpeaker, emptyValue );
 +
updateSelector( 'gender' )( listGender, emptyValue );
 +
updateSelector( 'language' )( listLanguage, emptyValue );
 +
updateSelector( 'proficiency' )( listProficiency, emptyValue );
 +
}
 +
 +
request = {
 +
speaker: getQValue( 'speaker' ),
 +
gender: getQValue( 'gender' ),
 +
language: getQValue( 'language' ),
 +
proficiency: getQValue( 'proficiency' ),
 +
};
 +
console.debug( automaticField );
 
}
 
}
  
Line 218: Line 373:
 
query: sparqlGlobal['speaker']
 
query: sparqlGlobal['speaker']
 
}
 
}
).done( updateSelector( 'speaker' ) );
+
).then( updateSelector( 'speaker', true ), displayErrorToUser );
  
 
$.getJSON(
 
$.getJSON(
Line 225: Line 380:
 
query: sparqlGlobal['gender']
 
query: sparqlGlobal['gender']
 
}
 
}
).done( updateSelector( 'gender' ) );
+
).then( updateSelector( 'gender', true ), displayErrorToUser );
  
 
$.getJSON(
 
$.getJSON(
Line 232: Line 387:
 
query: sparqlGlobal['language']
 
query: sparqlGlobal['language']
 
}
 
}
).done( updateSelector( 'language' ) );
+
).then( updateSelector( 'language', true ), displayErrorToUser );
  
 
$.getJSON(
 
$.getJSON(
Line 239: Line 394:
 
query: sparqlGlobal['proficiency']
 
query: sparqlGlobal['proficiency']
 
}
 
}
).done( updateSelector( 'proficiency' ) );
+
).then( updateSelector( 'proficiency', true ), displayErrorToUser );
 
}
 
}
  
 
function reset() {
 
function reset() {
lastRequest = {
+
request = {
 
speaker: '',
 
speaker: '',
 
gender: '',
 
gender: '',
Line 249: Line 404:
 
proficiency: '',
 
proficiency: '',
 
};
 
};
updateSelector( 'speaker' )( list.speaker );
+
automaticField = {
updateSelector( 'gender' )( list.gender );
+
'speaker': null,
updateSelector( 'language' )( list.language );
+
'gender': null,
updateSelector( 'proficiency' )( list.proficiency );
+
'language': null,
$( '#audioresults' ).html( '' );
+
'proficiency': null,
 +
};
 +
updateSelector( 'speaker', true )( list.speaker );
 +
updateSelector( 'gender', true )( list.gender );
 +
updateSelector( 'language', true )( list.language );
 +
updateSelector( 'proficiency', true )( list.proficiency );
 +
$( '#sndlib-audioresults' ).html( '' );
 +
$( '#sndlib-audioPages' ).html( '' );
 +
$( '#sndlib-messages' ).html( '' );
 +
$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );
 
}
 
}
  
Line 263: Line 427:
  
 
// Add button 'Search'
 
// Add button 'Search'
$( '#gosearch' ).html('').append(
+
$( '#sndlib-gosearch' ).html('').append(
 
( new OO.ui.ButtonInputWidget( {
 
( new OO.ui.ButtonInputWidget( {
label: messages['button-gosearch'][userLanguage] ? messages['button-gosearch'][userLanguage] : messages['button-gosearch'].en
+
label: i18n( 'button-gosearch' )
} ) ).on( 'click', doQuery ).$element
+
} ) ).on( 'click', factoryDoQuery( 'random' ) ).$element
 +
);
 +
 
 +
$( '#sndlib-gosearch-last-records' ).html('').append(
 +
( new OO.ui.ButtonInputWidget( {
 +
label: i18n( 'button-gosearch-last-records' )
 +
} ) ).on( 'click', factoryDoQuery( 'last' ) ).$element
 
);
 
);
  
 
// Add button 'Reset'
 
// Add button 'Reset'
$( '#resetsearch' ).html('').append(
+
$( '#sndlib-resetsearch' ).html('').append(
 
( new OO.ui.ButtonInputWidget( {
 
( new OO.ui.ButtonInputWidget( {
label: messages['button-resetsearch'][userLanguage] ? messages['button-resetsearch'][userLanguage] : messages['button-resetsearch'].en
+
label: i18n( 'button-resetsearch' )
 
} ) ).on( 'click', reset ).$element
 
} ) ).on( 'click', reset ).$element
 
);
 
);
 +
 +
// Add button 'Datasets' - it seems that OOUI ButtonWidget.setHref does not work
 +
var buttonDatasetsA = $( '<a href="https://lingualibre.org/datasets/"></a>' );
 +
var buttonDatasets = new OO.ui.ButtonInputWidget( {
 +
label: i18n( 'button-datasets' )
 +
} );
 +
var buttonDatasetsLinked = buttonDatasetsA.append( buttonDatasets.$element );
 +
$( '#sndlib-datasetsButton' ).html('').append( buttonDatasetsLinked );
 +
 +
function setDownloadLink( data ) {
 +
if ( data.results === undefined || data.results.bindings === undefined ) {
 +
displayErrorToUser( 'error: no result from SPARQL' );
 +
return;
 +
}
 +
 +
function escapeCsv( val ) {
 +
if( !val ) {
 +
return '""';
 +
}
 +
if( val.value ) {
 +
val = val.value;
 +
}
 +
return '"' + val.replaceAll( '"', '""' ) + '"';
 +
}
 +
 +
var headers = [
 +
[
 +
escapeCsv( 'speaker' ),
 +
escapeCsv( 'gender' ),
 +
escapeCsv( 'language' ),
 +
escapeCsv( 'proficiency' ),
 +
escapeCsv( 'word' ),
 +
escapeCsv( 'file' ),
 +
escapeCsv( 'date' ),
 +
].join( ',' )
 +
];
 +
 +
var csv = 'data:application/csv;charset=utf-8,' + encodeURIComponent( headers.concat( data.results.bindings.map( function( x ) {
 +
return [
 +
escapeCsv( x.speakerName ),
 +
escapeCsv( x.genderLabel ),
 +
escapeCsv( x.languageLabel ),
 +
escapeCsv( x.proficiencyLabel ),
 +
escapeCsv( x.word ),
 +
escapeCsv( x.file ),
 +
escapeCsv( x.date ),
 +
].join( ',' );
 +
} ) ).join( "\n" ) );
 +
 +
$('#sndlib-savefiltersearch').wrapInner( '<a style="color:white"></a>' );
 +
$('#sndlib-savefiltersearch a').attr( {
 +
download: 'export-lili.csv',
 +
href: csv,
 +
} );
 +
}
 +
 +
function displayErrorToUser( obj, error ) {
 +
displayError( obj, error );
 +
var code = obj;
 +
if( obj && obj.status ) {
 +
code = '' + obj.status;
 +
}
 +
if( obj && obj.statusText ) {
 +
code = '' + code + ' ' + obj.statusText;
 +
}
 +
$( '#sndlib-audioresults' ).html( i18n( 'msg-error' ) + ' (' + code + ')' + ( error ? ' ' + error : '' ) );
 +
}
  
 
// Display results
 
// Display results
 
function createAudioBoxesForSearch( data ) {
 
function createAudioBoxesForSearch( data ) {
 
if ( data.results === undefined || data.results.bindings === undefined ) {
 
if ( data.results === undefined || data.results.bindings === undefined ) {
displayError( 'error: no result from SPARQL' );
+
displayErrorToUser( 'error: no result from SPARQL' );
 
return;
 
return;
 
}
 
}
 +
$( '#sndlib-messages' ).html( '' );
 
if ( data.results.bindings.length < 1 ) {
 
if ( data.results.bindings.length < 1 ) {
$( '#audioresults' ).html( messages['msg-no-results'][userLanguage] ? messages['msg-no-results'][userLanguage] : messages['msg-no-results'].en );
+
$( '#sndlib-audioresults' ).html( '' );
 +
$( '#sndlib-audioPages' ).html( '' );
 +
$( '#sndlib-messages' ).html( i18n( 'msg-no-results' ) );
 
return;
 
return;
 
}
 
}
var length = data.results.bindings.length;
+
var length = data.results.bindings.length <= nbTotalResults ? data.results.bindings.length : nbTotalResults;
console.log( data.results.bindings );
+
var moreResults = data.results.bindings.length > nbTotalResults;
 +
console.debug( 'SPARQL results', data.results.bindings );
 
function displayAudioBoxes( index ) {
 
function displayAudioBoxes( index ) {
$( '#audioresults' ).html( '' );
+
$( '#sndlib-audioresults' ).html( '' );
console.log( 'index', index );
+
for( var i = 0; i < nbResultsPerPage && i+index*nbResultsPerPage < length; i++ ) {
for( var i = 0; i < 10 && i+index*10 < length; i++ ) {
 
console.log( 'i effectif', i+index*10 );
 
 
var box = $( '<div class="audiobox"> <div class="ab-playbutton"><i></i></div> <div> <div class="ab-title">...</div> <div class="ab-metadata">...</div> </div> </div>' );
 
var box = $( '<div class="audiobox"> <div class="ab-playbutton"><i></i></div> <div> <div class="ab-title">...</div> <div class="ab-metadata">...</div> </div> </div>' );
var audiobox = new AudioBox( data.results.bindings[ i+index*10 ].record.value.substr( 31 ), box );
+
var audiobox = new AudioBox( data.results.bindings[ i+index*nbResultsPerPage ].record.value.substr( 31 ), box );
$( '#audioresults' ).append( box );
+
$( '#sndlib-audioresults' ).append( box );
 
}
 
}
 
function applyFn( i ) {
 
function applyFn( i ) {
 
return function() {
 
return function() {
console.log( i );
 
 
displayAudioBoxes( i );
 
displayAudioBoxes( i );
 
return false;
 
return false;
 
}
 
}
 
}
 
}
for( var i = 0; i < Math.ceil( length/10 ); i++ ) {
+
$( '#sndlib-audioPages' ).html( '' );
var link = $( '<a href="#">' + (i+1) + '</a>' ).on( 'click', applyFn( i ) );
+
if( length > nbResultsPerPage ) {
$( '#audioresults' ).append( link ).append( ' ' );
+
for( var i = 0; i < Math.ceil( length/nbResultsPerPage ); i++ ) {
 +
var link = $( '<a href="#"' + ( i === index ? ' class="selected"' : '' ) + '>' + (i+1) + '</a>' ).on( 'click', applyFn( i ) );
 +
$( '#sndlib-audioPages' ).append( link ).append( i < Math.ceil( length/nbResultsPerPage )-1 ? '&nbsp;' : '' );
 +
}
 +
}
 +
if( moreResults ) {
 +
$( '#sndlib-audioPages' ).append( i18n( 'msg-other-records' ) );
 
}
 
}
 
}
 
}
 
displayAudioBoxes( 0 );
 
displayAudioBoxes( 0 );
 +
 +
setDownloadLink( data );
 
}
 
}
  
 
// Do SPARQL request from filters
 
// Do SPARQL request from filters
function doQuery() {
+
function factoryDoQuery( order ) {
  
request = {
+
return function doQuery() {
speaker: getQValue( 'speaker' ),
 
gender: getQValue( 'gender' ),
 
language: getQValue( 'language' ),
 
proficiency: getQValue( 'proficiency' ),
 
};
 
if( JSON.stringify( request ) === JSON.stringify( lastRequest ) ) {
 
return;
 
}
 
  
lastRequest = request;
+
request = {
 +
speaker: getQValue( 'speaker' ),
 +
gender: getQValue( 'gender' ),
 +
language: getQValue( 'language' ),
 +
proficiency: getQValue( 'proficiency' ),
 +
order: order,
 +
};
 +
console.debug( 'doQuery', request );
  
console.log( request );
+
var potentialSpeakers = speakersCriteria;
  
// When all fields are empty, do not query
+
var query = 'SELECT ?record ?speakerName ?genderLabel ?languageLabel ?proficiencyLabel ?word ?file ?date WHERE { ?record prop:P2 entity:Q2 ; prop:P4 ?language ; prop:P5 ?speaker . OPTIONAL { ?speaker llp:P4 [ llv:P4 ?language ; llq:P16 ?proficiency ] ; rdfs:label ?speakerName ; prop:P8 ?gender . FILTER( LANG( ?speakerName ) = "en" ) . ?record prop:P7 ?word ; prop:P3 ?file ; prop:P6 ?date } . ';
if( !request.speaker && !request.gender && !request.language && !request.proficiency ) {
+
if( request.language ) {
return;
+
query += "?record prop:P4 entity:" + request.language + " . ";
}
+
potentialSpeakers = reduceSpeakers( 2, request.language, potentialSpeakers );
 +
}
 +
if( request.speaker ) {
 +
query += "?record prop:P5 entity:" + request.speaker + " . ";
 +
potentialSpeakers = reduceSpeakers( 0, request.speaker, potentialSpeakers );
 +
}
 +
if( request.gender ) {
 +
query += "?speaker prop:P8 entity:" + request.gender + " . ";
 +
potentialSpeakers = reduceSpeakers( 1, request.gender, potentialSpeakers );
 +
}
 +
if( request.proficiency ) {
 +
query += "?speaker llp:P4 [ llv:P4 ?language ; llq:P16 entity:" + request.proficiency + " ] . ";
 +
potentialSpeakers = reduceSpeakers( 3, request.proficiency, potentialSpeakers );
 +
}
 +
if( potentialSpeakers.length < speakersCriteria.length ) {
 +
var listSpeakers = dedupe( potentialSpeakers.map( function( x ) {
 +
return 'entity:' + x[0];
 +
} ) ).join();
 +
console.debug( speakersCriteria, potentialSpeakers, listSpeakers );
 +
if( listSpeakers.length < 70 ) {
 +
query += 'FILTER( ?speaker IN (' + listSpeakers + ') )';
 +
}
 +
}
 +
query += 'SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } }';
 +
if( order === 'random' ) {
 +
query += ' ORDER BY RAND()';
 +
} else if( order === 'last' ) {
 +
query += ' ORDER BY DESC(?date)';
 +
}
 +
query += ' LIMIT ' + (nbTotalResults+1);
 +
if( order === 'random' ) {
 +
query += '\n# ' + Date.now();
 +
}
  
var potentialSpeakers = speakersCriteria;
+
// Voir https://commons.wikimedia.org/wiki/Category:Throbbers - il faut que le fond soit transparent
 +
$( '#sndlib-audioresults' ).html( '' ).append( '<img src="https://upload.wikimedia.org/wikipedia/commons/7/7a/Balls.gif" width="50" />' );
 +
$( '#sndlib-audioPages' ).html( '' );
 +
$( '#sndlib-messages' ).html( '' );
 +
$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );
  
var query = "SELECT ?record WHERE { ?record prop:P2 entity:Q2 ; prop:P4 ?language ; prop:P5 ?speaker . ";
+
// Execute the request
if( request.language ) {
+
var result = $.getJSON(
query += "?record prop:P4 entity:" + request.language + " . ";
+
'https://lingualibre.org/bigdata/namespace/wdq/sparql',
potentialSpeakers = reduceSpeakers( 2, request.language, potentialSpeakers );
+
{
}
+
query: query,
if( request.speaker ) {
+
//Accept: 'application/sparql-results+json'
query += "?record prop:P5 entity:" + request.speaker + " . ";
+
}
potentialSpeakers = reduceSpeakers( 0, request.speaker, potentialSpeakers );
+
);
}
 
if( request.gender ) {
 
potentialSpeakers = reduceSpeakers( 1, request.gender, potentialSpeakers );
 
}
 
if( request.proficiency ) {
 
potentialSpeakers = reduceSpeakers( 3, request.proficiency, potentialSpeakers );
 
query += "?speaker llp:P4 [ llv:P4 ?language ; llq:P16 entity:" + request.proficiency + " ] . ";
 
}
 
if( potentialSpeakers.length < speakersCriteria.length ) {
 
var listSpeakers = dedupe( potentialSpeakers.map( function( x ) {
 
return 'entity:' + x[0];
 
} ) ).join();
 
query += 'FILTER( ?speaker IN (' + listSpeakers + ') )';
 
}
 
query += "} LIMIT 100";
 
 
 
// Voir https://commons.wikimedia.org/wiki/Category:Throbbers - il faut que le fond soit transparent
 
$( '#audioresults' ).html( '' ).append( '<img src="https://upload.wikimedia.org/wikipedia/commons/7/7a/Balls.gif" width="50" />' );
 
  
console.log( potentialSpeakers );
+
result.then( createAudioBoxesForSearch, displayErrorToUser );
console.log( listSpeakers );
 
 
 
// Execute the request
 
var result = $.getJSON(
 
'https://lingualibre.org/bigdata/namespace/wdq/sparql',
 
{
 
query: query,
 
//Accept: 'application/sparql-results+json'
 
}
 
);
 
 
 
// Update the selectors with the restricted values
 
// This could have been done a bit earlier, but we launched the SPARQL request before
 
if( request.speaker ) {
 
updateSelector( 'speaker' )( getListSelectorFromQids( 'speaker', potentialSpeakers.map( function( x ) { return x[0]; } ) ) );
 
}
 
if( request.gender ) {
 
updateSelector( 'gender' )( getListSelectorFromQids( 'gender', potentialSpeakers.map( function( x ) { return x[1]; } ) ) );
 
}
 
if( request.language ) {
 
updateSelector( 'language' )( getListSelectorFromQids( 'language', potentialSpeakers.map( function( x ) { return x[2]; } ) ) );
 
}
 
if( request.proficiency ) {
 
updateSelector( 'proficiency' )( getListSelectorFromQids( 'proficiency', potentialSpeakers.map( function( x ) { return x[3]; } ) ) );
 
 
}
 
}
 
result.then( createAudioBoxesForSearch, displayError );
 
 
}
 
}
  

Latest revision as of 11:58, 8 October 2021

// Development in final phase of the interface for displaying contributions
$( function (){
	if( !/^LinguaLibre:Explore_the_sound_library(\/[a-z_-]+)?$/.test( mw.config.get( 'wgPageName' ) ) ) {
		return;
	}

	// Parameters
	const nbTotalResults = 100;  // Total number of results requested to SPARQL endpoint
	const nbResultsPerPage = 10; // Number of results displayed per page

	// User messages to be translated
	const messages = {
		'msg-no-results': 'No results.',
		'msg-error': 'Error',
		'msg-no-record-in-language': 'We don’t have any recordings in this language yet. If you speak it, [[$1|please start recording here]]!',
		'msg-other-records': '&nbsp;(and many other results)',
		'button-gosearch': 'Search',
		'button-gosearch-last-records': 'Last records',
		'button-resetsearch': 'Reset',
		'button-datasets': 'Datasets',
		'placeholder': 'Select or type',
	};

	function i18n( msg, getjQuery ) {
		var element = $( '#' + msg );
		if( element.length && element.text() !== messages[msg] ) {
			return getjQuery ? element.clone() : element.text();
		}
		return getjQuery ? $( '<span>' + messages[msg] + '</span>' ) : messages[msg];
	}

	const userLanguage = mw.config.get( 'wgPageContentLanguage' );
	mw.loader.using( ['oojs', 'oojs-ui', 'mediawiki.api', 'ext.recordWizard.wikibase'], function () {

		oouiSelectors = {
			'speaker': null,
			'gender': null,
			'language': null,
			'proficiency': null,
		};
		var sparqlGlobal = {
			'speaker': 'SELECT ?speaker ?speakerLabel ?gender ?language ?proficiency WHERE { ?speaker prop:P2 entity:Q3 . OPTIONAL { ?speaker prop:P8 ?gender } . OPTIONAL { ?speaker llp:P4 ?statement . ?statement llv:P4 ?language . OPTIONAL { ?statement llq:P16 ?proficiency } } . SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?speakerLabel',
			'gender': 'SELECT DISTINCT ?gender ?genderLabel WHERE { ?gender prop:P2 entity:Q7 . SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?gender',
			'language': 'SELECT DISTINCT ?language ?languageLabel WHERE { ?language prop:P2 entity:Q4 . SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?languageLabel',
			'proficiency': 'SELECT DISTINCT ?proficiency ?proficiencyLabel WHERE { ?proficiency prop:P2 entity:Q5 . SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?proficiency',
		};
		const htmlElements = {
			'speaker': '#sndlib-filteruser',
			'gender': '#sndlib-filtergender',
			'language': '#sndlib-filterlanguage',
			'proficiency': '#sndlib-filterlevelofproficiency',
		};
		const unknown = {};

		request = {
			'speaker': '',
			'gender': '',
			'language': '',
			'proficiency': '',
		};
		speakersCriteria = [];
		list = {
			'speaker': null,
			'gender': null,
			'language': null,
			'proficiency': null,
		};
		mapping = {
			'speaker': null,
			'gender': null,
			'language': null,
			'proficiency': null,
		};
		automaticField = {
			'speaker': null,
			'gender': null,
			'language': null,
			'proficiency': null,
		};

		// Helper function to obtain the Qvalue of some selector
		function getQValue( type ) {
			var rawValue;
			if( type === 'speaker' || type === 'language' ) {
				rawValue = oouiSelectors[type].getValue();
			} else {
				rawValue = oouiSelectors[type].getMenu().findSelectedItem() ? oouiSelectors[type].getMenu().findSelectedItem().getData() : '';
			}
			rawValue = rawValue.trim();
			if( !rawValue ) {
				return null;
			} else if( /^Q[0-9]+$/.test( rawValue ) ) {
				return rawValue;
			} else if( /\((Q[0-9]+)\)$/.test( rawValue ) ) {
				return rawValue.replace( /.*\((Q[0-9]+)\)$/, '$1' );
			} else {
				return '';
			}
		}

		// Helper function to dedupe a sorted list
		function dedupe( arr, fn ) {
			if( fn ) {
				return arr.filter( function( x, i, a ) {
					return !i || fn( x ) !== fn( a[i-1] );
				} );
			}
			return arr.filter( function( x, i, a ) {
				return !i || x !== a[i-1];
			} );
		}

		// Returns the list of items of a given type for a restricted list of Qids
		function getListSelectorFromQids( type, qids ) {
			const indexes = dedupe( qids.map( function( x ) {
				return mapping[type][x];
			} ).sort( function( a, b ) { return a-b; } ) );
			const r = indexes.map( function( x ) {
				return list[type][x];
			} );
			return r;
		}

		// Initialise a selector and keep the list in memory the result for later reuse
		function updateSelector( type, force ) {
			return function( values, emptyValue ) {

				if( emptyValue === undefined ) {
					emptyValue = true;
				}

				console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values,'+(emptyValue===true?'true':(emptyValue===null?'null':(emptyValue===false?'false':'autre')))+')' );

				const widgetType = type === 'speaker' || type === 'language' ? 'ComboBoxInputWidget' : 'DropdownWidget';

				if( !list[type] ) {

					// Save the list in a global variable to quickly filter then
					// We dedupe because the 'speaker' (only) has duplicate (ordered) values
					const v = values.results.bindings;
					list[type] = dedupe( v, function( x ) { return x[type].value; } );
					if( widgetType === 'DropdownWidget' ) {
						var emptyItem = [ {} ];
						emptyItem[0][type] = { value: '' };
						emptyItem[0][type+'Label'] = { value: ' ' };
						list[type] = emptyItem.concat( list[type] );
					}
					values = list[type];

					// Save the mapping Qxx → index to update the list from a list of Qxx (see doQuery)
					console.debug( type, list[type] );
					mapping[type] = list[type].reduce( function( o, x, i ) {
						o[ x[type].value.substr( 31 ) ] = i;
						return o;
					}, {} );

					// Save the global matrix: speaker x gender x language x proficiency
					if( type === 'speaker' ) {
						speakersCriteria = v.map( function( x ) {
							return [
								x.speaker.value.substr( 31 ),
								!x.gender || x.gender.type === 'bnode' ? unknown : x.gender.value.substr( 31 ),
								!x.language || x.language.type === 'bnode' ? unknown : x.language.value.substr( 31 ),
								!x.proficiency || x.proficiency.type === 'bnode' ? unknown : x.proficiency.value.substr( 31 ),
							];
						} );
					}
				}

				// Never update manual selectors (except if the user forces the reset)
				if( !force && getQValue( type ) && automaticField[type] === false ) {
					console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): abort1' );
					return;
				}

				if( force || values === null ) {
					values = list[type];
				}

				// If there are at least one unknown value, reset the selector
				else if( values.filter( function( x ) { return x === undefined; } ).length ) {
					values = list[type];
					console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): abort2' );
				}

				// For dropdowns, add an empty item if there is none
				if( widgetType === 'DropdownWidget' && values.length && values[0][type].value ) {
					var emptyItem = [ {} ];
					emptyItem[0][type] = { value: '' };
					emptyItem[0][type+'Label'] = { value: ' ' };
					values = emptyItem.concat( values );
				}

				// Create the OOUI selector
				oouiSelectors[type] = new OO.ui[widgetType]( {
					placeholder: i18n( 'placeholder' ),
					menu: {
						filterFromInput: true,				
						items: values.map( function( x ) {
							return new OO.ui.MenuOptionWidget( {
								data: widgetType === 'ComboBoxInputWidget' ? x[ type + 'Label' ].value + " (" + x[type].value.substr( 31 ) + ")" : x[type].value.substr( 31 ),
								label: x[ type + 'Label' ].value
							} );
						} )
					}
				} );
				if( widgetType === 'DropdownWidget' && values.length > 2 ) {
					oouiSelectors[type].getMenu().items[0].toggle( false );
				}

				if( widgetType === 'ComboBoxInputWidget' && values.length === 1 && automaticField[type] !== false ) {
					oouiSelectors[type].setValue( values[0][ type + 'Label' ].value + " (" + values[0][type].value.substr( 31 ) + ")" );
				// For dropdown, when there is one real value, there is also the empty value as first option
				} else if( widgetType === 'DropdownWidget' && values.length === 2 && automaticField[type] !== false ) {
					oouiSelectors[type].getMenu().selectItemByData( values[1][type].value.substr( 31 ) );
				}

				$( htmlElements[type] ).html('').append(
					oouiSelectors[type].$element
				);
				if( !force ) {
					console.debug( '!force', type, getQValue( type ), automaticField[type] );
					if( automaticField[type] !== false ) {
						automaticField[type] = getQValue( type ) ? true : emptyValue;
					}
					console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): on passe en automatique' );
				} else {
					automaticField[type] = null;
					console.debug( 'updateSelector('+type+','+force+')('+(values?values.length:'null')+' values): on passe en manuel' );
				}

				if( widgetType === 'ComboBoxInputWidget' ) {
					oouiSelectors[type].on( 'change', function() {
						if( getQValue( type ) === '' ) {
							return;
						}
						onChange( type );
					} );
				} else {
					oouiSelectors[type].getMenu().on( 'select', function( item ) {
						if( item.getData() === '' ) {
							item.toggle( false );
						} else {
							oouiSelectors[type].getMenu().items[0].toggle( true );
						}
						onChange( type );
					} );
				}

			};
		}

		function onChange( type ) {

			// Logique ternaire pour les sélecteurs :
			// - null := sélecteur indéterminé, pouvant être réduit sous l’influence d’autres sélecteurs
			// - false := sélecteur manuel, n’étant jamais réduit sous l’influence d’autres sélecteurs et étant toujours étendus (=comprend toutes les valeurs possibles)
			// - true := sélecteur automatique, étant toujours réduit sous l’influence d’autres sélecteurs

			console.debug( 'onChange('+type+')' );
			var state = {
				speaker: getQValue( 'speaker' ),
				gender: getQValue( 'gender' ),
				language: getQValue( 'language' ),
				proficiency: getQValue( 'proficiency' ),
			};
			var nbFilled = 1, emptyValue = true;
			if( !state.speaker && !state.gender && !state.language && !state.proficiency ) {
				reset();
				return;
			} else if( state[type] ) {
				if( state[type] != request[type] ) {
					automaticField[type] = false;
					console.debug( 'onChange('+type+'): on passe en manuel' );
				}
			} else {
				automaticField[type] = null;
				emptyValue = null;
				nbFilled = ( automaticField.language === false && state.language ? 1 : 0 )
					+ ( automaticField.speaker === false && state.speaker ? 1 : 0 )
					+ ( automaticField.gender === false && state.gender ? 1 : 0 )
					+ ( automaticField.proficiency === false && state.proficiency ? 1 : 0 );
				if( nbFilled >= 1 ) {
					automaticField[type] = false;
					emptyValue = true;
					if( type !== 'speaker' && !state.speaker && automaticField.speaker !== false ) {
						automaticField.speaker = emptyValue;
					}
					if( type !== 'gender' && !state.gender && automaticField.gender !== false ) {
						automaticField.gender = emptyValue;
					}
					if( type !== 'language' && !state.language && automaticField.language !== false ) {
						automaticField.language = emptyValue;
					}
					if( type !== 'proficiency' && !state.proficiency && automaticField.proficiency !== false ) {
						automaticField.proficiency = emptyValue;
					}
				} else {
					emptyValue = null;
					if( !state.speaker ) {
						automaticField.speaker = emptyValue;
					}
					if( !state.gender ) {
						automaticField.gender = emptyValue;
					}
					if( !state.language ) {
						automaticField.language = emptyValue;
					}
					if( !state.proficiency ) {
						automaticField.proficiency = emptyValue;
					}
				}
			}
			console.debug( nbFilled, state[type], request[type], emptyValue, automaticField );

			// Do not reduce fields above one change by the user, else it leads to a bad User Experience
			// because fields change themselves with a hardly-understandable logic
			//var nbFilled = ( state.language ? 1 : 0 ) + ( state.speaker ? 1 : 0 ) + ( state.gender ? 1 : 0 ) + ( state.proficiency ? 1 : 0 );
			//if( nbFilled > 1 ) {
			//	return;
			//}

			var potentialSpeakers = speakersCriteria;

			if( state.language && automaticField.language === false ) {
				potentialSpeakers = reduceSpeakers( 2, state.language, potentialSpeakers );
			}
			if( state.speaker && automaticField.speaker === false ) {
				potentialSpeakers = reduceSpeakers( 0, state.speaker, potentialSpeakers );
			}
			if( state.gender && automaticField.gender === false ) {
				potentialSpeakers = reduceSpeakers( 1, state.gender, potentialSpeakers );
			}
			if( state.proficiency && automaticField.proficiency === false ) {
				potentialSpeakers = reduceSpeakers( 3, state.proficiency, potentialSpeakers );
			}

			// Language where there are no records
			if( potentialSpeakers.length === 0 && !state.speaker && !state.gender && state.language && !state.proficiency ) {
				var msg = i18n( 'msg-no-record-in-language', true ), link = $( 'a', msg ), text = link.text();
				link.replaceWith( $( '<a href="'+mw.config.get( 'wgArticlePath').replace( /\$1/, 'Special:RecordWizard' )+'" title="Special:RecordWizard">'+text+'</a>' ) );
				$( '#sndlib-audioresults' ).html( '' );
				$( '#sndlib-audioPages' ).html( '' );
				$( '#sndlib-messages' ).html( msg );
				$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );
			} else {
				var listSpeaker = getListSelectorFromQids( 'speaker', dedupe( potentialSpeakers.map( function( x ) { return x[0]; } ) ) );
				var listGender = getListSelectorFromQids( 'gender', dedupe( potentialSpeakers.map( function( x ) { return x[1]; } ) ) );
				var listLanguage = getListSelectorFromQids( 'language', dedupe( potentialSpeakers.map( function( x ) { return x[2]; } ) ) );
				var listProficiency = getListSelectorFromQids( 'proficiency', dedupe( potentialSpeakers.map( function( x ) { return x[3]; } ) ) );

				// Update the selectors with the restricted values
				updateSelector( 'speaker' )( listSpeaker, emptyValue );
				updateSelector( 'gender' )( listGender, emptyValue );
				updateSelector( 'language' )( listLanguage, emptyValue );
				updateSelector( 'proficiency' )( listProficiency, emptyValue );
			}

			request = {
				speaker: getQValue( 'speaker' ),
				gender: getQValue( 'gender' ),
				language: getQValue( 'language' ),
				proficiency: getQValue( 'proficiency' ),
			};
			console.debug( automaticField );
		}

		function initSelectors() {

			$.getJSON(
				'https://lingualibre.org/bigdata/namespace/wdq/sparql',
				{
					query: sparqlGlobal['speaker']
				}
			).then( updateSelector( 'speaker', true ), displayErrorToUser );

			$.getJSON(
				'https://lingualibre.org/bigdata/namespace/wdq/sparql',
				{
					query: sparqlGlobal['gender']
				}
			).then( updateSelector( 'gender', true ), displayErrorToUser );

			$.getJSON(
				'https://lingualibre.org/bigdata/namespace/wdq/sparql',
				{
					query: sparqlGlobal['language']
				}
			).then( updateSelector( 'language', true ), displayErrorToUser );

			$.getJSON(
				'https://lingualibre.org/bigdata/namespace/wdq/sparql',
				{
					query: sparqlGlobal['proficiency']
				}
			).then( updateSelector( 'proficiency', true ), displayErrorToUser );
		}

		function reset() {
			request = {
				speaker: '',
				gender: '',
				language: '',
				proficiency: '',
			};
			automaticField = {
				'speaker': null,
				'gender': null,
				'language': null,
				'proficiency': null,
			};
			updateSelector( 'speaker', true )( list.speaker );
			updateSelector( 'gender', true )( list.gender );
			updateSelector( 'language', true )( list.language );
			updateSelector( 'proficiency', true )( list.proficiency );
			$( '#sndlib-audioresults' ).html( '' );
			$( '#sndlib-audioPages' ).html( '' );
			$( '#sndlib-messages' ).html( '' );
			$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );
		}

		function reduceSpeakers( index, value, arr ) {
			return arr.filter( function( x ) {
				return x[index] === value;
			} );
		}

		// Add button 'Search'
		$( '#sndlib-gosearch' ).html('').append(
			( new OO.ui.ButtonInputWidget( {
				label: i18n( 'button-gosearch' )
			} ) ).on( 'click', factoryDoQuery( 'random' ) ).$element
		);

		$( '#sndlib-gosearch-last-records' ).html('').append(
			( new OO.ui.ButtonInputWidget( {
				label: i18n( 'button-gosearch-last-records' )
			} ) ).on( 'click', factoryDoQuery( 'last' ) ).$element
		);

		// Add button 'Reset'
		$( '#sndlib-resetsearch' ).html('').append(
			( new OO.ui.ButtonInputWidget( {
				label: i18n( 'button-resetsearch' )
			} ) ).on( 'click', reset ).$element
		);

		// Add button 'Datasets' - it seems that OOUI ButtonWidget.setHref does not work
		var buttonDatasetsA = $( '<a href="https://lingualibre.org/datasets/"></a>' );
		var buttonDatasets = new OO.ui.ButtonInputWidget( {
				label: i18n( 'button-datasets' )
			} );
		var buttonDatasetsLinked = buttonDatasetsA.append( buttonDatasets.$element );
		$( '#sndlib-datasetsButton' ).html('').append( buttonDatasetsLinked );

		function setDownloadLink( data ) {
			if ( data.results === undefined || data.results.bindings === undefined ) {
				displayErrorToUser( 'error: no result from SPARQL' );
				return;
			}

			function escapeCsv( val ) {
				if( !val ) {
					return '""';
				}
				if( val.value ) {
					val = val.value;
				}
				return '"' + val.replaceAll( '"', '""' ) + '"';
			}

			var headers = [
				[
					escapeCsv( 'speaker' ),
					escapeCsv( 'gender' ),
					escapeCsv( 'language' ),
					escapeCsv( 'proficiency' ),
					escapeCsv( 'word' ),
					escapeCsv( 'file' ),
					escapeCsv( 'date' ),
				].join( ',' )
			];
			
			var csv = 'data:application/csv;charset=utf-8,' + encodeURIComponent( headers.concat( data.results.bindings.map( function( x ) {
					return [
						escapeCsv( x.speakerName ),
						escapeCsv( x.genderLabel ),
						escapeCsv( x.languageLabel ),
						escapeCsv( x.proficiencyLabel ),
						escapeCsv( x.word ),
						escapeCsv( x.file ),
						escapeCsv( x.date ),
					].join( ',' );
				} ) ).join( "\n" ) );

			$('#sndlib-savefiltersearch').wrapInner( '<a style="color:white"></a>' );
			$('#sndlib-savefiltersearch a').attr( {
				download: 'export-lili.csv',
				href: csv,
			} );
		}

		function displayErrorToUser( obj, error ) {
			displayError( obj, error );
			var code = obj;
			if( obj && obj.status ) {
				code = '' + obj.status;
			}
			if( obj && obj.statusText ) {
				code = '' + code + ' ' + obj.statusText;
			}
			$( '#sndlib-audioresults' ).html( i18n( 'msg-error' ) + ' (' + code + ')' + ( error ? ' ' + error : '' ) );
		}

		// Display results
		function createAudioBoxesForSearch( data ) {
			if ( data.results === undefined || data.results.bindings === undefined ) {
				displayErrorToUser( 'error: no result from SPARQL' );
				return;
			}
			$( '#sndlib-messages' ).html( '' );
			if ( data.results.bindings.length < 1 ) {
				$( '#sndlib-audioresults' ).html( '' );
				$( '#sndlib-audioPages' ).html( '' );
				$( '#sndlib-messages' ).html( i18n( 'msg-no-results' ) );
				return;
			}
			var length = data.results.bindings.length <= nbTotalResults ? data.results.bindings.length : nbTotalResults;
			var moreResults = data.results.bindings.length > nbTotalResults;
			console.debug( 'SPARQL results', data.results.bindings );
			function displayAudioBoxes( index ) {
				$( '#sndlib-audioresults' ).html( '' );
				for( var i = 0; i < nbResultsPerPage && i+index*nbResultsPerPage < length; i++ ) {
					var box = $( '<div class="audiobox"> <div class="ab-playbutton"><i></i></div> <div> <div class="ab-title">...</div> <div class="ab-metadata">...</div> </div> </div>' );
					var audiobox = new AudioBox( data.results.bindings[ i+index*nbResultsPerPage ].record.value.substr( 31 ), box );
					$( '#sndlib-audioresults' ).append( box );
				}
				function applyFn( i ) {
					return function() {
						displayAudioBoxes( i );
						return false;
					}
				}
				$( '#sndlib-audioPages' ).html( '' );
				if( length > nbResultsPerPage ) {
					for( var i = 0; i < Math.ceil( length/nbResultsPerPage ); i++ ) {
						var link = $( '<a href="#"' + ( i === index ? ' class="selected"' : '' ) + '>' + (i+1) + '</a>' ).on( 'click', applyFn( i ) );
						$( '#sndlib-audioPages' ).append( link ).append( i < Math.ceil( length/nbResultsPerPage )-1 ? '&nbsp;' : '' );
					}
				}
				if( moreResults ) {
					$( '#sndlib-audioPages' ).append( i18n( 'msg-other-records' ) );
				}
			}
			displayAudioBoxes( 0 );

			setDownloadLink( data );
		}

		// Do SPARQL request from filters
		function factoryDoQuery( order ) {

			return function doQuery() {

				request = {
					speaker: getQValue( 'speaker' ),
					gender: getQValue( 'gender' ),
					language: getQValue( 'language' ),
					proficiency: getQValue( 'proficiency' ),
					order: order,
				};
				console.debug( 'doQuery', request );

				var potentialSpeakers = speakersCriteria;

				var query = 'SELECT ?record ?speakerName ?genderLabel ?languageLabel ?proficiencyLabel ?word ?file ?date WHERE { ?record prop:P2 entity:Q2 ; prop:P4 ?language ; prop:P5 ?speaker . OPTIONAL { ?speaker llp:P4 [ llv:P4 ?language ; llq:P16 ?proficiency ] ; rdfs:label ?speakerName ; prop:P8 ?gender . FILTER( LANG( ?speakerName ) = "en" ) . ?record prop:P7 ?word ; prop:P3 ?file ; prop:P6 ?date } . ';
				if( request.language ) {
					query += "?record prop:P4 entity:" + request.language + " . ";
					potentialSpeakers = reduceSpeakers( 2, request.language, potentialSpeakers );
				}
				if( request.speaker ) {
					query += "?record prop:P5 entity:" + request.speaker + " . ";
					potentialSpeakers = reduceSpeakers( 0, request.speaker, potentialSpeakers );
				}
				if( request.gender ) {
					query += "?speaker prop:P8 entity:" + request.gender + " . ";
					potentialSpeakers = reduceSpeakers( 1, request.gender, potentialSpeakers );
				}
				if( request.proficiency ) {
					query += "?speaker llp:P4 [ llv:P4 ?language ; llq:P16 entity:" + request.proficiency + " ] . ";
					potentialSpeakers = reduceSpeakers( 3, request.proficiency, potentialSpeakers );
				}
				if( potentialSpeakers.length < speakersCriteria.length ) {
					var listSpeakers = dedupe( potentialSpeakers.map( function( x ) {
						return 'entity:' + x[0];
					} ) ).join();
					console.debug( speakersCriteria, potentialSpeakers, listSpeakers );
					if( listSpeakers.length < 70 ) {
						query += 'FILTER( ?speaker IN (' + listSpeakers + ') )';
					}
				}
				query += 'SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } }';
				if( order === 'random' ) {
					query += ' ORDER BY RAND()';
				} else if( order === 'last' ) {
					query += ' ORDER BY DESC(?date)';
				}
				query += ' LIMIT ' + (nbTotalResults+1);
				if( order === 'random' ) {
					query += '\n# ' + Date.now();
				}

				// Voir https://commons.wikimedia.org/wiki/Category:Throbbers - il faut que le fond soit transparent
				$( '#sndlib-audioresults' ).html( '' ).append( '<img src="https://upload.wikimedia.org/wikipedia/commons/7/7a/Balls.gif" width="50" />' );
				$( '#sndlib-audioPages' ).html( '' );
				$( '#sndlib-messages' ).html( '' );
				$( '#sndlib-savefiltersearch' ).html( $( '#sndlib-savefiltersearch' ).text() );

				// Execute the request
				var result = $.getJSON(
					'https://lingualibre.org/bigdata/namespace/wdq/sparql',
					{
						query: query,
						//Accept: 'application/sparql-results+json'
					}
				);

				result.then( createAudioBoxesForSearch, displayErrorToUser );
			}
		}

		initSelectors();

	} );

} );