Jump to content

User:Ingenuity/ReferenceEditorTest.js

From Wikipedia, the free encyclopedia
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.
// <nowiki>

const editableReferences = [];
let currentlySelectedRef;
const rfApi = new mw.Api();
let refsSaved = 0;

const referenceTemplateData = {
	"none": [
		"wikitext"
	],
	"web": [
		"url",
		"title",
		"authors",
		"date",
		"website",
		"accessdate",
		"publisher",
		"archiveurl"
	],
	"news": [
		"url",
		"title",
		"authors",
		"date",
		"work",
		"accessdate",
		"publisher",
		"archiveurl"
	]
};
const supportedArgs = [
	"url", "title", "archive-date", "archivedate", "website", "work",
	"publisher", "archiveurl", "archive-url", "date", "url-status"
];

async function runReferenceEditor() {
	const page = await rfApi.get({
		action: 'query',
		prop: 'revisions',
		rvprop: 'content',
		titles: mw.config.get('wgPageName'),
		formatversion: 2,
		rvslots: '*'
	});

	const wikitext = page.query.pages[0].revisions[0].slots.main.content;
	
	const references = [...wikitext.matchAll(/<ref(?: name="?([^\/]+?)"?)?>(.+?)<\/ref>/gmsi)];
	
	const referenceArgs = references
		.map(ref => [...ref[2].matchAll(/\|(?:\s+)?([^=]+?)(?:\s+)?=(?:\s+)?([^\|]+?)(\s+?)?(?=[\|]|(?:}}$))/gmsi)])
		.map(ref => ref.map(a => [a[1].toLowerCase(), a[2]]));
	
	const cleanedRefs = [];
	
	for (let i = 0; i < references.length; i++) {
		const refUrl = references[i][2].match(/https?:\/\/.+?(?=[\| }])/);
		const citeType = references[i][2].match(/{{cite (.+?)(\s+)?(\||})/i);
		
		if (!refUrl) {
			continue;
		}
		
		cleanedRefs.push({
			type: citeType ? citeType[1] : null,
			url: refUrl[0],
			args: referenceArgs[i],
			wikitext: references[i][2]
		});
	}
	
	const refElems = [...document.querySelectorAll("ol.references > li")];
	
	for (let refElem of refElems) {
		const links = [...refElem.querySelectorAll("a")];
		
		for (let item of cleanedRefs) {
			for (let link of links) {
				if (link.href === item.url && (item.type in referenceTemplateData || !item.type)) {
					editableReferences.push({ item, refElem });
					refElem.style.position = "relative";
					refElem.innerHTML += `<div class="referenceEditorButton" style="position: absolute; top: -3px; left: -42px; cursor: pointer;" onclick="editReference(${editableReferences.length - 1})"><img style="width: 16px; height: 16px;" draggable="false" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAANlJREFUWEftl8EOhCAMROX/P1oDSUkpRaAdOCgeNntQ3qOUQcO197oZLsT/6WfTxeGEDLsENHiS+I1AnKxahZUVICBnSIncA801GmhQbRIViI1TiMWHPXCtj1rjqdWWAt4l6U2mGh8p0IOruw4lYIKTURWPA43HbzHDEQIuuFfADfcIQOBWARh8lYDc61ok5ya2bMO3CvRiGRJEU1Erot4tMAuXxzBUYPTceA06Sw9MBmVx2rorMAuHL8EROBX4fgXQX0vdty0KhpEj1lJ+/kzztZxuWinRjO0HcnI/HmlWsQEAAAAASUVORK5CYIIA"></div>`;
				}
			}
		}
	}
}

function editReference(number) {
	[...document.querySelectorAll(".referenceEditor")].forEach(e => e.remove());
	currentlySelectedRef = editableReferences[number];
	
	const editorElem = document.createElement("div");
	editorElem.className = "referenceEditor";
	
	editorElem.style.width = "600px";
	editorElem.style.height = "500px";
	editorElem.style.position = "fixed";
	editorElem.style.top = "calc(50% - 250px)";
	editorElem.style.left = "calc(50% - 300px)";
	editorElem.style.background = "white";
	editorElem.style.border = "1px solid #333";
	editorElem.style.overflowY = "auto";
	
	editorElem.innerHTML = `
		<div id="referenceType">
			<select name="referenceType">
				<option value="none">No reference template</option>
				<option value="web">{{cite web}}</option>
				<option value="news">{{cite news}}</option>
			</select>
		</div>
		<div id="referenceArgs"></div>
		<div id="additionalArgs"></div>
		<div id="referenceEditorButtons">
			<button onclick="loadReferenceData()">Load</button>
			<button onclick="this.parentElement.parentElement.remove()">Cancel</button>
			<button onclick="saveButtonClicked(this)">Save</button>
		</div>
	`;
	
	document.body.appendChild(editorElem);
	
	const selectElem = document.querySelector("select[name=referenceType]");
	
	selectElem.addEventListener("change", event => {
		selectReferenceType(event.target.value);
	});
	
	selectReferenceType(editableReferences[number].item.type || "none");
	selectElem.value = editableReferences[number].item.type || "none";
}

