User:Luca.favorido/linkypop.js

From Wikidata
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * LinkyPop - From zero, to property hero!
 *  _      _       _          _____            
 * | |    (_)     | |        |  __ \           
 * | |     _ _ __ | | ___   _| |__) ___  _ __  
 * | |    | | '_ \| |/ | | | |  ___/ _ \| '_ \ 
 * | |____| | | | |   <| |_| | |  | (_) | |_) |
 * |______|_|_| |_|_|\_\\__, |_|   \___/| .__/ 
 *                       __/ |          | |    
 *                      |___/           |_|    
 * Easily *search an identifier* on an external site.
 * 
 * Sample use cases:
 * 
 * 1. You're adding an ORCID for a researcher. As soon as you type "ORCID" in 
 *     the property input, an icon with a lens will be clickable.
 *     You click it, a new tab opens with the ORCID site looking for the name
 *     of the researcher. You copy the URL and paste it to Wikidata. Bang!
 * 
 * 2. You're creating the element for a movie.
 *    * You type "IMDB" and the button to search immediately appears!
 *    * You type "TMDB" and the button to search immediately appears!
 *    * ...and so on.
 * 
 *
 * This gadget is still under test and development, so please write me for 
 * every issue you encounter or idea to evolve the tool! I'll be glad!
 * 
 * <<< Special thanks to [User:Bargioni] >>>
 * 
 * How to install: Copy and paste the following text to your page common.js.
 * mw.loader.load('//www.wikidata.org/w/index.php?title=User:Luca.favorido/lookup_test.js&action=raw&ctype=text/javascript');
 */
