User:Andrybak/Scripts/Archiver.js
Appearance
< User:Andrybak | Scripts
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Andrybak/Scripts/Archiver. |
/*
* This script is a fork of https://en.wikipedia.org/w/index.php?title=User:Enterprisey/archiver.js&oldid=1113588553
* which was forked from https://en.wikipedia.org/w/index.php?title=User:%CE%A3/Testing_facility/Archiver.js&oldid=1003561411
*/
const USERSCRIPT_NAME = "Archiver";
function notifyUser(notificationMessage) {
mw.notify(notificationMessage, {
title: USERSCRIPT_NAME
});
}
const LOG_PREFIX = `[${USERSCRIPT_NAME}]:`;
function error(...toLog) {
console.error(LOG_PREFIX, ...toLog);
}
function warn(...toLog) {
console.warn(LOG_PREFIX, ...toLog);
}
function info(...toLog) {
console.info(LOG_PREFIX, ...toLog);
}
function debug(...toLog) {
console.debug(LOG_PREFIX, ...toLog);
}
$.when( mw.loader.using(['mediawiki.util','mediawiki.api']), $.ready).done( function () {
if (mw.config.get("wgNamespaceNumber") % 2 == 0 && mw.config.get("wgNamespaceNumber") != 4) {
// not a talk page and not project namespace
return;
}
if (mw.config.get("wgNamespaceNumber") == -1) {
// is a special page
return;
}
mw.util.addCSS(".arky-selected-section { background-color:#D9E9FF } .arky-selected-section .arky-span a { font-weight:bold }");
var sectionCodepointOffsets = new Object();
var wikiText = "";
var revStamp; // The timestamp when we originally got the page contents - we pass it to the "edit" API call for edit conflict detection
var portletLink = mw.util.addPortletLink("p-cactions", "#", "ⵙCA", "ca-oecaAndrybak", "Enter/exit the archival process", null, null);
var archiveButton = $(document.createElement("button"));
let highestArchiveSubpagePromise = null;
$(portletLink).click(function(e) {
$(".arky-selected-section").removeClass('.arky-selected-section');
$(".arky-span").toggle();
archiveButton.toggle();
if (highestArchiveSubpagePromise == null) {
/*
* Start searching for the archive subpage with highest number immediately.
* Then the click listener on `archiveButton` will wait for this `Promise`.
*/
highestArchiveSubpagePromise = findHighestArchiveSubpage();
} else {
// TODO: if "Loading..." was already shown to the user via the button, we need to reset the text here.
}
});
archiveButton.html("archive all the selected threads")
.attr("id", 'arky-archive-button')
.css("position", 'sticky')
.css("bottom", 0)
.css("width", '100%')
.css("font-size", '200%');
$(document.body).append(archiveButton);
archiveButton.toggle();
archiveButton.click(function(e) {
var selectedSections = $(".arky-selected-section .arky-span").map(function() {
return $(this).data("section");
}).toArray();
if (selectedSections.length === 0) {
return alert("No threads selected, aborting");
}
const timeoutId = setTimeout(() => {
/*
* In case highestArchiveSubpagePromise is taking a long time,
* show to the user that stuff is happening.
*/
archiveButton.text("Loading...");
}, 1000);
highestArchiveSubpagePromise.then(result => {
clearTimeout(timeoutId);
info("Successful highestArchiveSubpagePromise:", result);
doArchive(selectedSections, result);
}, rejection => {
info("Failed highestArchiveSubpagePromise:", rejection);
const currentPageName = mw.config.get("wgPageName");
doArchive(selectedSections, archiveSpacedSubpageName(currentPageName, "???"));
});
}); // end of archiveButton click handler
function midPoint(lower, upper) {
return Math.floor(lower + (upper - lower) / 2);
}
/*
* Based on https://en.wikipedia.org/wiki/Module:Exponential_search
*/
async function exponentialSearch(testFunc, i, lower, upper) {
if (await testFunc(i)) {
if (i + 1 == upper) {
return i;
}
lower = i;
if (upper) {
i = midPoint(lower, upper);
} else {
i = i * 2;
}
return exponentialSearch(testFunc, i, lower, upper);
} else {
upper = i;
i = midPoint(lower, upper);
return exponentialSearch(testFunc, i, lower, upper);
}
}
function archiveSpacedSubpageName(pageName, archiveNumber) {
return pageName + "/Archive " + archiveNumber;
}
function archiveSlashedSubpageName(pageName, archiveNumber) {
return pageName + "/Archive/" + archiveNumber;
}
/*
* Based on https://en.wikipedia.org/wiki/Wikipedia_talk:User_scripts/Archive_7#nocreate-missing
*/
async function pageExists(title) {
const api = new mw.Api();
const response = await api.get({
"action": "query",
"format": "json",
"titles": title
});
const missing = "missing" in Object.values(response.query.pages)[0];
return !missing;
}
/*
* Find the subpage of this page, which will be used as destination/target of archiving.
* It is just "Archive 1" by default, but can be increased by exponentialSearch.
*/
function findHighestArchiveSubpage() {
info("findHighestArchiveSubpage: start");
// mw.config.get("wgPageName")
return new Promise(async (resolve, reject) => {
try {
const currentPageName = mw.config.get("wgPageName");
const currentYear = new Date().getUTCFullYear();
let subpageFunc;
/*
* Check if "current year" subpage is a good candidate for
* pages with https://en.wikipedia.org/wiki/Template:Archived_annually
* TODO: maybe implement checking if {{Archived annually}} is transcluded.
*/
if (await pageExists(archiveSpacedSubpageName(currentPageName, currentYear - 1)) && !await pageExists(archiveSpacedSubpageName(currentPageName, currentYear + 1))) {
resolve(archiveSpacedSubpageName(currentPageName, currentYear));
return;
} else if (await pageExists(archiveSpacedSubpageName(currentPageName, 1))) {
subpageFunc = archiveSpacedSubpageName;
} else if (await pageExists(archiveSlashedSubpageName(currentPageName, 1))) {
subpageFunc = archiveSlashedSubpageName;
} else {
notifyUser("Cannot find the first archive subpage");
info('Assuming zero archive subpages.');
resolve(archiveSpacedSubpageName(currentPageName, 1));
return;
}
async function checkArchiveSubpageExists(archiveNumber) {
const archiveSubpageTitle = subpageFunc(currentPageName, archiveNumber);
debug("Checking existence", archiveSubpageTitle);
return pageExists(archiveSubpageTitle);
}
// see also https://en.wikipedia.org/wiki/Module:Highest_archive_number
const highestNumber = await exponentialSearch(checkArchiveSubpageExists, 10, 1, null);
const highestArchiveSubpage = subpageFunc(currentPageName, highestNumber);
resolve(highestArchiveSubpage);
} catch (e) {
const msg = "Cannot find archive subpage with the highest number";
error(msg, e);
notifyUser(msg);
reject(e);
}
});
}
function doArchive(selectedSections, highestArchiveSubpage) {
// returns `s` without the substring starting at `start` and ending at `end`
function cut(s, start, end) {
return s.substr(0, start) + s.substring(end);
}
var archivePageName = prompt("Archiving " + selectedSections.length + " threads: where should we move them to? The latest archive number seems to be:", highestArchiveSubpage);
if (!archivePageName || archivePageName == mw.config.get("wgPageName")) {
return alert("No archive target selected, aborting");
}
// codepointToUtf16Idx maps codepoint idx (i.e. MediaWiki index into page text) to utf-16 idx (i.e. JavaScript index into wikiText)
var codepointToUtf16Idx = {};
// Initialize "important" (= either a section start or end) values to 0
selectedSections.forEach(function(n) {
codepointToUtf16Idx[sectionCodepointOffsets[n].start] = 0;
codepointToUtf16Idx[sectionCodepointOffsets[n].end] = 0;
});
codepointToUtf16Idx[Infinity] = Infinity; // Because sometimes we'll have Infinity as an "end" value
// fill in our mapping from codepoints (MediaWiki indices) to utf-16 (i.e. JavaScript).
// yes, this loops through every character in the wikitext. very unfortunate.
var codepointPos = 0;
for (var utf16Pos = 0; utf16Pos < wikiText.length; utf16Pos++, codepointPos++) {
if (codepointToUtf16Idx.hasOwnProperty(codepointPos)) {
codepointToUtf16Idx[codepointPos] = utf16Pos;
}
if ((0xD800 <= wikiText.charCodeAt(utf16Pos)) && (wikiText.charCodeAt(utf16Pos) <= 0xDBFF)) {
// high surrogate! utf16Pos goes up by 2, but codepointPos goes up by only 1.
utf16Pos++; // skip the low surrogate
}
}
var newTextForArchivePage = selectedSections.map(function(n) {
return wikiText.substring(
codepointToUtf16Idx[sectionCodepointOffsets[n].start],
codepointToUtf16Idx[sectionCodepointOffsets[n].end]
);
}).join("");
selectedSections.reverse(); // go in reverse order so that we don't invalidate the offsets of earlier sections
var newWikiText = wikiText;
selectedSections.forEach(function(n) {
newWikiText = cut(
newWikiText,
codepointToUtf16Idx[sectionCodepointOffsets[n].start],
codepointToUtf16Idx[sectionCodepointOffsets[n].end]
);
});
info("archive this:" + newTextForArchivePage);
info("revised page:" + newWikiText);
var pluralizedThreads = selectedSections.length + ' thread' + ((selectedSections.length === 1) ? '' : 's');
new mw.Api().postWithToken("csrf", {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikiText,
summary: "Removing " + pluralizedThreads + ", will be on [[" + archivePageName + "]]",
basetimestamp: revStamp,
starttimestamp: revStamp
}).done(function(res1) {
alert("Successfully removed threads from talk page");
info(res1);
new mw.Api().postWithToken("csrf", {action: 'edit', title: archivePageName, appendtext: "\n" + newTextForArchivePage, summary: "Adding " + pluralizedThreads + " from [[" + mw.config.get("wgPageName") + "]]"})
.done(function(res2) {
alert("Successfully added threads to archive page");
})
.fail(function(res2) {
alert("failed to add threads to archive page. manual inspection needed.");
})
.always(function(res2) {
console.log(res2);
window.location.reload();
});
})
.fail(function(res1) {
alert("failed to remove threads from talk page. aborting archive process.");
console.log(res1);
window.location.reload();
});
} // end of doArchive()
// grab page sections and wikitext so we can add the "archive" links to appropriate sections
new mw.Api().get({action: 'parse', page: mw.config.get("wgPageName")}).done(function(parseApiResult) {
new mw.Api().get({action: 'query', pageids: mw.config.get("wgArticleId"), prop: ['revisions'], rvprop: ['content', 'timestamp']}).done(function(revisionsApiResult) {
var rv;
rv = revisionsApiResult.query.pages[mw.config.get("wgArticleId")].revisions[0];
wikiText = rv["*"];
revStamp = rv['timestamp'];
});
var validSections = {};
$(parseApiResult.parse.sections)
// For sections transcluded from other pages, s.index will look
// like T-1 instead of just 1. Remove those.
.filter(function(i, s) { return s.index == parseInt(s.index) })
.each(function(i, s) { validSections[s.index] = s });
for (var i in validSections) {
i = parseInt(i);
// What MediaWiki calls "byteoffset" is actually a codepoint offset!! Drat!!
sectionCodepointOffsets[i] = {
start: validSections[i].byteoffset,
end: validSections.hasOwnProperty(i+1)?validSections[i+1].byteoffset:Infinity
};
}
$("#mw-content-text").find(":header").find("span.mw-headline").each(function(i, title) {
var header, headerLevel, editSection, sectionNumber;
header = $(this).parent();
headerLevel = header.prop("tagName").substr(1, 1) * 1; // wtf javascript
editSection = header.find(".mw-editsection"); // 1st child
var editSectionLink = header.find(".mw-editsection a:last");
var sectionNumber = undefined;
if (editSectionLink[0]) {
// Note: href may not be set.
var sectionNumberMatch = editSectionLink.attr("href") && editSectionLink.attr("href").match(/§ion=(\d+)/);
if (sectionNumberMatch) {
sectionNumber = sectionNumberMatch[1];
}
}
// if the if statement fails, it might be something like <h2>not a real section</h2>
if (validSections.hasOwnProperty(sectionNumber)){
$(editSection[0]).append(
" ",
$("<span>", { "class": "arky-span" })
.css({'display':'none'})
.data({'header-level': headerLevel, 'section': sectionNumber})
.append(
$('<span>', { 'class': 'mw-editsection-bracket' }).text('['),
$('<a>')
.text('archive')
.click(function(){
var parentHeader = $(this).parents(':header');
parentHeader.toggleClass('arky-selected-section');
// now, click all sub-sections of this section
var isThisSectionSelected = parentHeader.hasClass('arky-selected-section');
var thisHeaderLevel = $(this).parents('.arky-span').data('header-level');
// starting from the current section, loop through each section
var allArchiveSpans = $('.arky-span');
var currSectionIdx = allArchiveSpans.index($(this).parents('.arky-span'));
for(var i = currSectionIdx + 1; i < allArchiveSpans.length; i++) {
if($(allArchiveSpans[i]).data('header-level') <= thisHeaderLevel) {
// if this isn't a subsection, quit
break;
}
var closestHeader = $(allArchiveSpans[i]).parents(':header');
if(closestHeader.hasClass('arky-selected-section') != isThisSectionSelected) {
// if this section needs toggling, toggle it
closestHeader.toggleClass('arky-selected-section');
}
}
// finally, update button
archiveButton
.prop('disabled', !$('.arky-selected-section').length)
.text('archive ' + $('.arky-selected-section').length + ' selected thread' +
(($('.arky-selected-section').length === 1) ? '' : 's'));
}),
$('<span>', { 'class': 'mw-editsection-bracket' }).text(']')
));
}
});
});
});