function selectReferenceType(type) {
	const argsContainer = document.querySelector("#referenceArgs");
	const additionalContainer = document.querySelector("#additionalArgs");
	argsContainer.innerHTML = "";
	additionalContainer.innerHTML = "";
	
	if (!(type in referenceTemplateData)) {
		type = "none";
	}
	
	const argDict = {};
	
	for (let item of currentlySelectedRef.item.args) {
		argDict[item[0]] = item[1];
	}
		
	for (let item of referenceTemplateData[type]) {
		switch (item) {
			case "wikitext":
				argsContainer.innerHTML += `
					<div>
						<span class="title">Wikitext</span>
						<div>
							<textarea id="referenceEditorWikitext">${escapeHTML(currentlySelectedRef.item.wikitext)}</textarea>
						</div>
					</div>
				`;
				return;
			case "title":
				argsContainer.innerHTML += `
					<div>
						<span class="title">Title</span>
						<div>
							<input id="referenceEditorTitle" class="large" value="${escapeHTML(argDict["title"])}">
						</div>
					</div>
				`;
				break;
			case "website":
				argsContainer.innerHTML += `
					<div>
						<span class="title">Website</span>
						<div>
							<input id="referenceEditorWebsite" class="large" value="${escapeHTML(argDict["website"])}">
						</div>
					</div>
				`;
				break;
			case "work":
				argsContainer.innerHTML += `
					<div>
						<span class="title">Work</span>
						<div>
							<input id="referenceEditorWork" class="large" value="${escapeHTML(argDict["work"])}">
						</div>
					</div>
				`;
				break;
			case "date":
				const date = new Date(argDict["date"]);
				let day = "", month = "", year = "";
				if (date.toString() !== "Invalid Date") {
					day = date.getUTCDate();
					month = date.getUTCMonth() + 1;
					year = date.getUTCFullYear();
				}
				argsContainer.innerHTML += `
					<div>
						<span class="title">Date</span>
						<div>
							<input id="referenceEditorDay" type="number" class="small" value="${day}" placeholder="Day">
							<input id="referenceEditorMonth" type="number" class="small" value="${month}" placeholder="Month">
							<input id="referenceEditorYear" type="number" class="small" value="${year}" placeholder="Year">
						</div>
					</div>
				`;
				break;
			case "url":
				argsContainer.innerHTML += `
					<div>
						<span class="title">URL</span>
						<div>
							<input id="referenceEditorURL" class="large" value="${escapeHTML(currentlySelectedRef.item.url)}">
						</div>
					</div>
				`;
				break;
			case "authors":
				let authorCount = "first" in argDict || "first1" in argDict ? 1 : 0;
				while ("first" + (authorCount + 1) in argDict) {
					authorCount++;
				}
				let authorsHTML = `
					<span class="title">Authors</span><div id="referenceEditorAuthors">
				`;
				for (let i = 0; i < authorCount; i++) {
					authorsHTML += `
						<div class="referenceEditorAuthor">
							<input placeholder="Last" class="referenceEditorLast" value="${escapeHTML(i === 0 ? argDict["last"] || argDict["last1"] || "" : argDict["last" + (i + 1)] || "")}">
							<input placeholder="First" class="referenceEditorFirst" value="${escapeHTML(i === 0 ? argDict["first"] || argDict["first1"] || "" : argDict["first" + (i + 1)] || "")}">
							<button onclick="this.parentElement.remove()">Remove</button>
						</div>
					`;
				}
				authorsHTML += `
					<span style="cursor: pointer; user-select: none; font-size: 0.9em;" onclick="addAdditionalAuthor()">+ Add additional author</span></div>
				`;
				argsContainer.innerHTML += `<div>${authorsHTML}</div>`;
				break;
			case "publisher":
				if (!("publisher" in argDict)) {
					additionalContainer.innerHTML += `
						<span onclick="addAdditionalArg('publisher'); this.remove();">+ Publisher</span>
					`;
				} else {
					addAdditionalArg("publisher", { publisher: argDict["publisher"] });
				}
				break;
			case "archiveurl":
				if (!("archiveurl" in argDict) && !("archive-url" in argDict)) {
					addAdditionalArg("archiveurl");
				} else {
					const date = new Date(argDict["archive-date"] || argDict["archivedate"]);
					let day = "", month = "", year = "";
					if (date.toString() !== "Invalid Date") {
						day = date.getUTCDate();
						month = date.getUTCMonth() + 1;
						year = date.getUTCFullYear();
					}
					addAdditionalArg("archiveurl", { day, month, year, url: argDict["archiveurl"] || argDict["archive-url"], status: argDict["url-status"] });
				}
				break;
			default:
				break;
		}
	}
	
	for (let item in argDict) {
		if (supportedArgs.includes(item) || item.startsWith("last") || item.startsWith("first")) {
			continue;
		}
		
		argsContainer.innerHTML += `
			<div>
				<span class="title">${item}</span>
				<div>
					<input class="large" data-arg="${item}" value="${escapeHTML(argDict[item])}">
				</div>
			</div>
		`;
	}

	addAdditionalAuthor();
}

