// ==UserScript== // @name Stack Exchange Editor Toolkit // @author Stephen Ostermiller // @author Cameron Bernhardt (AstroCB) // @developer Jonathan Todd (jt0dd) // @developer sathyabhat // @contributor Unihedron // @license MIT // @version 1.6.1 // @namespace http://github.com/AstroCB // @updateURL https://github.com/AstroCB/Stack-Exchange-Editor-Toolkit/raw/master/editor.meta.js // @downloadURL https://github.com/AstroCB/Stack-Exchange-Editor-Toolkit/raw/master/editor.user.js // @description Fixes common grammar and usage mistakes on Stack Exchange posts with a click // @match https://*.stackexchange.com/posts/* // @match https://*.stackexchange.com/questions/* // @match https://*.stackexchange.com/review/* // @match https://*.stackoverflow.com/*posts/* // @match https://*.stackoverflow.com/*questions/* // @match https://*.stackoverflow.com/review/* // @match https://*.askubuntu.com/posts/* // @match https://*.askubuntu.com/questions/* // @match https://*.askubuntu.com/review/* // @match https://*.superuser.com/posts/* // @match https://*.superuser.com/questions/* // @match https://*.superuser.com/review/* // @match https://*.serverfault.com/posts/* // @match https://*.serverfault.com/questions/* // @match https://*.serverfault.com/review/* // @match https://*.mathoverflow.net/posts/* // @match https://*.mathoverflow.net/questions/* // @match https://*.mathoverflow.net/review/* // @match https://*.stackapps.com/posts/* // @match https://*.stackapps.com/questions/* // @match https://*.stackapps.com/review/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @connect raw.githubusercontent.com // ==/UserScript== (()=>{ const ENVIRONMENT = getEnvironment() const DATA = {} ENVIRONMENT.file("spelling-corrections.txt",x=>{DATA.spellingCorrections=x}) ENVIRONMENT.file("content-free-words.txt",x=>{DATA.contentFreeWords=x}) ENVIRONMENT.file("example-domain-words.txt",x=>{DATA.exampleDomainWords=x}) ENVIRONMENT.file("file-extensions.txt",x=>{DATA.fileExtensions=x}) ENVIRONMENT.file("top-level-domains.txt",x=>{DATA.topLevelDomains=x}) const rules = [] function waitForData(){ if (Object.keys(DATA).length != 5){ setTimeout(waitForData,50) return } DATA.POST_CODE_FORMAT = /([_\*\"\'\`\;\,\.\?\:\!\)\>]*(?=\s|$))/ const MISSPELLINGS = Object.assign({},...( DATA.spellingCorrections.trim().split(/[\n\|]/).map(l=>{ var r = l.split(/:/) if(r.length>2) throw "Extra colons in " + l var toc = [], cor=r[r.length-1] if (!toc) throw "Empty correction: " + l if (r.length==2) toc = r[0].split(/,/) if (/[A-Z]/.test(cor)) toc.push(cor.toLowerCase()) if (!toc.length) throw "No correction for " + cor return Object.assign({},...toc.map(w=>({[w]:cor}))) }) )) const CONTENT_FREE_WORDS = "(?:"+DATA.contentFreeWords.trim().replace(/\n/g,"|")+")" // Top 100 from https://dnsinstitute.com/research/popular-tld-rank/ plus "tld" const TLD = new RegExp('(?:\\\\?\\.com?)?\\\\?\\.(?:'+DATA.topLevelDomains.trim().replace(/\n/g,"|")+')') const SUBDOM = /(?:(?:[a-zA-Z0-9\-]+|[\*\%])\\?\.)*/ const REST_OF_URL = /(?:[\?\/\$\{][^ ]*?)?/ const PORT_OPT = /(?:\:[0-9]+)?/ const USER_OPT = /(?:[a-zA-Z\-\.]+\@)?/ const PRE_CODE_FORMAT = /(^|\s)([_\*\"\'\(\<]*)/ const ANSWER_WORDS = /(?:answers?|assistance|advice|examples?|fix|help|hints?|guidance|ideas?|point|pointers?|tips?|suggestions?)/ const BETWEEN_WORDS = "[, \\-\\/]+" DATA.EXAMPLE_DOMAIN = new RegExp("^((?:.*\\.)?)(" + // Made entirely of example-like words // Followed by an optional number or single letter "(?:(?:(?:"+DATA.exampleDomainWords.trim().replace(/\n/g,"|")+"|(?:(?<=[a-zA-Z\\-])co)|(?:a(?=[a-zA-Z\\-]{3,})))-?)+(?:-?(?:[0-9]+|[A-Za-z]))?)" + ')('+TLD.source +')$' ,'i') DATA.DOMAIN_NAME = /(?<=^|[^A-Za-z0-9\\-\\.])(\.?(?:(?:[a-zA-Z0-9\-_]+|[\*\%])\\?\.)+[a-zA-Z]+)(?=\.?(?:[\;\,\:\/_\"\*'\)\<\>\?\!\` \t\$]|$))/gmi const WORD_OR_NON=/(?:[0-9a-zA-Z]+)|(?:[^0-9a-zA-Z]+)/gm const WORD=/^[0-9a-zA-Z]+$/ rules.push(...[ { expr: /\b(https?)[ \t]*:[ \t]*\/[ \t]*\/[ \t]*([a-zA-Z0-9\-]+)[ \t]*\./gi, replacement: "$1://$2.", reason: "fix URL", context: ["fullbody","title"] },{ // Remove blank lines from beginning expr: /^[\n\r]+/gi, replacement: "", reason: "formatting", context: ["fullbody"], score: .1 },{ expr: DATA.DOMAIN_NAME, replacement: (domain)=>{ var m = DATA.EXAMPLE_DOMAIN.exec(domain) if (!m) return domain var pre=m[1], name=m[2], tld=m[3] var escape = ""; if (/^\\/.test(tld)){ escape="\\" tld = tld.substr(1) } if (!/^example$/i.test(name)){ if (context.exampleDomains[normalizeDomain(tld)] != 1){ name = name.replace(/-example-/gi,'-').replace(/-?example-?/gi,'') tld='.example' } else { name = "example" } } return pre+name+escape+tld }, reason: "use example domain", context: ["title","text","code","url"], score: .5 },{ // https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites expr: /.+/g, replacement: m=>{ if (/^<\s*\/?\s*(?:a|b|blockquote|br|code|del|dd|dl|dt|em|h1|h2|h3|h4|h5|h6|hr|i|img|kbd|li|ol|p|pre|s|strike|strong|sub|sup|ul)(?:\s|\>|\/)/i.test(m)){ // allowed tags return m } if(/^<\!\-\-\s*(?:language|language-all|begin snippet|end snippet|summary|End of automatically inserted text)/.test(m)){ // Special comments return m } return "`"+m+"`" }, reason: 'code format HTML', context: ["html"], score: 0 },{ expr: /^``$/g, replacement: "", reason: "remove empty code", context: ["code"], score: .2 },{ // Insert spaces after commas expr: /,([[a-z])/g, replacement: ", $1", reason: "grammar", score: .4 },{ // Remove spaces before punctuation expr: /[ ]+([,\!\?\.\:](?:\s|$))/gm, replacement: "$1", reason: "grammar", score: .4 },{ // Remove double spaces after periods expr: /([,\!\?\.\:]) {2,}/gm, replacement: "$1 ", reason: "grammar", score: .01 }, capitalizeWord(".htaccess","\\.?htacc?ess?"), capitalizeWordAndVersion("iOS", null, " "), capitalizeWord("Node.js","node\\.js"), capitalizeWordAndVersion("SQLite"), capitalizeWord("UTF-8"), { expr: new RegExp(/((?:^|\s)\(?)([A-Za-z0-9'\-]+)/.source + DATA.POST_CODE_FORMAT.source, "gm"), replacement: (p0,p1,w,p3)=>{ if (MISSPELLINGS.hasOwnProperty(w)){ w = MISSPELLINGS[w] // abbreviations or all lower case } else { var lc = w.toLowerCase() if (MISSPELLINGS.hasOwnProperty(lc)){ var correct = MISSPELLINGS[lc] if (/[A-Z]/.test(correct)) w = correct // Always use capitalization proper noun corrections else if (/^(?:[A-Z][a-z]+)+$/.test(w)) w = correct[0].toUpperCase() + correct.substr(1) // Match capitalizization of misspelling else w = correct // Use lower case correction } } return p1+w+p3 }, reason: spellingReason, score: spellingScore },{ edit: s=>{ var m, tokens=[], replacements = [] while (m = WORD_OR_NON.exec(s)){ tokens.push(m[0]) } for (var i=0; i $1[0] + $1.substring(1).toLowerCase(), reason: "capitalization", context: ["title"], score: .5, rounds: "0" },{ expr: /\[enter image description here\]/g, replacement: "[]", reason: "formatting", score: .1 },{ // Capitalize first letter expr: /^[a-z]+\s/gm, replacement: $0 => $0[0].toUpperCase()+$0.substring(1), reason: "capitalization", context: ["title"], score: .1, rounds: "0" },{ expr: new RegExp( PRE_CODE_FORMAT.source + '(' + '(?:' + '(?:' + '(?:' + '(?:https?:\\/\\/)?'+ // Optional protocol USER_OPT.source + '(?:'+ // example.tld style domains '(?:'+SUBDOM.source+'example'+TLD.source +')|'+ // some.example style domains '(?:'+SUBDOM.source+'[a-zA-Z0-9\\-]+\\.(?:example|localhost|invalid|test))|' + // IPV6 IP addresses /(?:(?:(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4})|(?:[A-Fa-f0-9]{0,4}::[A-Fa-f0-9]{1,4}(?::[A-Fa-f0-9]{1,4}){0,6}))/.source + '|' + // IPV4 IP addresses /(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})/.source + ')'+ ')|(?:' + // domains without TLD (like localhost) formatted in URL '(?:https?:\\/\\/)' + // Required protocol USER_OPT.source + '[a-zA-Z0-9]+' + // Host name with no dots ')' + ')' + PORT_OPT.source + REST_OF_URL.source + ')|(?:' + // domains without TLD (like localhost) with port number USER_OPT.source + '(?:[a-zA-Z0-9\-]*[a-zA-Z]+[a-zA-Z0-9\-]*\\:[0-9]+)' + REST_OF_URL.source + ')|(?:' + // email addresses '[a-zA-Z0-9\\-_]+\\@(?:[a-zA-Z0-9\\-]+\\.)*[a-zA-Z]+' + ')' + ')'+ DATA.POST_CODE_FORMAT.source ,'gmi'), replacement: applyCodeFormat, reason: "code format example URL", context: ["text","url"], score: .2 },{ expr: new RegExp( PRE_CODE_FORMAT.source + '(' + '(?:' + // File name with common file extension '[a-zA-Z0-9\\._\\-\\/\\~\\\\]*'+ // Extension list from https://fileinfo.com/filetypes/common // and https://gist.github.com/ppisarczyk/43962d06686722d26d176fad46879d41 '\\.(?:'+DATA.fileExtensions.trim().replace(/\n/g,"|")+')'+ ')|(?:'+ // Directory ending in slash (or backslash) '[a-zA-Z0-9\\._\\-\\~]*[\\/\\\\](?:[a-zA-Z0-9\\._\\-\\~]+[\\/\\\\])+'+ ')|(?:'+ // Directory starting in slash '(?:\\/[a-zA-Z0-9\\._\\-\\~]+){2,}'+ ')|(?:'+ // Windows file starting with drive letter '(?:[A-Z]\\:\\\\)[a-zA-Z0-9\\._\\-\\~\\\\]+'+ ')' + ')' + DATA.POST_CODE_FORMAT.source ,"gmi"), replacement: applyCodeFormat, reason: "code format file name", context: ["text"], score: .2 },{ // Remove trailing white space expr: /[ \t]+(\r\n|\n|$)/gm, replacement: "$1", reason: "formatting", context: ["title"], score: .01 },{ // Remove multiple new lines expr: /(?:\r|\n|\r\n){3,}/gm, replacement: "\n\n", reason: "formatting", context: ["fullbody"], score: .01 } ]) } waitForData() function applyCodeFormat (m,prefix,start,url,suffix){ start=start||'' suffix=suffix||'' var code='`' if ((m = url.search(/[_\*\"\'\`\;\,\.\?\:\!\)\>]+$/)) != -1){ suffix = url.substr(m) + suffix url = url.substr(0,m) } if (start && (((/[\_\*\"\']+/.test(start) && suffix.startsWith(start)) || (start == "<" && suffix.startsWith(">"))))){ suffix=suffix.substr(start.length) start = "" } else if (url.length<=4 && !url.match(/::/)){ code="" } else if (url.match(/^node\.js$/i)){ code="" } return prefix+start+code+url+code+suffix } function normalizeDomain(d){ return d.replace(/\\/g,"").toLowerCase() } function spellingReason(i, o){ i = i.toLowerCase() o = o.toLowerCase() if (o == i){ return "capitalization" } else if (o.replace(/[^a-zA-Z0-9]/g,"") == i.replace(/[^a-zA-Z0-9]/g,"")){ return "grammar" } return "spelling" } function spellingScore(r, i, o){ switch (r){ case "capitalization": return 0.3 case "grammar": return 0.5 } return 1 } function removeLeaveSpace(s){ var start = "", end="" if (/^[\.\!\?]/.test(s)){ start = s[0] s = s.substr(1) } if(/^(?:\r\r|\n\n|\r\n\r\n)(\s|\S)*[ \t\r\n]$/.test(s)) end="\n\n" else if(/^[ \t\r\n](\s|\S)*(?:\r\r|\n\n|\r\n\r\n)$/.test(s)) end="\n\n" else if(/^[\r\n](\s|\S)*[ \t\r\n]$/.test(s)) end="\n" else if(/^[ \t\r\n](\s|\S)*(?:\r|\n|\r\n)$/.test(s)) end="\n" else if(/^[ \t\r\n](\s|\S)*[ \t\r\n]$/.test(s)) end=" " return start+end } // Create a rule for converting the given word into its exact given case. // The regex parameter is optional, if none is given, it is auto-created from the word // The auto created regex inserts white space for camel case words, a custom regex // should be created if other white space removal desired function capitalizeWord(word, re){ if (!re) re = word re = re.replace(/[ \-]+/g, "[\\s\\-]*") re = re.replace(/([A-Z][a-z]+)([A-Z])/g, "$1\\s*$2") return { expr: new RegExp("((?:^|\\s)\\(?)(?:"+re+")"+DATA.POST_CODE_FORMAT.source,"igm"), replacement: "$1"+word+"$2", reason: spellingReason, score: .1 } } // Create a rule for word capitalization same as above, but followed by // a numeric version. The separator can be used to Specify whether or // not a space in included in the output: FooBar8 vs FooBar 8 function capitalizeWordAndVersion(word, re, separator){ if (!separator) separator = "" if (!re) re = word re = re.replace(/ /g, "\\s*") re = re.replace(/([A-Z][a-z]+)([A-Z])/g, "$1\\s*$2") return { expr: new RegExp("((?:^|\\s)\\(?)(?:"+re+")"+(separator==" "?"\\s*":"")+"([0-9]+)"+DATA.POST_CODE_FORMAT.source,"igm"), replacement: "$1"+word+separator+"$2$3", reason: spellingReason, score: .1 } } function tokenizeMarkdown(str){ var tokens=[], m, startRx = new RegExp("(" + [ /^ {0,3}([\~]{3,}|[\`]{3,})/, // start of code fence (group 1 and 2) /\<(?:[^\>\r\n]+)[\>\r\n]/, // HTML tag (group 3) /^(?: {0,3}>)*(?: {4}|\t)/, // start of indented code (group 4) /`/, // start of single backtick code (group 5) /\]\([^\)\r\n]+\)/, // link (group 6) /^ {0,3}\[[^ \t\r\n]+\]\:\s[^\r\n]*/, // footnote link (group 7) /(?:_+|\*+|[\'\"\(])?https?\:\/\/[^ \t\r\n]*/ // URL (group 8) ].map(r=>r.source).join(')|(') + ")","gim"), codeRx = new RegExp("((?:" + [ /(?: {0,3}>)*(?: {4}|\t).*(?:[\r\n]+(?: {0,3}>)*(?: {4}|\t).*)*/, // indented block /`[^`\r\n]*`/, // single back ticks /<\s*pre(?:\s[^>]*)?>[\s\S]*?<\s*\/\s*pre\s*>/, // HTML pre tags /<\s*code(?:\s[^>]*)?>[\s\S]*?<\s*\/\s*code\s*>/, // HTML code tags ].map(r=>r.source).join(')|(?:') + "))","gi"), lastEnd=0 while(m = (startRx.exec(str))){ var thisStart=m.index if (m.index-lastEnd>0){ tokens.push({type:"text",content:str.slice(lastEnd,m.index)}) lastEnd=m.index } if (m[1]){ // code fence var fence = m[2], endRx = new RegExp("^ {0,3}"+fence,"gm") endRx.lastIndex = lastEnd+fence.length if (m=(endRx.exec(str))){ var end = m.index+fence.length tokens.push({type:"code",content:str.slice(lastEnd,end)}) lastEnd=end } else { tokens.push({type:"code",content:str.substr(lastEnd)}) return tokens } } else if (m[3] || m[4] || m[5]){ // html tag OR indented code OR single backtick code codeRx.lastIndex = lastEnd var codeM=codeRx.exec(str) if (codeM && codeM.index == lastEnd){ tokens.push({type:"code",content:codeM[1]}) lastEnd+=codeM[1].length } else if (m[3]) { // Other HTML tags type = "html" // Unless it is a URL if (/^\{ var context = rule.context || ["title","text"] var inRound = typeof rule.rounds == 'undefined' || rule.rounds.includes(''+round) if (context.includes(type) && inRound){ var ruleEditCount = 0, output = input if (rule.edit){ var o = rule.edit(input) output = o[0] for (var i=0; it.content).join("") } if (d.title) d.title = applyRules(d, d.title, "title", tries) editsMade = d.replacements.length - editsMade tries++ if (tries>=9){ d.error="Ten rounds of edits" throw d } } while(editsMade>0) return d } function buildSummary(summary, reasons){ var used={} summary.split(/, */).map(i=>{if(i)used[i]=1}) for (var i=0; i{ if(e.key=="Escape"){ var tki = $('.autoEditorInfo') if (tki.length){ tki.remove() e.preventDefault() return false } } if(e.key=="e" && e.ctrlKey){ var button = $(e.target).closest('.wmd-container').find('.autoEditorButton') if (button.length){ e.preventDefault() button.trigger('click') return false } } }) function cssColorVar(v){ // get var then convert from hsv to rgb because passing hsv string to animate doesn't work return $('
').css("color",window.getComputedStyle(document.body).getPropertyValue(v))[0].style.color; } function addClick(button,d){ return button.click(function(e){ e.preventDefault() if (d.lastrun){ // Second time button clicked, show a report if($('.autoEditorInfo').length) return // already open var info = $('
').append($("").click(()=>info.parent().remove())), table d.info=info if(d.getBody() != d.lastrun.body || d.getTitle() != d.lastrun.title){ info.append("

