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.
// Uses the OpenAI API to provide suggestions on spelling and grammar. The suggestions are in wikitext using the template "text diff".
(function(){
const scriptName = 'SpellGrammarSuggestions';
$.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 originalSentenceTag = "**Original sentence:**";
const correctionTag = "**Correction:**";
const explanationTag = "**Explanation:**";
const noSuggestionsMessage = "**No errors found.**";
let hasError = false;
let modalTextarea;
let modalPreviewButton;
let modalPreviewDiv;
let wikitext = '';
function start(){
openModalWithTextarea();
}
// get AI assessments for each text segment, transform them to wikitext, and display the result in the log area
async function getAssessments(){
const textSegments = getTextSegments();
const assessments = [];
for(let i = 0; i < textSegments.length; i++){
let message = `Processing text segment ${i+1}/${textSegments.length} ...`;
logTextarea(message);
assessment = await getAssessment(textSegments[i]) + '\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('');
fullAssessment = fullReplace(fullAssessment, '\n\n\n', '\n\n');
fullAssessment = fullReplace(fullAssessment, '\n ', '\n');
while(fullAssessment[0] === '\n'){
fullAssessment = fullAssessment.substring(1);
}
const articleTitle = firstHeading.innerText;
wikitext = `== [[${articleTitle}]] ==\n\n`;
fullAssessment = fullAssessment.trim();
let noErrorDetectedString = 'The script did not detect any spelling or grammar errors.\n'
if(fullAssessment.length < 20){
wikitext += noErrorDetectedString;
}
else{
wikitext += fullAssessmentToWikitext(fullAssessment) + '\n';
if(wikitext.split('\r').join('').trim().split('\n').length < 4){
wikitext += noErrorDetectedString;
}
}
clearTextarea();
logTextarea(wikitext);
modalPreviewButton.disabled = false;
}
}
// get an individual assessment for a single text segment
async function getAssessment(textSegment){
const systemPrompt =
`Check the provided encyclopedic text for spelling and grammar errors, and suggest corrections.
- Review the text thoroughly to identify any spelling mistakes or grammatical issues.
- Offer clear corrections for each identified error.
- Maintain the original meaning and encyclopedic style of the text.
- Only report sentences that contain objective errors. Do not make subjective style suggestions.
- Do not alter spellings that have multiple accepted forms; maintain the original spelling as provided.
- Leave all instances of American and British English unchanged, even if they are mixed within the text.
- Provide a concise explanation for each correction about why it is necessary.
# Output Format
The output should be a list of identified errors and their corrections in the following format:
${originalSentenceTag} "[original sentence with error]"
${correctionTag} "[corrected sentence]"
${explanationTag} "[explanation of the correction]"
${originalSentenceTag} ...
Do not list sentences that contain no errors. If the whole text contains no errors, respond only with the following standard message:
${noSuggestionsMessage}`;
const messages = [
{role: "system", content: systemPrompt},
{role: "user", content: textSegment},
];
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('SpellGrammarSuggestionsAPIKey'),
};
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';
}
console.log(assessment);
return assessment;
}
// transform the html paragraphs containing the article text into text segments
function getTextSegments(){
const elementContainer = $('#mw-content-text').find('.mw-parser-output').eq(0)[0].cloneNode(true);
// remove references
const refs = elementContainer.querySelectorAll('.reference, .Inline-Template');
for(let ref of refs){
ref.outerHTML = '';
}
// remove annotation (of math elements)
const anElements = elementContainer.querySelectorAll('annotation');
for(let anElement of anElements){
anElement.outerHTML = '';
}
// get p-elements
const children = Array.from(elementContainer.children);
const elementParas = children.filter(function(item){
return item.tagName.toLowerCase() === 'p';
});
// transform p-elements to text
const textParas = [];
for(let elementPara of elementParas){
let tempText = elementPara.innerText;
// adjustments like removing newlines caused by mathematical formulas
tempText = tempText.split('\r').join('')
.split('\n').join(' ')
.split('\t').join(' ');
tempText = fullReplace(tempText, ' ', ' ');
tempText = tempText.trim();
// ignore very short paragraphs
const minTextParaLength = 50;
if(tempText.length >= minTextParaLength){
textParas.push(tempText);
}
}
// combine textParas to form longer textSegments
const maxTextSegmentLength = 5000;
const textSegments = textParas.splice(0, 1);
let curSegmentIndex = 0;
while(textParas.length > 0){
let curPara = textParas.splice(0, 1)[0];
if(textSegments[curSegmentIndex].length + curPara.length < 5000){
textSegments[curSegmentIndex] += '\n\n' + curPara;
}
else{
textSegments.push(curPara);
curSegmentIndex++;
}
}
return textSegments;
}
// transform the full assessment to wikitext
function fullAssessmentToWikitext(fullAssessment){
let wikitext = '';
let individualAssessments = fullAssessment.split(originalSentenceTag);
for(let individualAssessment of individualAssessments){
if(hasExpectedFormat(individualAssessment)){
let originalSentence = individualAssessment.split(correctionTag)[0].trim();
originalSentence = removeQuotationMarks(originalSentence);
let remainingAssesment = individualAssessment.split(correctionTag)[1];
let correctedSentence = remainingAssesment.split(explanationTag)[0].trim();
correctedSentence = removeQuotationMarks(correctedSentence);
let explanationSentence = remainingAssesment.split(explanationTag)[1].trim();
let pageURL = window.location.href.split('#')[0];
let pageScrollURL = pageURL + '#:~:text=' + encodeURIComponent(originalSentence);
let pageEditURL = pageURL.replace('/wiki/', '/w/index.php?title=') + '&action=edit';
let linkString = `([${pageScrollURL} scroll to sentence in article] | [${pageEditURL} edit article])`;
let assessmentWikitext = `{{text diff|${originalSentence}|${correctedSentence}}}\n:::Explanation: ${explanationSentence} ${linkString}\n\n\n\n`;
if(passesFilter(originalSentence, correctedSentence, explanationSentence)){
wikitext += assessmentWikitext;
}
else {
console.log('filtered out: ' + assessmentWikitext);
}
}
else if (individualAssessment.trim() > 50){
individualAssessment = originalSentenceTag + ' ' + individualAssessment.trim();
individualAssessment = individualAssessment.split(':**').join('')
.split('**').join('*');
individualAssessment += '\n*:[This suggestion is not displayed using the template "text diff" because the AI response did not have the expected format.]\n\n\n\n';
}
}
return wikitext.trim();
// LLM output format may be inconsistent so it has to be checked
function hasExpectedFormat(individualAssessment){
let correctionTagCount = individualAssessment.split(correctionTag).length - 1;
let explanationTagCount = individualAssessment.split(correctionTag).length - 1;
return correctionTagCount === 1 && explanationTagCount === 1;
}
function removeQuotationMarks(text){
if (text.startsWith('"')) {
text = text.substring(1);
}
if (text.endsWith('"')) {
text = text.substring(0, text.length - 1);
}
return text;
}
// filter out common AI errors
function passesFilter(originalSentence, correctedSentence, explanationSentence){
let explanationExclusionList = ['American English', 'British English', 'The sentence is correct as it is', 'The sentence is correct as is', 'No correction is needed', 'No correction needed', 'no errors were found'];
for(let item of explanationExclusionList){
if(explanationSentence.toLowerCase().includes(item.toLowerCase())){
return false;
}
}
return true;
}
}
// create the modal overlay to display controls and output
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 = "SpellGrammarSuggestions";
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);
modalTextarea = 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('SpellGrammarSuggestionsAPIKey');
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('SpellGrammarSuggestionsAPIKey');
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('SpellGrammarSuggestionsAPIKey', input);
}
startButton.disabled = false;
});
modalPreviewButton = addButton("Preview & Close", async function(){
document.body.removeChild(overlay);
openModalWithPreview();
});
modalPreviewButton.disabled = true;
addButton("Close", function(){
document.body.removeChild(overlay);
});
document.body.appendChild(overlay);
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;
}
}
// create the modal overlay to display preview
async function openModalWithPreview() {
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 = "Preview";
title.style.marginBottom = '15px';
modal.appendChild(title);
const previewDiv = document.createElement('div');
previewDiv.style.width = '100%';
previewDiv.style.height = '80%';
previewDiv.style.resize = 'none';
previewDiv.style.marginBottom = '15px';
previewDiv.style.borderRadius = '5px';
previewDiv.style.overflowY = 'auto';
previewDiv.innerHTML = await wikitextToHTML(wikitext);
modal.appendChild(previewDiv);
modalPreviewDiv = previewDiv;
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.flexDirection = 'row';
modal.appendChild(buttonContainer);
addButton("Close", function(){
document.body.removeChild(overlay);
});
document.body.appendChild(overlay);
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;
}
async function wikitextToHTML(wikitext){
const apiUrl = 'https://en.wikipedia.org/w/api.php';
const params = {
action: 'parse',
format: 'json',
contentmodel: 'wikitext',
text: wikitext
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(params)
});
const data = await response.json();
if (data.error) {
console.error('Error from API:', data.error);
return null;
}
const htmlContent = data.parse.text['*'];
return htmlContent
}
}
function logTextarea(text){
modalTextarea.value = text + '\n' + modalTextarea.value;
}
function clearTextarea(){
modalTextarea.value = '';
}
// get the title of the page
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;
}
// replace the old string with the new string until no instance of the old string remains
function fullReplace(string, oldSubstring, newSubstring){
let newString = string;
while(newString.includes(oldSubstring)){
newString = newString.split(oldSubstring).join(newSubstring);
}
return newString;
}
})();