function addAdditionalArg(type, data) {
	data = data || {};
	switch (type) {
		case "archiveurl":
			document.querySelector("#referenceArgs").insertAdjacentHTML("beforeend", `
				<div>
					<span class="title">Archive URL</span>
					<div>
						<input id="referenceEditorArchiveURL" value="${escapeHTML(data.url || "")}" class="large">
					</div>
					<div class="referenceEditorArgTools">
						<button onclick="referenceFixerLoadArchive(this)">Load</button>
					</div>
				</div>
				<div>
					<span class="title">Archive date</span>
					<div>
						<input id="referenceEditorArchiveDay" type="number" class="small" value="${data.day || ""}" placeholder="Day">
						<input id="referenceEditorArchiveMonth" type="number" class="small" value="${data.month || ""}" placeholder="Month">
						<input id="referenceEditorArchiveYear" type="number" class="small" value="${data.year || ""}" placeholder="Year">
					</div>
				</div>
				<div>
					<span class="title">URL status</span>
					<div>
						<select id="referenceEditorURLStatus">
							<option name="live" ${data.status === "live" || !data.status ? "selected" : ""}>Live</option>
							<option name="dead" ${data.status === "dead" ? "selected" : ""}>Dead</option>
						</select>
					</div>
				</div>
			`);
			break;
		case "publisher":
			document.querySelector("#referenceArgs").insertAdjacentHTML("beforeend", `
				<div>
					<span class="title">Publisher</span>
					<div>
						<input id="referenceEditorPublisher" value="${escapeHTML(data.publisher || "")}">
					</div>
				</div>
			`);
			break;
		default:
			break;
	}
}

if (document.readyState === "complete") {
	refEditorLoadStylesheet();
}

window.addEventListener("load", refEditorLoadStylesheet);

function refEditorLoadStylesheet() {
	const style = document.createElement("style");
	style.innerHTML = `
		#referenceArgs > div > span.title {
			display: block;
			margin: 2px 0;
			font-weight: bold;
			font-size: 0.9em;
			width: 120px;
			padding: 5px;
			flex-shrink: 0;
		}

		#referenceArgs > div {
			display: flex;
			border-bottom: 1px solid #ddd;
		}

		#referenceArgs > div > div {
			width: 100%;
			display: flex;
			align-items: center;
			flex-wrap: wrap;
		}

		#referenceArgs textarea {
			height: 100px;
		}

		#referenceArgs input.large {
			width: 100%;
		}

		#referenceArgs input {
			height: 100%;
			box-sizing: border-box;
			border: none;
			outline: none !important;
		}

		#referenceArgs input.small {
			width: 60px;
		}

		.referenceEditorAuthor {
			margin: 5px 0;
			display: flex;
		}

		#referenceEditorAuthors {
			padding-bottom: 5px;
		}

		#referenceEditorButtons {
			display: flex;
			justify-content: flex-end;
		}

		#referenceEditorButtons button {
			margin: 5px;
		}

		#additionalArgs span {
			font-weight: bold;
			cursor: pointer;
			user-select: none;
			display: inline-block;
			margin-left: 10px;
			font-size: 0.9em;
		}

		.referenceEditorArgTools {
			position: absolute;
			width: 100%;
			display: flex;
			justify-content: flex-end;
		}

		#referenceEditorCount {
			position: fixed;
			top: calc(100% - 50px);
			left: 15px;
			font-size: 0.9em;
			user-select: none;
			cursor: pointer;
		}
	`;
	document.head.appendChild(style);
}

