MediaWiki:POTY-gallery.js
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.
Documentation for this user script can be added at MediaWiki:POTY-gallery. |
// <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>