MediaWiki:POTY-gallery.js

From Wikimedia Commons, the free media repository
Jump to navigation Jump to search
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
// <nowiki>

(function() {
	const potyInfo = {
		year: null,
		category: null,
		username: mw.user.getName(),
		finalist: false,
		imageList: null
	};

	const potyAPI = {
		api: new mw.Api(),
		getPageContent: async function(pageToFetch) {
			const pageData = await this.api.get({
				action: "query",
				prop: "revisions",
				rvprop: "content",
				format: "json",
				titles: pageToFetch
			});

			try {
				return pageData.query.pages[Object.keys(pageData.query.pages)[0]].revisions[0]["*"];
			} catch (e) {
				return null;
			}
		},
		getPagesContent: async function(pagesToFetch, fetchOnAltWiki=false) {
			const allPageData = {};

			// fetch the content of each page in chunks of 50
			for (let i = 0; i < pagesToFetch.length; i += 50) {
				const apiQuery = {
					action: "query",
					prop: "revisions",
					rvprop: "content",
					format: "json",
					titles: pagesToFetch.slice(i, i + 50)
				};
				let pageData;

				if (mw.config.values.wgWikiID !== "commonswiki" && !fetchOnAltWiki) {
					const apiUrl = "https://commons.wikimedia.org/w/api.php?";
					const response = await fetch(apiUrl + new URLSearchParams(apiQuery).toString() + "&origin=*");
					pageData = await response.json();
				} else {
					// if we are on Commons, use the normal API
					pageData = await this.api.get(apiQuery);
				}

				// add each page's content to the map
				for (const page of Object.values(pageData.query.pages)) {
					if (page.revisions) {
						allPageData[page.title] = page.revisions[0]["*"];
					}
				}
			}

			return allPageData;
		},
		savePage: async function(title, content, summary) {
			await this.api.postWithToken("edit", {
				action: "edit",
				format: "json",
				title: title,
				text: content,
				summary: summary,
				tags: "poty-script",
				watchlist: "unwatch"
			});
		},
		getImageProperties: async function(pagesToFetch) {
			// get the image properties (image url, artist, description) for the given pages
			const propDict = {};

			// fetch pages, 50 at a time
			for (let i = 0; i < pagesToFetch.length; i += 50) {
				const queryParams = {
					action: "query",
					prop: "imageinfo",
					iiprop: "url|extmetadata",
					format: "json",
					titles: pagesToFetch.slice(i, i + 50)
				};
				let pageData;

				if (mw.config.values.wgWikiID === "commonswiki") {
					pageData = await this.api.get(queryParams);
				} else {
					// for testing purposes, use a proxy to avoid CORS issues
					const apiUrl = "https://commons.wikimedia.org/w/api.php?";
					const response = await fetch(apiUrl + new URLSearchParams(queryParams).toString() + "&origin=*");
					pageData = await response.json();
				}

				for (const page of Object.values(pageData.query.pages)) {
					if (page.missing === "") {
						continue;
					}

					for (const image of Object.values(page.imageinfo)) {
						propDict[page.title] = {
							url: image.url,
							artist: image.extmetadata.Artist ? image.extmetadata.Artist.value : "",
							description: image.extmetadata.ImageDescription ? image.extmetadata.ImageDescription.value : ""
						};
					}
				}
			}

			return propDict;
		},
		getUserGlobalStats: async function(username) {
			try {
				const userData = await this.api.get({
					action: "query",
					meta: "globaluserinfo",
					guiuser: username,
					guiprop: "editcount",
					format: "json"
				});

				// if the user does not exist, return 0 edit count and 0 registration date
				if (userData.query.globaluserinfo.missing === "") {
					return { editCount: 0, registrationDate: 0 };
				}

				return {
					editCount: userData.query.globaluserinfo.editcount,
					registrationDate: new Date(userData.query.globaluserinfo.registration)
				};
			} catch (e) {
				// probably not signed in
				return { editCount: -1, registrationDate: -1 };
			}
		}
	};

	// various functions to interact with the local storage
	// this is not saved between computers
	const potyStorage = {
		initialize: function() {
			const data = mw.storage.store.getItem("poty-votes-" + potyInfo.year);

			if (data === null) {
				mw.storage.store.setItem("poty-votes-" + potyInfo.year, JSON.stringify([]));
				mw.storage.store.setItem("finalist-poty-votes-" + potyInfo.year, JSON.stringify([]));
			}
		},
		checkVoted: function(image) {
			const data = mw.storage.store.getItem((potyInfo.finalist ? "finalist-" : "") + "poty-votes-" + potyInfo.year);
			const parsed = JSON.parse(data);

			return parsed.includes(image);
		},
		addVote: function(image) {
			const data = mw.storage.store.getItem((potyInfo.finalist ? "finalist-" : "") + "poty-votes-" + potyInfo.year);
			const parsed = JSON.parse(data);

			if (!parsed.includes(image)) {
				parsed.push(image);
				mw.storage.store.setItem((potyInfo.finalist ? "finalist-" : "") + "poty-votes-" + potyInfo.year, JSON.stringify(parsed));
			}
		},
		removeVote: function(image) {
			const data = mw.storage.store.getItem((potyInfo.finalist ? "finalist-" : "") + "poty-votes-" + potyInfo.year);
			const parsed = JSON.parse(data);

			const index = parsed.indexOf(image);
			if (index > -1) {
				parsed.splice(index, 1);
				mw.storage.store.setItem((potyInfo.finalist ? "finalist-" : "") + "poty-votes-" + potyInfo.year, JSON.stringify(parsed));
			}
		},
		finalistVoteCount: function() {
			// get the number of finalists this user has voted for
			const data = mw.storage.store.getItem("finalist-poty-votes-" + potyInfo.year);
			const parsed = JSON.parse(data);

			return parsed.length;
		}
	};
	
	/**
	 * Return the translation for the given key as plain text.
	 *
	 * Use with textContent and similar APIs.
	 */
	function translationText(key) {
		return potyInfo.translations[key];
	}
	
	/**
	 * Return the translation for the given key as HTML-escaped text.
	 *
	 * Use with innerHTML/outerHTML or directly in HTML template strings.
	 */
	function translationHtml(key) {
		return mw.html.escape(translationText(key));
	}

	function getVotingUsers(pageContent) {
		if (!pageContent) {
			return [];
		}

		const users = [];

		// get the users who voted for this image
		pageContent.split("\n").forEach(line => {
			if (!line.startsWith("# ")) {
				return;
			}

			users.push(line.slice(2).trim());
		});

		return users;
	}

	function getVotingPage(image) {
		return (mw.config.values.wgWikiID === "commonswiki" ? "Commons:" : "") + "Picture of the Year/" + potyInfo.year + "/" + (potyInfo.finalist ? "R2" : "R1") + "/votes/" + image;
	}

	async function vote(image) {
		// cannot vote for more than three finalists
		if (potyStorage.finalistVoteCount() >= 3) {
			return "finalist-vote-limit";
		}

		if (potyInfo.finalist) {
			// but since that's just local device storage, we need to check each finalist voting page
			const votePages = potyInfo.imageList.map(image => getVotingPage(image.title));
			const pageContents = await potyAPI.getPagesContent(votePages, true);
			let userVoteCount = 0;

			for (const page in pageContents) {
				const votingUsers = getVotingUsers(pageContents[page]);
				userVoteCount += votingUsers.includes(potyInfo.username) ? 1 : 0;
			}

			if (userVoteCount >= 3) {
				return "finalist-vote-limit";
			}
		}

		// make sure the user is eligible to vote
		if (!(await checkIsEligible())) {
			return "not-eligible";
		}

		// get the voting page for the image
		const votePage = getVotingPage(image);
		const pageContent = await potyAPI.getPageContent(votePage);
		const votingUsers = getVotingUsers(pageContent);
		potyStorage.addVote(image);

		// if the user has already voted, return "already-voted"
		if (votingUsers.includes(potyInfo.username)) {
			return "already-voted";
		}

		// save the new content
		votingUsers.push(potyInfo.username);
		const newContent = votingUsers.map(user => "# " + user).join("\n");
		await potyAPI.savePage(votePage, newContent, "Added vote via POTY helper script");

		return "success";
	}

	async function removeVote(image) {
		// make sure we can change votes, given the current competition stage
		const competitionStage = await getCompetitionStage();

		if ((competitionStage !== "first-round" && !potyInfo.finalist) || (competitionStage !== "second-round" && potyInfo.finalist)) {
			return false;
		}

		// get the voting page for the image
		const pageContent = await potyAPI.getPageContent(getVotingPage(image));
		const votingUsers = getVotingUsers(pageContent);
		const index = votingUsers.indexOf(potyInfo.username);
		potyStorage.removeVote(image);

		// if the user is not in the list of voting users, return false
		if (index === -1) {
			return false;
		}

		// remove from list of voters, and save the new content
		votingUsers.splice(index, 1);
		const newContent = votingUsers.map(user => "# " + user).join("\n");
		await potyAPI.savePage(getVotingPage(image), newContent, "Removed vote via POTY helper script");

		return true;
	}

	function addGalleryStylesheet() {
		document.head.innerHTML += `
			<style>
				.poty-gallery {
					display: flex;
					flex-wrap: wrap;
					justify-content: center;
				}

				.poty-gallery-image {
					display: flex;
					flex-direction: column;
					align-items: center;
					margin: 10px;
					padding: 15px;
					border: 1px solid #ccc;
					border-radius: 5px;
					width: 300px;
				}

				.poty-gallery-image img {
					max-width: 100%;
					max-height: 300px;
					cursor: pointer;
				}

				.poty-gallery-image span {
					font-size: 0.9em;
					margin: 7px 0;
					color: #666666;
					display: block;
				}

				.poty-gallery-buttons {
					display: flex;
					width: 100%;
				}

				.poty-gallery-buttons button {
					padding: 10px;
					width: 50%;
					outline: none;
					border: none;
					border-radius: 5px;
					margin: 5px;
					cursor: pointer;
				}

				.poty-gallery-view {
					border: 1px solid #bba !important;
					background: linear-gradient(to bottom, #ddd 0%, #ccc 90%) !important;
				}

				.poty-gallery-view:hover {
					background: linear-gradient(to bottom, #ccc 0%, #bbb 90%) !important;
				}

				.poty-gallery-vote, .poty-gallery-vote-disabled {
					border: 1px solid #468 !important;
					background: linear-gradient(to bottom, #48e 0%, #36b 90%) !important;
					color: white;
				}

				.poty-gallery-vote-disabled {
					background: linear-gradient(to bottom, #aac 0%, #99b 90%) !important;
					border: 1px solid #779 !important;
					cursor: not-allowed !important;
				}

				.poty-gallery-vote:hover:not(.poty-gallery-vote-disabled) {
					background: linear-gradient(to bottom, #37d 0%, #25a 90%) !important;
				}

				.poty-gallery-vote-voted {
					border: 1px solid #486 !important;
					background: linear-gradient(to bottom, #3d7 0%, #2a5 90%) !important;
					color: white;
				}
			</style>
		`;
	}

	function randomizeCandidates(candidates) {
		// standard array shuffle
		const shuffled = candidates.slice();

		for (let i = shuffled.length - 1; i > 0; i--) {
			const j = Math.floor(Math.random() * (i + 1));
			[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
		}

		return shuffled;
	}

	async function checkIsEligible() {
		// get the user's edit count and registration date from the global user stats
		const userData = await potyAPI.getUserGlobalStats(potyInfo.username);

		// the user must have made 75 edits and be registered before the end of the competition year
		return userData.editCount >= 75 && userData.registrationDate < new Date((potyInfo.year + 1).toString());
	}

	async function getCompetitionStage() {
		// get the competition stage from the json file ("not-started", "first-round", "second-round", "completed")
		try {
			const pageContent = await potyAPI.getPageContent((mw.config.values.wgWikiID === "commonswiki" ? "Commons:" : "") + "Picture of the Year/" + potyInfo.year + "/poty.json");
			const data = JSON.parse(pageContent);

			return data.stage;
		} catch (e) {
			return null;
		}
	}

	async function createGallery() {
		// get the contents of the json file for this category
		const jsonLink = (mw.config.values.wgWikiID === "commonswiki" ? "Commons:" : "") + "Picture of the Year/" + potyInfo.year + "/Gallery/" + potyInfo.category + "/data.json";
		const pageContents = await potyAPI.getPageContent(jsonLink);
		const candidates = JSON.parse(pageContents);
		candidates.forEach(cand => cand.title = cand.title.trim());
		potyInfo.imageList = candidates;

		// get the image properties for each candidate
		const imageProperties = await potyAPI.getImageProperties(candidates.map(candidate => "File:" + candidate.title));
		const imageUrls = {};

		// for each image, convert the full image url to a thumbnail (300px) url
		for (const image in imageProperties) {
			const regex = /(^.+\/wikipedia\/commons\/)(.+)/;
			const match = imageProperties[image].url.match(regex);
			// if the image is .svg or .tif, we need to convert it to .png by appending that to the url
			const altType = (image.endsWith(".svg") || image.endsWith(".tif") ? ".png" : "");
			imageUrls[image] = match[1] + "thumb/" + match[2] + "/300px-" + image + altType;
		}

		// create stylesheet
		addGalleryStylesheet();

		// find and clear the existing gallery container
		const container = document.getElementById("poty-gallery-container");
		container.innerHTML = "";

		// create the gallery element
		const gallery = document.createElement("div");
		gallery.classList.add("poty-gallery");
		container.appendChild(gallery);

		// randomize the order of the candidates + loop through them
		for (const candidate of randomizeCandidates(candidates)) {
			// if the image is not available, skip it (it was probably deleted)
			if (!imageProperties["File:" + candidate.title]) {
				continue;
			}

			// create the container, image, and description for the candidate
			const imageUrl = imageUrls["File:" + candidate.title];
			const creditLink = candidate.nominator === candidate.uploader
				? `${translationHtml("uploaded-and-nominated-by")} <a href="https://commons.wikimedia.org/wiki/User:${candidate.nominator}">${candidate.nominator}</a>`
				: `${translationHtml("uploaded-by")} <a href="https://commons.wikimedia.org/wiki/User:${candidate.uploader}">${candidate.uploader}</a> ${translationHtml("and-nominated-by")} <a href="https://commons.wikimedia.org/wiki/User:${candidate.nominator}">${candidate.nominator}</a>`;

			const descriptionElem = document.createElement("div");
			descriptionElem.innerHTML = imageProperties["File:" + candidate.title].description;
			const desc = descriptionElem.innerText.slice(0, 100) + (descriptionElem.innerText.length > 100 ? "..." : "");
			const voted = potyStorage.checkVoted(candidate.title, potyInfo.finalist);

			gallery.innerHTML += `
				<div class="poty-gallery-image" data-image="${encodeURIComponent(candidate.title)}">
					<img src="${imageUrl.replaceAll("\"", "%22")}" onclick="window.open('${imageProperties["File:" + candidate.title].url.replaceAll("'", "\\'")}')">
					<div class="poty-gallery-info">
						<span>${mw.html.escape(desc)}</span>
						<span>${creditLink}</span>
					</div>
					<div style="flex-grow: 1;"></div>
					<div class="poty-gallery-buttons">
						<button
							class="poty-gallery-view"
							onclick="window.open('https://commons.wikimedia.org/wiki/File:${encodeURIComponent(candidate.title).replaceAll("'", "\\'")}')">${translationHtml("view-file")}</button>
						<button
							class="${voted ? "poty-gallery-vote-voted" : "poty-gallery-vote"}"
							data-image="${encodeURIComponent(candidate.title)}"
							data-text="${voted ? translationHtml("voted") : translationHtml("vote-for-this-image")}">${translationHtml("loading")}</button>
					</div>
				</div>
			`;
		}

		// if this is the finalist category, check if the user has already voted for three images
		if (potyInfo.finalist && potyStorage.finalistVoteCount() >= 3) {
			updateButtons(true, translationText("vote-limit-reached"));
		}

		// if this stage has not started yet, disable voting buttons
		const stage = await getCompetitionStage();
		if (stage === "not-started" || (stage === "first-round" && potyInfo.finalist)) {
			updateButtons(true, translationText("not-started-yet"));
		}

		// if the voting is over for this stage, disable voting buttons
		if ((stage === "second-round" && !potyInfo.finalist) || stage === "completed") {
			updateButtons(true, translationText("voting-closed"), true);
		}

		// if the user is ineligible to vote, disable voting buttons
		if (mw.user.isAnon() || !(await checkIsEligible())) {
			updateButtons(true, translationText("not-eligible"));
		}

		// add click event listeners to all voting buttons
		[
			...document.getElementsByClassName("poty-gallery-vote"),
			...document.getElementsByClassName("poty-gallery-vote-voted")
		].forEach(button => {
			if (button.innerText === translationText("loading")) {
				button.innerText = button.dataset.text;
			}

			button.addEventListener("click", () => {
				voteButtonPressed(button, button.dataset.image);
			});
		});

		// interface with the poty-admin script to add "remove image" and "recategorize image" links
		if (window.potyAdminCreateGalleryLinks) {
			window.potyAdminCreateGalleryLinks(potyInfo.category, (mw.config.values.wgWikiID === "commonswiki" ? "Commons:" : "") + "Picture of the Year/" + potyInfo.year);
		}
	}

	function updateButtons(shouldDisable, message, disableVotedButtons=false) {
		const voteButtons = [
			...document.getElementsByClassName("poty-gallery-vote"),
			...document.getElementsByClassName("poty-gallery-vote-disabled")
		];

		// either disable or enable all voting buttons based on shouldDisable parameter
		voteButtons.forEach(button => {
			button.className = shouldDisable ? "poty-gallery-vote-disabled" : "poty-gallery-vote";
			button.disabled = shouldDisable;
			button.innerText = shouldDisable ? message : translationText("vote-for-this-image");
		});

		[...document.getElementsByClassName("poty-gallery-vote-voted")].forEach(button => {
			button.disabled = disableVotedButtons;
		});
	}

	async function voteButtonPressed(button, image) {
		// get the image's filename
		const filename = decodeURIComponent(image);

		// disable the button until the click handling is complete
		button.classList.add("poty-gallery-vote-disabled");
		button.disabled = true;

		let hitVoteLimit = false;

		// if the button is already voted, remove the vote
		if (button.classList.contains("poty-gallery-vote-voted")) {
			button.innerText = translationText("removing-vote");
			let editResult;

			try {
				editResult = await removeVote(filename);
			} catch (e) {
				mw.notify(translationText("something-went-wrong-removing"));
				return;
			}

			// give a success or failure notification
			mw.notify(editResult ? translationText("vote-removed-successfully") : translationText("you-have-not-voted-for"));

			// reset the button and re-enable it
			button.className = "poty-gallery-vote";
			button.innerText = translationText("vote-for-this-image");
			button.disabled = false;
		} else {
			// otherwise, start the voting process
			button.innerText = translationText("voting");

			// check if the image has already been voted for using the local storage
			// this only works if the user voted on the same computer
			const voted = potyStorage.checkVoted(filename);

			if (voted) {
				return;
			}

			// vote for the image
			const voteResult = await vote(filename);

			switch (voteResult) {
				case "success":
					mw.notify(translationText("voted-successfully"));
					break;
				case "already-voted":
					mw.notify(translationText("already-voted"));
					break;
				case "finalist-vote-limit":
					mw.notify(translationText("finalist-vote-limit"));
					hitVoteLimit = true;
					break;
				case "not-eligible":
					mw.notify(translationText("you-are-not-eligible"));
					break;
			}

			// update and re-enable the button
			if (voteResult === "success" || voteResult === "already-voted") {
				button.innerText = translationText("voted");
				button.className = "poty-gallery-vote-voted";
				button.disabled = false;
			}
		}

		// if the image is a finalist and the user has already voted for three images, disable all voting buttons
		if (hitVoteLimit && potyInfo.finalist) {
			updateButtons(true, translationText("vote-limit-reached"));
		}
	}

	async function loadTranslations() {
		// get the language the user set in their preferences, or default to English
		const userLanguage = mw.config.values.wgUserLanguage || "en";
		const translationDataPage = mw.config.values.wgWikiID === "commonswiki" ? "Commons:Picture of the Year/i18n.json" : "Picture of the Year/i18n.json";

		potyInfo.translations = {};

		try {
			// get the json data from the translation page
			const pageContent = await potyAPI.getPagesContent([translationDataPage], true);
			const translationData = JSON.parse(pageContent[translationDataPage]);

			// for each phrase, check if the user's language is available, and if not, use English
			for (const item of Object.keys(translationData)) {
				potyInfo.translations[item] = translationData[item][userLanguage] || translationData[item]["en"];
			}
		} catch (e) {
			console.log("Failed to load translations", e);
		}
	}

	async function runPoty() {
		// check if the page is a gallery page
		const match = mw.config.values.wgTitle
			.replaceAll("_", " ")
			.match(/Picture of the Year\/(\d{4})\/Gallery\/([^\/]+)$/);

		// year must be 2023 or later, and the page must be being viewed
		if (match && Number(match[1]) > 2022 && mw.config.values.wgAction === "view") {
			potyInfo.year = Number(match[1]);
			potyInfo.category = match[2];
			potyInfo.finalist = potyInfo.category === "Finalists";

			// load translations
			await loadTranslations();

			potyStorage.initialize();
			createGallery();
		}
	}

	// run poty when page is ready
	if (document.readyState === "complete") {
		runPoty();
	} else {
		window.addEventListener("load", runPoty);
	}
})();

// </nowiki>