User

Difference between revisions of "Elfix/MediaWiki:SoundLibrary.js"

(Filtering by UUID causes side effects on small result sets (e.g. Martinique Creole). Removing filter (small performance impact).)
(attempt to optimize by moving ORDER BY out of SPARQL query and into browser)
Line 111: Line 111:
 
};
 
};
 
var sparqlGlobal = {
 
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 FILTER (!ISBLANK(?proficiency)) } } SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?speakerLabel',
+
'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 FILTER (!ISBLANK(?proficiency)) } } SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } }',
 
'gender': 'SELECT ?gender ?genderLabel WHERE { ?gender prop:P2 entity:Q7 FILTER EXISTS { [] prop:P5 [ prop:P8 ?gender ]  }. SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?gender',
 
'gender': 'SELECT ?gender ?genderLabel WHERE { ?gender prop:P2 entity:Q7 FILTER EXISTS { [] prop:P5 [ prop:P8 ?gender ]  }. SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?gender',
 
'language': 'SELECT ?language (SAMPLE(?languageLabelGroup) as ?languageLabel) WHERE { ?language prop:P2 entity:Q4 . FILTER EXISTS { _:record prop:P4 ?language } SERVICE wikibase:label { bd:serviceParam wikibase:language "'+ userLanguage +'fr,en" . ?language rdfs:label ?languageLabelGroup } } GROUP BY ?language ORDER BY ?languageLabel',
 
'language': 'SELECT ?language (SAMPLE(?languageLabelGroup) as ?languageLabel) WHERE { ?language prop:P2 entity:Q4 . FILTER EXISTS { _:record prop:P4 ?language } SERVICE wikibase:label { bd:serviceParam wikibase:language "'+ userLanguage +'fr,en" . ?language rdfs:label ?languageLabelGroup } } GROUP BY ?language ORDER BY ?languageLabel',
Line 208: Line 208:
  
 
// Save the list in a global variable to quickly filter then
 
// Save the list in a global variable to quickly filter then
// We dedupe because the 'speaker' (only) has duplicate (ordered) values
+
// We dedupe because the 'speaker' (only) has duplicate values
const v = values.results.bindings;
+
const v = type === 'speaker' ?
 +
values.results.bindings.sort(function(v1, v2) {return v1.speakerLabel.localeCompare(s2.speakerLabel);})  :
 +
values.results.bindings;
 
list[type] = dedupe( v, function( x ) { return x[type].value; } );
 
list[type] = dedupe( v, function( x ) { return x[type].value; } );
 