function escapeHTML(unsafe) {
	if (!unsafe) {
		return "";
	}
	return unsafe
		.replace(/&/g, "&amp;")
		.replace(/</g, "&lt;")
		.replace(/>/g, "&gt;")
		.replace(/"/g, "&quot;")
		.replace(/'/g, "&#039;");
}

function addAdditionalAuthor() {
	const elem = document.querySelector("#referenceEditorAuthors");
	
	const author = document.createElement("div");
	author.className = "referenceEditorAuthor";
	author.innerHTML = `
		<input placeholder="Last" class="referenceEditorLast">
		<input placeholder="First" class="referenceEditorFirst">
		<button onclick="this.parentElement.remove()">Remove</button>
	`;
	elem.insertBefore(author, elem.children[elem.children.length - 1]);
}

function getInputValue(id) {
	id = "referenceEditor" + id;
	return document.getElementById(id) ? document.getElementById(id).value : false;
}

function getAuthors() {
	return [...document.querySelectorAll(".referenceEditorAuthor")]
		.map(elem => {
			const first = elem.querySelector(".referenceEditorFirst").value;
			const last = elem.querySelector(".referenceEditorLast").value;
			
			return !first || !last ? false : [ first, last ];
		})
		.filter(elem => elem);
}

function padNum(num, length) {
	num = num.toString();
	while (num.length < length) {
		num = "0" + num;
	}
	return num;
}

function saveReference() {
	const title = getInputValue("Title");
	const website = getInputValue("Website");
	const [ day, month, year ] = [ getInputValue("Day"), getInputValue("Month"), getInputValue("Year") ];
	const work = getInputValue("Work");
	const authors = getAuthors();
	const publisher = getInputValue("Publisher");
	const url = getInputValue("URL");
	const archiveurl = getInputValue("ArchiveURL");
	const [ aday, amonth, ayear ] = [ getInputValue("ArchiveDay"), getInputValue("ArchiveMonth"), getInputValue("ArchiveYear") ];
	const urlstatus = (getInputValue("URLStatus") || "").toLowerCase();
	
	const refType = document.querySelector("select[name=referenceType]").value;
	const args = [];
	const argumentsAvailable = referenceTemplateData[refType];
	
	if (argumentsAvailable.includes("title") && title) {
		args.push([ "title", title ]);
	}
	
	if (argumentsAvailable.includes("url") && url) {
		args.push([ "url", url ]);
	}
	
	if (argumentsAvailable.includes("website") && website) {
		args.push([ "website", website ]);
	}
	
	if (argumentsAvailable.includes("date") && day && month && year &&
	   	day > 0 && day < 32 && month > 0 && month < 13) {
		args.push([ "date", year + "-" + padNum(month, 2) + "-" + padNum(day, 2) ]);
	}
	
	if (argumentsAvailable.includes("archiveurl") && archiveurl && urlstatus && aday && amonth && ayear &&
	   	aday > 0 && aday < 32 && amonth > 0 && amonth < 13) {
		args.push([ "archive-url", archiveurl ]);
		args.push([ "archive-date", ayear + "-" + padNum(amonth, 2) + "-" + padNum(aday, 2) ]);
		args.push([ "url-status", urlstatus ]);
	}
	
	if (argumentsAvailable.includes("work") && work) {
		args.push([ "work", work ]);
	}
	
	for (let i = 0; i < authors.length; i++) {
		args.push([ "last" + (i + 1), authors[i][1] ]);
		args.push([ "first" + (i + 1), authors[i][0] ]);
	}
	
	if (argumentsAvailable.includes("publisher") && publisher) {
		args.push([ "publisher", publisher ]);
	}
	
	const additionalArgs = [...document.querySelectorAll("input[data-arg]")];
	
	additionalArgs.forEach(arg => {
		if (!arg.value) {
			return;
		}
		
		args.push([ arg.attributes["data-arg"].value, arg.value ]);
	});
	
	const argText = args
		.map(arg => `|${arg[0]}=${arg[1]}`)
		.join(" ");
	
	if (refType === "none") {
		return document.querySelector("#referenceEditorWikitext").value;
	}
	
	return `{{cite ${refType} ${argText}}}`;
}

async function referenceFixerLoadArchive(button) {
	button.innerText = "Loading...";
	button.disabled = true;
	const url = getInputValue("URL");
	const archive = await getArchiveURL(url);
	button.remove();
	
	if (!url) {
		return;
	}
	
	document.querySelector("#referenceEditorArchiveURL").value = archive.url;
	document.querySelector("#referenceEditorArchiveDay").value = archive.day;
	document.querySelector("#referenceEditorArchiveMonth").value = archive.month;
	document.querySelector("#referenceEditorArchiveYear").value = archive.year;
}

async function getArchiveURL(url) {
	try {
		const response = await fetch("https://archive.org/wayback/available?url=" + url);
		const json = await response.json();

		if (!json["archived_snapshots"] || !json["archived_snapshots"]["closest"]) {
			return { url: "", day: "", month: "", year: "" };
		}

		const { timestamp, url: archiveURL } = json["archived_snapshots"]["closest"];
		const [_, year, month, day] = timestamp.match(/(\d{4})(\d{2})(\d{2})/);

		return { url: archiveURL, day, month, year };
	} catch (e) {
		console.log("Could not fetch archive url: " + e);
		return { url: "", day: "", month: "", year: "" };
	}
}

async function saveButtonClicked(button) {
	if (!currentlySelectedRef.item.replace && saveReference() !== currentlySelectedRef.item.wikitext) {
		refsSaved++;
	}
	if (refsSaved === 1) {
		document.body.insertAdjacentHTML("beforeend", `
			<div id="referenceEditorCount" onclick="referenceEditorSave()"></div>
		`);
	}
	if (refsSaved) {
		document.querySelector("#referenceEditorCount").innerHTML = `
			${refsSaved} reference${refsSaved === 1 ? "" : "s"} edited<br>
			Click here to save
		`;
	}
	currentlySelectedRef.item.replace = saveReference();
	const refText = currentlySelectedRef.refElem.querySelector(".reference-text");
	button.parentElement.parentElement.remove();
	refText.innerHTML = "Loading...";
	refText.innerHTML = await wikitextToHTML(currentlySelectedRef.item.replace);
	refText.children[0].style.display = "inline";
}

async function referenceEditorSave() {
	const page = await rfApi.get({
		action: 'query',
		prop: 'revisions',
		rvprop: 'content',
		titles: mw.config.get('wgPageName'),
		formatversion: 2,
		rvslots: '*'
	});

	let wikitext = page.query.pages[0].revisions[0].slots.main.content;
	
	for (let item of editableReferences) {
		if (!item.item.replace) {
			continue;
		}
		
		wikitext = wikitext.replaceAll(item.item.wikitext, item.item.replace);
	}
	
	await rfApi.postWithEditToken({
		"action": "edit",
		"title": mw.config.get('wgPageName'),
		"text": wikitext,
		"summary": `Edited ${refsSaved} reference${refsSaved === 1 ? "" : "s"}`,
		"format": "json"
	});
	
	location.reload();
}

async function wikitextToHTML(wikitext) {
	let deferred = $.Deferred();
	$.post("https://en.wikipedia.org/api/rest_v1/transform/wikitext/to/html",
		"wikitext=" + encodeURIComponent(wikitext) + "&body_only=true",
		function (data) {
			deferred.resolve(data);
		}
	);
	
	return deferred;
}

async function loadReferenceData() {
	const response = await fetch("http://127.0.0.1:8000/url?sitename=" + encodeURIComponent(currentlySelectedRef.item.url));
	const data = await response.json();
	
	if ("website" in data.metadata && data.metadata["website"]) {
		document.querySelector(currentlySelectedRef.item.type === "news" ? "#referenceEditorWork" : "#referenceEditorWebsite").value = data.metadata["website"];
	}

	if ("title" in data.metadata && data.metadata["title"]) {
		document.querySelector("#referenceEditorTitle").value = data.metadata["title"];
	}

	if ("date" in data.metadata && data.metadata["date"]) {
		const date = new Date(data.metadata["date"]);
		document.querySelector("#referenceEditorDay").value = date.getUTCDate();
		document.querySelector("#referenceEditorMonth").value = date.getUTCMonth() + 1;
		document.querySelector("#referenceEditorYear").value = date.getUTCFullYear();
	}

	if ("author" in data.metadata && data.metadata["author"] && data.metadata["author"].split(" ").length > 1) {
		const author = data.metadata["author"].split(" ");
		document.querySelector(".referenceEditorFirst").value = author[0];
		document.querySelector(".referenceEditorLast").value = author[1];
	}

	if ("archive-url" in data.metadata && data.metadata["archive-url"]) {
		const [ year, month, day ] = data.metadata["archive-date"].split("-");
		document.querySelector("#referenceEditorArchiveURL").value = data.metadata["archive-url"];
		document.querySelector("#referenceEditorArchiveDay").value = day;
		document.querySelector("#referenceEditorArchiveMonth").value = month;
		document.querySelector("#referenceEditorArchiveYear").value = year;
	}
}

runReferenceEditor();

// </nowiki>