(function(){ // scope for the script

const gadgetName = "LinkyPop";
const wgUserLanguage = mw.config.get( 'wgUserLanguage' );
const searchIconUrl = "//wikidata.org/w/skins/Vector/resources/skins.vector.styles.legacy/images/search.svg";
const entityUrl = (entityId) => `//www.wikidata.org/wiki/Special:EntityData/${entityId}.json`;
const propertyUrl = (propId) => `//www.wikidata.org/wiki/Property:${propId}`;
const propertyRegex = /[Property:](P\d+)/gm;
const log = (...props) => { console.log(`[${gadgetName}]`, ...props); };

// Constants from Wikidata
const searchUrlPropId = "P4354";
const sourceUrlPropId = "P1896";

/**
 * Get property codes and labels, cache them in the localstorage dict
 * PropertyName => PropertyID
 */
const setPropertiesAssociations = () => {
	$("ul.ui-entityselector-list li a").each((i,e) => {
		const matches = e.href.matchAll(propertyRegex);
		const $propertyNode = $(e).find(".ui-entityselector-label").contents();
		const text = $propertyNode == null || $propertyNode.get(0) == null ? '' : $propertyNode.get(0).nodeValue;
		for (const match of matches) {
			localStorage.setItem(`[${gadgetName}].[${text}]`, match[1]);
		}
	});
};

/**
 * Parse all the key-values of property ID and name and save them into the
 * local storage. e.g. ("MyIdentifier" : "P12345")
 * When adding a new property, we have the property name, look for it in the
 * local storage and return the property ID
 * @param {Object} $fatherElem The JQuery obj where the property is read
 * @returns {string} propertyId the identifier of the prop P12345
 */
const getPropertyValue = ($fatherElem) => {
	const $selectedInput = $fatherElem.find("input.ui-entityselector-input-recognized");
	const selectedProperty = $selectedInput.val();
	// log('selectedProperty', selectedProperty);
	const pValue = localStorage.getItem(`[${gadgetName}].[${selectedProperty}]`);
	// log('pValue', pValue);
	if (pValue != null) return pValue;
	return localStorage.getItem(`[${gadgetName}].[${selectedProperty}]`);
};

/**
 * Show a warning message below the $fatherElem
 * @param {Object} $fatherElem The JQuery obj where the warning is added
 * @param {string} message A message to display in the first line
 * @param {string} suggestion A message to display in the second line
 */
const showWarning = ($fatherElem, message, suggestion) => {
	const $placeToPutParent = $fatherElem.find(".wikibase-toolbar-button-lookup").parent();
	const areThereOtherWarnings = Boolean($placeToPutParent.find(".lookup-warning").length);
	if (areThereOtherWarnings) return;
	const warningMessage = `<div class='lookup-warning' style='background: beige; padding: 0 4px;'>${message}<br />${suggestion}</div>`;
	$placeToPutParent.append(warningMessage);
};

/**
 * Handle cases when the property doesn't have the search URL
 * e.g. Scopus identifier doesn't have a search URL.
 * Display a message, and a link that help to add the search URL to the property
 * statements.
 * @param {Object} $fatherElem The JQuery obj where the warning is added
 * @param {string} propId The ID of the property (e.g. P12345)
 */ 
const propertyDoesntHaveSearchUrl = ($fatherElem, propId) => {
	const message = `Cannot search this element because the selected property ${propId} doesn't have any search URL associated (${searchUrlPropId}).`;
	const suggestion = `You can <a href='${propertyUrl(propId)}#${searchUrlPropId}' target='_blank'>add one</a> if you wish!`; // TODO clear cache on add one click
	showWarning($fatherElem, message, suggestion);
};

/**
 * Given a property ID, look for it, and save in the cache details about it
 * @param {string} propId The ID of the property (e.g. P12345)
 * @return {Promise<void>} The property cache is populated, you can access it
 */
const investigateAboutProperty = (propId) => {
	const property = localStorage.getItem(`[${gadgetName}].[${propId}]`);
	// log(property);
	if (property != null) { return Promise.resolve(); }
	return fetch(entityUrl(propId))
		.then(body => body.json())
		.then(jsonResponse => {
			const claims = jsonResponse.entities[propId].claims;
			const isExternalIdentifier = jsonResponse.entities[propId].datatype === "external-id";
			// log('isExternalIdentifier', isExternalIdentifier);
			const searchProp = claims[searchUrlPropId];
			const hasSearchUrl = searchProp != null && !["novalue","somevalue"].includes(searchProp[0].mainsnak.snaktype);
			// log('searchProp', searchProp, '\n', 'hasSearchUrl', hasSearchUrl);
			const searchUrl = hasSearchUrl ? searchProp[0].mainsnak.datavalue.value : null;
			const sourceUrl = claims[sourceUrlPropId] != null && !["novalue","somevalue"].includes(claims[sourceUrlPropId][0].mainsnak.snaktype) 
				? claims[sourceUrlPropId][0].mainsnak.datavalue.value : null;
			localStorage.setItem(`[${gadgetName}].[${propId}]`, 
				JSON.stringify({
					isExternalIdentifier, hasSearchUrl, searchUrl, sourceUrl,
				})
			);
		});
};


/**
 * Quick and dirty way to get name of the element...
 * TODO: I might use the JSON labels
 * @return {string} The title of the element
 */
const getElementTitle = () => {
	const pageTitle = $(".wikibase-title-label").text();
	if (pageTitle != null) return pageTitle;
	$(".wikibase-labelview-text").each((i,e) => { return $(e).text(); });
};

/**
 * Add the UI control to lookup the identifier
 * @param {Object} $placeToPutUi The JQuery obj where the UI elements are added
 * @param {string} replacedSearchUrl The URL to follow when btn is clicked
 * @param {string} onClick The action that must happen when btn is clicked
 */
const addControl = ($placeToPutUi, replacedSearchUrl, onClick) => {
	// log($placeToPutUi);
	const lookupButton = document.createElement("span");
	lookupButton.classList.add("wikibase-toolbar-button-lookup","wikibase-toolbarbutton","wikibase-toolbar-item","wikibase-toolbar-button");
	lookupButton.style.cssText = "float:right;position:relative;margin-right:12px;left:8px";
	const a = document.createElement('a');
	a.title = `${gadgetName} - Click to start searching this property`;
	a.target = '_blank';
	a.href = replacedSearchUrl;
	a.onclick = onClick;
	a.appendChild(getIcon());
	lookupButton.appendChild(a);
	// check another time if we must really insert the control, in case of async tasks
	const alreadyInserted = Boolean($placeToPutUi.find(".wikibase-toolbar-button-lookup").length);
	if(alreadyInserted) { return; }
	$placeToPutUi.append(lookupButton);
	$placeToPutUi.append("<div class='wikibase-toolbar-button-lookup' style='clear: both;'></div>");
};

/**
 * Get the image icon for the UI
 * @return {Element} The image icon for the UI
 */
const getIcon = () => {
	const img = document.createElement('img');
	img.src = searchIconUrl;
	img.width = "16"; img.height = "16";
	return img;
}

/**
 * Starting point,
 * If it's not a Wikidata item, do nothing 8-)
 * On page load, add a mutation observer that catches the event of a new 
 * property added. On this event, bind a callback to add UI control.
 * TODO: handle the edit tag case? //mw.hook('wikibase.statement.startEditing').add(log);
 */
mw.hook('wikibase.entityPage.entityLoaded').add((page) => {
	// log(page);

	if ($('.wikibase-entityview-main').length && typeof MutationObserver !== 'undefined') {
		const observer = new MutationObserver(function(mutationList, observer) {
			// log('some mutations on DOM', mutationList); // This log can be very spammy
			
			const $newElemDiv = $("#mw-content-text div.listview-item.wikibase-statementgroupview.wb-new");
			if ($newElemDiv.length) {
				// log('you\'re inserting a new property to this element');
				// Look for property names and IDs and cache them
				setPropertiesAssociations();
				$('input.ui-entityselector-input').each(function() {
					const $container = $(this).closest(".wikibase-statementgroupview");
					// log('$container',$container);
					if(!$container.length){ return; }
					$placeToPutUi = $container.find(".wikibase-snakview-property-container");
					const alreadyInserted = Boolean($placeToPutUi.find(".wikibase-toolbar-button-lookup").length);
					// log(alreadyInserted ? "UI control inserted" : "UI control NOT inserted YET");
					const inputRecognized = Boolean($container.find(".ui-entityselector-input-recognized").length);
					// log(inputRecognized ? 'property input recognized' : 'property still empty');
					if (inputRecognized && !alreadyInserted) {
						const propId = getPropertyValue($container);
						// log("selectedProperty", propId);
						investigateAboutProperty(propId)
						.then(() => {
							const property = JSON.parse(localStorage.getItem(`[${gadgetName}].[${propId}]`));
							const replacedSearchUrl = property.hasSearchUrl ? property.searchUrl.replace("$1", getElementTitle()) : '';
							const onClick = property.hasSearchUrl ? () => {} : (e) => { e.preventDefault(); propertyDoesntHaveSearchUrl($placeToPutUi, propId); };
							if (property.isExternalIdentifier) addControl($placeToPutUi, replacedSearchUrl, onClick);
						});
						// TODO: catch issues like network error
					}
				});
			}
		});
		
		const observeNode = $('.wikibase-entityview-main')[0];
		if (!observeNode) return;
		observer.observe(observeNode, {
			childList: true,
			attributes: false, // We don't care about attributes
			subtree: true
		});
	}
});


})(); // end of scope