if( widgetType === 'DropdownWidget' ) {
 
if( widgetType === 'DropdownWidget' ) {

Revision as of 14:35, 20 May 2023

/* ************************************************************************* */
/* SOUND LIBRARY *********************************************************** */
// HTML : [[User:Yug/LinguaLibre:Explore_the_sound_library]]
// CSS : [[MediaWiki:Common.css]]
// JS: [[Special:MyPage/Common.js]] calling [[User:Yug/MediaWiki:SoundLibrary.js]]. See [[:mw:Adding_JavaScript_to_Wiki_Pages#Separate_script]].
// T206801 - Links to Wikipedia contain "+" (instead of "_")
if ( $( 'div#P19 .wb-external-id' ).length ) {
	$( 'div#P19 .wb-external-id' ).attr( 'href', $( 'div#P19 .wb-external-id' ).attr( 'href' ).replace( /\+/g, '_' ) );
}

		var AudioBox = function(recordQid, $node) {
  this.wbRecord = new mw.recordWizard.wikibase.Item(recordQid);

  this.$node = $node;
  this.audioNode = document.createElement('audio');
  this.audioNode.preload = 'auto';

  this.api = new mw.Api();

  this.recordQid = recordQid;
  this.langQid = null;
  this.speakerQid = null;

  this.label = '';
  this.media = '';
  this.lang = '';
  this.speaker = '';

  this.wbRecord.getFromApi(this.api).then(this.processRecord.bind(this), displayError);
}

AudioBox.prototype.processRecord = function() {
  this.label = this.wbRecord.getLabel('en');
  this.media = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + this.wbRecord.getStatements('P3')[0].getValue();
  this.langQid = this.wbRecord.getStatements('P4')[0].getValue();
  this.speakerQid = this.wbRecord.getStatements('P5')[0].getValue();

  this.api.get({
    action: "wbgetentities",
    format: "json",
    ids: this.langQid + '|' + this.speakerQid,
    props: "labels",
    languages: mw.config.get('wgUserLanguage') + "|en",
    languagefallback: 1,
  }).then(this.processLabels.bind(this), displayError);
}

AudioBox.prototype.processLabels = function(data) {
  var langLabels;
  if (data.entities === undefined || data.entities[this.langQid] === undefined || data.entities[this.speakerQid] === undefined) {
    displayError('dataerror');
    return;
  }
  langLabels = data.entities[this.langQid].labels;
  if (langLabels[mw.config.get('wgUserLanguage')] !== undefined) {
    this.lang = langLabels[mw.config.get('wgUserLanguage')].value;
  } else {
    this.lang = langLabels['en'].value;
  }
  this.speaker = data.entities[this.speakerQid].labels['en'].value;
  this.display();
}

AudioBox.prototype.display = function() {
  this.$node.find('.ab-title').text(this.label);
  this.$node.find('.ab-metadata').text(this.lang + ' - ' + this.speaker);
  this.audioNode.src = this.media;
  this.$node.find('.ab-playbutton').click(this.audioNode.play.bind(this.audioNode));
}

function displayError(code, error) {
  console.warn(code, error);
}


// Interface for displaying contributions on [[LinguaLibre:Explore the sound library]]
$( function (){
	// Parameters
	const nbTotalResults = 100;  // Total number of results requested to SPARQL endpoint
	const nbResultsPerPage =20; // 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': ' (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' )|| 'en';
	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 FILTER (!ISBLANK(?proficiency)) } } SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } }',
			'gender': 'SELECT ?gender ?genderLabel WHERE { ?gender prop:P2 entity:Q7 FILTER EXISTS { [] prop:P5 [ prop:P8 ?gender ]  }. SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } } ORDER BY ?gender',
			'language': 'SELECT ?language (SAMPLE(?languageLabelGroup) as ?languageLabel) WHERE { ?language prop:P2 entity:Q4 . FILTER EXISTS { _:record prop:P4 ?language } SERVICE wikibase:label { bd:serviceParam wikibase:language "'+ userLanguage +'fr,en" . ?language rdfs:label ?languageLabelGroup } } GROUP BY ?language 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 values
					const v = type === 'speaker' ? 
						values.results.bindings.sort(function(v1, v2) {return v1.speakerLabel.localeCompare(s2.speakerLabel);})  : 
						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() {
    
    	var updateField= function(field){
      	$.getJSON(
        	'https://lingualibre.org/bigdata/namespace/wdq/sparql',
					{	query: sparqlGlobal[field] }
				).then( updateSelector(field, true ), displayErrorToUser );
      }
			updateField('speaker');
			updateField('gender');
			updateField('language');
			updateField('proficiency');
		}

		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 { ';
				if( request.language ) {
					query += "BIND(entity:"+ request.language +" as ?language) ";
					potentialSpeakers = reduceSpeakers( 2, request.language, potentialSpeakers );
				}
				if( request.speaker ) {
					query += "BIND(entity:"+ request.speaker +" as ?speaker) ";
					potentialSpeakers = reduceSpeakers( 0, request.speaker, potentialSpeakers );
				}
				if( request.gender ) {
					query += "BIND(entity:"+ request.gender +" as ?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 += '?record prop:P2 entity:Q2 ; prop:P4 ?language ; prop:P5 ?speaker .';
                                if ( order === 'random' ) { query += 'BIND(STRUUID() as ?uuidRandom)' }

                                query += ' OPTIONAL { ?speaker llp:P4 ?statement ; rdfs:label ?speakerName ; prop:P8 ?gender . ?statement llv:P4 ?language . ?record prop:P7 ?word ; prop:P3 ?file ; prop:P6 ?date . OPTIONAL { ?statement llq:P16 ?proficiency FILTER(!ISBLANK(?proficiency)) } FILTER( LANG( ?speakerName ) = "en" ) } SERVICE wikibase:label { bd:serviceParam wikibase:language "' + userLanguage + ',fr,en" } }';
				if( order === 'random' ) {
					query += ' ORDER BY ?uuidRandom';
				} 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();

	} );
} );