Manual edits detected

").append($("").click(()=>replaceFromUi(d))) } if (!d.replacements.length){ info.append($("

No auto-edits

")) } else { info.append($("

Auto-edits

")) table = $("").append($("")) $.each(d.replacements, (x,r)=>{ if (r.i.search(/^[ \t]+/)!=-1 && r.o.search(/^[ \t]+/)!=-1){ r.i=r.i.replace(/^[ \t]+/,"") r.o=r.o.replace(/^[ \t]+/,"") } table.append($("").append($("
FoundReplacedReason
").html(visibleSpace(r.i))).append($("").html(visibleSpace(r.o))).append($("").html(r.r))) }) info.append(table) } d.diffsfrom=$("").on("change",(()=>doDiffs(d))) d.diffsto=$("").on("change",(()=>doDiffs(d))) info.append($("

Diffs from

").append(d.diffsfrom).append(" to ").append(d.diffsto)) info.append(d.diffs = $("
")) doDiffs(d) $('body').prepend($('
').append(info).click(e=>{ if($(e.target).is('.autoEditorInfo')){ e.preventDefault() $(e.target).remove() return false } })) } else { // First time button clicked, do all the replacements replaceFromUi(d) } return false }) } function doDiffs(d){ recordText(d, "now") var diffsfrom=d.diffsfrom.val(), diffsto=d.diffsto.val() try { var title = "" if(d[diffsfrom].title) title += "

