User:Phlsph7/SpellGrammarHelper.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
(function(){
	const scriptName = 'SpellGrammarHelper';

	$.when(mw.loader.using('mediawiki.util'), $.ready).then(function(){
		const portletLink = mw.util.addPortletLink('p-tb', '#', scriptName, scriptName + 'Id');
		portletLink.onclick = function(e) {
			e.preventDefault();
			start();
		};
	});

	const noSuggestionsMessage = "[No errors found]";
	let hasError = false;
	let modalTextarea;
	
	function start(){
		modalTextarea = openModalWithTextarea();
	}
	
	async function getAssessments(){
		const elements = getElements();
		for(const element of elements){
			adjustElement(element);
		}
		const assessments = [];
		
		for(let i = 0; i < elements.length; i++){
			let message = `Processing element ${i+1}/${elements.length} ...`;
			logTextarea(message);
			let innerText = elements[i].innerText;
			let assessment;
			if(isList(elements[i])){
				assessment = await getAssessment(innerText, 'list items') + '\n';
			}
			else{
				assessment = await getAssessment(innerText, 'text') + '\n';
			}
			if(hasError){
				break;
			}
			assessments.push(assessment);
		}

		if(hasError){
			clearTextarea();
			logTextarea(`There was an error. The most likely sources of the error are:

* You entered a false OpenAI API key.
* Your OpenAI account ran out of credit.

You can ask at the script talk page if you are unable to resolve the error.`);
		}

		else{
			let fullAssessment = assessments.join('\n').split('\r').join('');
			fullAssessment = fullAssessment.split(noSuggestionsMessage).join('');

			while(fullAssessment.includes('\n\n\n')){
				fullAssessment = fullAssessment.split('\n\n\n').join('\n\n');
			}

			while(fullAssessment.includes('\n ')){
				fullAssessment = fullAssessment.split('\n ').join('\n');
			}

			while(fullAssessment[0] === '\n'){
				fullAssessment = fullAssessment.substring(1);
			}
			
			if(fullAssessment.length < 20){
				fullAssessment = 'The script did not detect any spelling or grammar errors.';
			}

			clearTextarea();
			logTextarea(fullAssessment);
		}
	}

	function getElements(){
		const elementContainer = $('#mw-content-text').find('.mw-parser-output').eq(0)[0].cloneNode(true);
		const children = Array.from(elementContainer.children);
		const elements = children.filter(function(item){return isPara(item) || isList(item)});
		for(let i = elements.length-1; i >= 0; i--){
			if(elements[i].innerText.length < 20){
				elements.splice(i, 1);
			}
		}
		return elements;
	}

	function adjustElement(element){
		removeRefs(element);
		replaceMathFormulas(element);
		
		function removeRefs(element){
			let refs = element.querySelectorAll('.reference, .Inline-Template');
			
			for(let ref of refs){
				ref.outerHTML = '';
			}
		}
		
		function replaceMathFormulas(element){
			let formulas = element.querySelectorAll('.mwe-math-element');
			for(let formula of formulas){
				formula.outerHTML = '<span>[MATHEMATICAL FORMULA]</span>';
			}
		}
	}

	function isPara(element){
		return hasTagName(element, 'p');
	}

	function isList(element){
		return hasTagName(element, 'ul') || hasTagName(element, 'ol');
	}

	function hasTagName(element, tagName){
		return element.tagName.toLowerCase() === tagName.toLowerCase();
	}

	async function getAssessment(text, textType){
		const messages = [
			{role: "system", content: `Check the user's ${textType} from the Wikipedia article "${getTitle()}" for spelling and grammar errors. If there are no errors, respond with "${noSuggestionsMessage}". Report each major error in the following format: 

* Sentence: ... 
** Suggested change: ... 
** Explanation: ... 

Do not provide any other information. Ignore factual errors. Ignore minor errors, like errors pertaining to apostrophe format.`},
			{role: "user", content: '"""As is well-known, this subject have been widely discussed. However, there are still deep disagreements among experts."""'},
			{role: "assistant", content: `* Sentence: ...this subject have been widely discussed.
** Suggested change: replace "have" with "has"
** Explanation: "this subject" is singular and requires the singular verb form "has"`},
			{role: "user", content: '"""Nonetheless, this leads to an increase various factors such as productivity and efficiency."""'},
			{role: "assistant", content: `* Sentence: ...this leads to an increase various factors...
** Suggested change: add "in" after "increase"
** Explanation: the preposition "in" is required to connect "increase" with "various factors"`},
			{role: "user", content: '"""' + text + '"""'},
		];
		
		console.log(messages);

		const url = "https://api.openai.com/v1/chat/completions";
		const body = JSON.stringify({
			"messages": messages,
			"model": "gpt-4o",
			"temperature": 0,
		});
		const headers = {
			"content-type": "application/json",
			Authorization: "Bearer " + localStorage.getItem('SpellGrammarHelperAPIKey'),
		};

		const init = {
			method: "POST",
			body: body,
			headers: headers
		};
		
		let assessment;
		const response = await fetch(url, init);
		console.log(response);
		if(response.ok){
			const json = await response.json();
			assessment = json.choices[0].message.content;
		}
		else{
			hasError = true;
			assessment = 'error';
		}
		
		return assessment;
	}

	function openModalWithTextarea() {
		const overlay = document.createElement('div');
		overlay.style.position = 'fixed';
		overlay.style.top = '0';
		overlay.style.left = '0';
		overlay.style.width = '100%';
		overlay.style.height = '100%';
		overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
		overlay.style.display = 'flex';
		overlay.style.justifyContent = 'center';
		overlay.style.alignItems = 'center';
		overlay.style.zIndex = '1000';

		const modal = document.createElement('div');
		modal.style.backgroundColor = 'white';
		modal.style.padding = '15px';
		modal.style.borderRadius = '5px';
		modal.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)';
		modal.style.width = '80%';
		modal.style.height = '70%';
		modal.style.display = 'flex';
		modal.style.flexDirection = 'column';
		overlay.appendChild(modal);
		
		const title = document.createElement('div');
		title.innerHTML = "SpellGrammarHelper";
		title.style.marginBottom = '15px';
		modal.appendChild(title);

		const textarea = document.createElement('textarea');
		textarea.style.width = '100%';
		textarea.style.height = '80%';
		textarea.style.resize = 'none';
		textarea.style.marginBottom = '15px';
		textarea.style.borderRadius = '5px';
		textarea.readOnly = true;
		modal.appendChild(textarea);
		
		const buttonContainer = document.createElement('div');
		buttonContainer.style.display = 'flex';
		buttonContainer.style.flexDirection = 'row';
		modal.appendChild(buttonContainer);
		
		
		const startButton = addButton("Start", function(){
			clearTextarea();
			let currentAPIKey = localStorage.getItem('SpellGrammarHelperAPIKey');
			if(currentAPIKey === 'null' || currentAPIKey === null || currentAPIKey === ''){
				clearTextarea();
				logTextarea('No OpenAI API key detected. This script requires an OpenAI API key. Use the button below to add one.');
			}
			else{
				getAssessments();
				startButton.disabled = true;
			}
		});
		addButton("Copy", function(){
			modalTextarea.select();
			document.execCommand("copy");
		});
		addButton("Add/Remove API key", function(){
			let currentAPIKey = localStorage.getItem('SpellGrammarHelperAPIKey');
			if(currentAPIKey === 'null' || currentAPIKey === null){
				currentAPIKey = '';
			}
			
			let input = prompt('Please enter your OpenAI API key. It starts with "sk-...". It will be saved locally on your device. It will not be shared with anyone and will only be used for your queries to OpenAI. To delete your API key, leave this field empty and press [OK].', currentAPIKey);
			
			// check that the cancel-button was not pressed
			if(input !== null){
				localStorage.setItem('SpellGrammarHelperAPIKey', input);
			}
			
			startButton.disabled = false;
		});
		addButton("Close", function(){
			document.body.removeChild(overlay);
		});

		document.body.appendChild(overlay);
		
		return textarea;
		
		function addButton(textContent, clickFunction){
			const button = document.createElement('button');
			button.textContent = textContent;
			button.style.padding = '5px';
			button.style.margin = '5px';
			button.style.flex = '1';
			button.addEventListener('click', clickFunction);
			buttonContainer.appendChild(button);
			return button;
		}
	}

	function logTextarea(text){
		modalTextarea.value = text + '\n' + modalTextarea.value;
	}

	function clearTextarea(){
		modalTextarea.value = '';
	}
	
	function getTitle(){
		let innerText = document.getElementById('firstHeading').innerText;
		if(innerText.substring(0, 8) === 'Editing '){
			innerText = innerText.substring(8);
		}
		if(innerText.substring(0, 6) === 'Draft:'){
			innerText = innerText.substring(6);
		}
		if(innerText.includes('User:')){
			let parts = innerText.split('/');
			parts.shift();
			innerText = parts.join('/');
		}
		return innerText;
	}
})();