" + diff2html(d[diffsfrom].title, d[diffsto].title) + "

" d.diffs.html(title + diff2html(d[diffsfrom].body, d[diffsto].body)) } catch (x){ d.diffs.html("") d.diffs.append($("
").text("Diffs failed to render\n" + x.toString() + "\n\n" + x.stack))
			}
		}

		function replaceFromUi(d){
			if (d.info) d.info.parent().remove()
			recordText(d, "before")
			d.body = d.before.body
			d.title = d.before.title
			edit(d)
			// Flash red or green depending on whether edits were made
			d.flashMe.animate({backgroundColor:d.editCount==0?cssColorVar('--red-100'):cssColorVar('--green-100')},10)
			// Then back to white
			d.flashMe.animate({backgroundColor:cssColorVar('--white')})
			// Update values in UI
			d.setTitle(d.title)
			d.setBody(d.body)
			d.setSummary(buildSummary(d.getSummary(),d.reasons))
			recordText(d,"lastrun")

		}

		function needsButton(editor){
			if (!$(editor).is(':visible')) return false // not showing up
			if ($(editor).find('.autoEditorButton').length > 0) return false // Already has editor
			return true
		}

		function recordText(d,name){
			d[name]={
				title: d.getTitle(),
				body: d.getBody()
			}
		}

		// Continually monitor for newly created editing widgets
		setInterval(()=>{
			$('.wmd-button-bar').each(function(){
				if (needsButton(this)){
					var d = getDefaultData(),
					editContainer = $(this).parents('.inline-editor, .post-form, .post-editor, .js-review-editor').last(),
					bodyBox = editContainer.find('.js-post-body-field'),
					summaryBox = editContainer.find('.js-post-edit-comment-field'),
					titleBox = editContainer.find('.js-post-title-field')
					d.getTitle = function(){
						return titleBox.length?titleBox.val():''
					}
					d.setTitle = function(s){
						if (!titleBox.length) return
						titleBox.val(s)
						titleBox[0].dispatchEvent(new Event('keyup')) // Cause title display to be updated
					}
					d.getBody = function(){
						return bodyBox.val()
					}
					d.setBody = function(s){
						bodyBox.val(s)
						bodyBox[0].dispatchEvent(new Event('keypress')) // Cause markdown re-parse
					}
					d.flashMe = bodyBox
					d.getSummary = function(){
						return summaryBox.val()
					}
					d.setSummary = function(s){
						summaryBox.val(s)
					}
					editContainer.find('.wmd-spacer').last().before($('
  • ')).before(addClick($('
  • '),d)) recordText(d,"initial") } }) $('.js-stacks-editor-container').each(function(){ if (needsButton(this)){ var d = getDefaultData(), editContainer = $(this).parents('.inline-editor, .post-form, .post-editor, .js-review-editor').last(), editArea = editContainer.find('.js-post-body-field'), summaryBox = editContainer.find('.js-post-edit-comment-field') d.getTitle = function(){ return "" // This style editor only used for answers, so never a title } d.setTitle = function(s){} // no-op d.getBody = function(){ return editArea.val() } d.setBody = function(s){ editArea.val(s) } d.flashMe = editContainer.find('.js-editor') d.getSummary = function(){ return summaryBox.val() } d.setSummary = function(s){ summaryBox.val(s) } editContainer.find('.js-editor-btn').last().before(addClick( $('