MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
Aurelianus (talk | contribs) Target native add-entry link markup |
Aurelianus (talk | contribs) Add form-aware automatic version snapshots and admin version controls |
||
| Line 630: | Line 630: | ||
} else { | } else { | ||
initVersionHistory(); | initVersionHistory(); | ||
} | |||
}()); | |||
/* HarvestWiki version snapshots for source edits, Page Forms, and administrators */ | |||
(function () { | |||
'use strict'; | |||
var MARKER_KEY = 'hwVersionHistoryPending'; | |||
var ADMIN_PAGE = 'HarvestWiki:Version control'; | |||
var AUTO_START = '<!-- HW-AUTO-START:'; | |||
function api() { | |||
return new mw.Api(); | |||
} | |||
function isSignedInEditor() { | |||
return Boolean(mw.config.get('wgUserName')) && mw.config.get('wgIsProbablyEditable') !== false; | |||
} | |||
function isVersionAdmin() { | |||
var groups = mw.config.get('wgUserGroups') || []; | |||
return groups.indexOf('sysop') !== -1 || groups.indexOf('interface-admin') !== -1 || groups.indexOf('bureaucrat') !== -1; | |||
} | |||
function normalTitle(title) { | |||
return String(title || '').replace(/_/g, ' ').trim(); | |||
} | |||
function seriesFor(version) { | |||
var parts = String(version || '').split('.'); | |||
return parts.length === 3 ? parts[0] + '.' + parts[1] : ''; | |||
} | |||
function validVersion(version) { | |||
return /^\d+\.\d{2}\.\d+$/.test(String(version || '').trim()); | |||
} | |||
function getPageText(title) { | |||
return api().get({ | |||
action: 'query', | |||
prop: 'revisions', | |||
titles: title, | |||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var page = data && data.query && data.query.pages && data.query.pages[0]; | |||
var revision = page && page.revisions && page.revisions[0]; | |||
var slots = revision && revision.slots; | |||
return { | |||
exists: Boolean(page && !page.missing), | |||
text: slots && slots.main && typeof slots.main.content === 'string' ? slots.main.content : '' | |||
}; | |||
}); | |||
} | |||
function savePage(title, text, summary, createOnly) { | |||
var params = { | |||
action: 'edit', | |||
title: title, | |||
text: text, | |||
summary: summary, | |||
watchlist: 'nochange', | |||
formatversion: 2 | |||
}; | |||
if (createOnly) { | |||
params.createonly = 1; | |||
} | |||
return api().postWithToken('csrf', params); | |||
} | |||
function readCurrentVersion() { | |||
return Promise.all([ | |||
getPageText('Template:Current version'), | |||
getPageText('Template:Current version page') | |||
]).then(function (pages) { | |||
var versionMatch = pages[0].text.match(/\d+\.\d{2}\.\d+/); | |||
var seriesMatch = pages[1].text.match(/\d+\.\d{2}/); | |||
var version = versionMatch ? versionMatch[0] : ''; | |||
var series = seriesMatch ? seriesMatch[0] : seriesFor(version); | |||
if (!validVersion(version) || !series) { | |||
throw new Error('The current version templates do not contain a valid x.xx.x version.'); | |||
} | |||
return { version: version, series: series }; | |||
}); | |||
} | |||
function cleanFieldName(name) { | |||
var raw = String(name || ''); | |||
var matches = raw.match(/\[([^\]]+)\]/g); | |||
var last; | |||
if (matches && matches.length) { | |||
last = matches[matches.length - 1].replace(/^\[|\]$/g, ''); | |||
} else { | |||
last = raw; | |||
} | |||
last = last.replace(/^pf_free_text$/i, 'Article content') | |||
.replace(/^free_text$/i, 'Article content') | |||
.replace(/_/g, ' ') | |||
.replace(/\s+/g, ' ') | |||
.trim(); | |||
return last; | |||
} | |||
function labelFor(key) { | |||
return String(key || '') | |||
.replace(/[_-]+/g, ' ') | |||
.replace(/\b\w/g, function (letter) { return letter.toUpperCase(); }); | |||
} | |||
function normalizeValue(value) { | |||
return String(value == null ? '' : value).replace(/\r\n?/g, '\n').replace(/[ \t]+/g, ' ').trim(); | |||
} | |||
function collectFormSnapshot(form) { | |||
var values = {}; | |||
var ignored = /^(wp(EditToken|Starttime|Edittime|AutoSummary|Summary|Section|UltimateParam)|title|action|format|oldid)$/i; | |||
Array.prototype.forEach.call(form.querySelectorAll('input[name], select[name], textarea[name]'), function (field) { | |||
var type = String(field.type || '').toLowerCase(); | |||
var name = String(field.name || ''); | |||
var key; | |||
var value; | |||
if (!name || field.disabled || ignored.test(name) || /submit|button|reset|image|file/.test(type)) { | |||
return; | |||
} | |||
if ((type === 'checkbox' || type === 'radio') && !field.checked) { | |||
return; | |||
} | |||
if (type === 'hidden' && name.indexOf('[') === -1 && name !== 'pf_free_text') { | |||
return; | |||
} | |||
key = cleanFieldName(name); | |||
if (!key || /token|timestamp|summary/i.test(key)) { | |||
return; | |||
} | |||
if (field.tagName === 'SELECT' && field.multiple) { | |||
value = Array.prototype.filter.call(field.options, function (option) { return option.selected; }) | |||
.map(function (option) { return option.value; }).join('; '); | |||
} else { | |||
value = field.value; | |||
} | |||
value = normalizeValue(value); | |||
if (Object.prototype.hasOwnProperty.call(values, key) && value && values[key] !== value) { | |||
values[key] = normalizeValue(values[key] + '; ' + value); | |||
} else if (!Object.prototype.hasOwnProperty.call(values, key) || value) { | |||
values[key] = value; | |||
} | |||
}); | |||
return values; | |||
} | |||
function snapshotFromWikitext(text) { | |||
var source = String(text || ''); | |||
var snapshot = {}; | |||
var match; | |||
var paramPattern = /^\s*\|\s*([^=\n]+?)\s*=\s*([\s\S]*?)(?=^\s*\|\s*[^=\n]+?\s*=|^\s*}}|\z)/gm; | |||
var cleaned = source.replace(/==\s*Change History\s*==[\s\S]*$/i, '').trim(); | |||
while ((match = paramPattern.exec(cleaned))) { | |||
snapshot[cleanFieldName(match[1])] = normalizeValue(match[2]); | |||
} | |||
if (!Object.keys(snapshot).length) { | |||
snapshot['Article content'] = normalizeValue(cleaned); | |||
} | |||
return snapshot; | |||
} | |||
function snapshotForForm(form) { | |||
var textbox = form.querySelector('#wpTextbox1, textarea[name="wpTextbox1"]'); | |||
if (textbox && String(mw.config.get('wgAction')) === 'edit') { | |||
return snapshotFromWikitext(textbox.value); | |||
} | |||
return collectFormSnapshot(form); | |||
} | |||
function sameSnapshot(a, b) { | |||
var aKeys = Object.keys(a || {}).sort(); | |||
var bKeys = Object.keys(b || {}).sort(); | |||
if (aKeys.length !== bKeys.length) { | |||
return false; | |||
} | |||
return aKeys.every(function (key, index) { | |||
return key === bKeys[index] && normalizeValue(a[key]) === normalizeValue(b[key]); | |||
}); | |||
} | |||
function encodeSnapshot(snapshot) { | |||
return btoa(unescape(encodeURIComponent(JSON.stringify(snapshot || {})))); | |||
} | |||
function decodeSnapshot(encoded) { | |||
try { | |||
return JSON.parse(decodeURIComponent(escape(atob(encoded)))); | |||
} catch (error) { | |||
return {}; | |||
} | |||
} | |||
function historyBlocks(text) { | |||
var blocks = []; | |||
var pattern = /<!-- HW-AUTO-START:([0-9]+\.[0-9]{2}\.[0-9]+) -->([\s\S]*?)<!-- HW-AUTO-END:\1 -->/g; | |||
var match; | |||
var snapshotMatch; | |||
while ((match = pattern.exec(String(text || '')))) { | |||
snapshotMatch = match[2].match(/<!-- HW-SNAPSHOT:([A-Za-z0-9+/=]+) -->/); | |||
blocks.push({ | |||
version: match[1], | |||
full: match[0], | |||
index: match.index, | |||
created: /\*\s*Page created\./i.test(match[2]), | |||
snapshot: snapshotMatch ? decodeSnapshot(snapshotMatch[1]) : {} | |||
}); | |||
} | |||
return blocks; | |||
} | |||
function shortValue(value) { | |||
var clean = normalizeValue(value); | |||
if (clean.length > 120) { | |||
clean = clean.slice(0, 117) + '...'; | |||
} | |||
return clean; | |||
} | |||
function changeMessages(before, after) { | |||
var keys = {}; | |||
var messages = []; | |||
Object.keys(before || {}).forEach(function (key) { keys[key] = true; }); | |||
Object.keys(after || {}).forEach(function (key) { keys[key] = true; }); | |||
Object.keys(keys).sort().forEach(function (key) { | |||
var oldValue = normalizeValue((before || {})[key]); | |||
var newValue = normalizeValue((after || {})[key]); | |||
var label = labelFor(key); | |||
if (oldValue === newValue) { | |||
return; | |||
} | |||
if (!oldValue && newValue) { | |||
messages.push('Added ' + label + ': “' + shortValue(newValue) + '”.'); | |||
} else if (oldValue && !newValue) { | |||
messages.push('Removed ' + label + ' (previously “' + shortValue(oldValue) + '”).'); | |||
} else if (key === 'Article content') { | |||
messages.push('Updated the article content.'); | |||
} else { | |||
messages.push('Changed ' + label + ' from “' + shortValue(oldValue) + '” to “' + shortValue(newValue) + '”.'); | |||
} | |||
}); | |||
if (messages.length > 12) { | |||
messages = messages.slice(0, 12).concat(['Updated ' + (messages.length - 12) + ' additional fields.']); | |||
} | |||
return messages; | |||
} | |||
function buildBlock(version, series, messages, snapshot) { | |||
return AUTO_START + version + ' -->\n' + | |||
'== Version ' + version + ' ==\n' + | |||
"'''{{Version link|" + version + '|' + series + "}}'''\n" + | |||
messages.map(function (message) { return '* ' + message; }).join('\n') + '\n' + | |||
'<!-- HW-SNAPSHOT:' + encodeSnapshot(snapshot) + ' -->\n' + | |||
'<!-- HW-AUTO-END:' + version + ' -->'; | |||
} | |||
function upsertHistory(existingText, versionInfo, afterSnapshot, isNewPage, forceCreated) { | |||
var text = String(existingText || '').trim(); | |||
var blocks = historyBlocks(text); | |||
var current = blocks.filter(function (block) { return block.version === versionInfo.version; })[0]; | |||
var previous = blocks.filter(function (block) { return block.version !== versionInfo.version && Object.keys(block.snapshot).length; })[0]; | |||
var beforeSnapshot = previous ? previous.snapshot : {}; | |||
var messages; | |||
var replacement; | |||
if (current && current.created) { | |||
messages = ['Page created.']; | |||
} else if ((isNewPage || forceCreated) && !blocks.length) { | |||
messages = ['Page created.']; | |||
} else { | |||
messages = changeMessages(beforeSnapshot, afterSnapshot); | |||
if (!messages.length) { | |||
return { changed: false, text: text }; | |||
} | |||
} | |||
replacement = buildBlock(versionInfo.version, versionInfo.series, messages, afterSnapshot); | |||
if (current) { | |||
return { changed: replacement !== current.full, text: text.replace(current.full, replacement) }; | |||
} | |||
return { changed: true, text: replacement + (text ? '\n\n' + text : '') }; | |||
} | |||
function historyMarkup(versionInfo) { | |||
return '\n\n== Change History ==\n{{Change History|' + versionInfo.version + '|' + versionInfo.series + '}}\n'; | |||
} | |||
function ensureHistorySection(title, versionInfo) { | |||
return getPageText(title).then(function (page) { | |||
if (!page.exists || /{{\s*Change History\b/i.test(page.text)) { | |||
return false; | |||
} | |||
return savePage(title, page.text.replace(/\s+$/, '') + historyMarkup(versionInfo), 'Add version change history section').then(function () { | |||
return true; | |||
}); | |||
}); | |||
} | |||
function recordAutomaticHistory(title, versionInfo, afterSnapshot, isNewPage, forceCreated) { | |||
var historyTitle = title + '/Change History'; | |||
return getPageText(historyTitle).then(function (historyPage) { | |||
var updated = upsertHistory(historyPage.text, versionInfo, afterSnapshot, isNewPage, forceCreated); | |||
if (!updated.changed) { | |||
return false; | |||
} | |||
return savePage(historyTitle, updated.text + '\n', 'Record automatic change history for ' + versionInfo.version).then(function () { | |||
return true; | |||
}); | |||
}); | |||
} | |||
function setPendingEnsure(title, versionInfo) { | |||
try { | |||
sessionStorage.setItem(MARKER_KEY, JSON.stringify({ | |||
title: title, | |||
version: versionInfo.version, | |||
series: versionInfo.series, | |||
time: Date.now() | |||
})); | |||
} catch (error) {} | |||
} | |||
function completePendingEnsure() { | |||
var raw; | |||
var pending; | |||
try { | |||
raw = sessionStorage.getItem(MARKER_KEY); | |||
pending = raw ? JSON.parse(raw) : null; | |||
} catch (error) { | |||
pending = null; | |||
} | |||
if (!pending || !pending.title || Date.now() - Number(pending.time || 0) > 300000) { | |||
return; | |||
} | |||
if (normalTitle(mw.config.get('wgPageName')) !== normalTitle(pending.title) || Number(mw.config.get('wgArticleId') || 0) === 0) { | |||
return; | |||
} | |||
sessionStorage.removeItem(MARKER_KEY); | |||
ensureHistorySection(pending.title, { version: pending.version, series: pending.series }).then(function (changed) { | |||
if (changed) { | |||
mw.notify('The page was saved and its change-history section was added.', { type: 'success' }); | |||
window.setTimeout(function () { window.location.reload(); }, 700); | |||
} | |||
}).catch(function (error) { | |||
mw.notify('The page saved, but its change-history section could not be added: ' + error.message, { type: 'error' }); | |||
}); | |||
} | |||
function initTrackedForm() { | |||
var namespace = Number(mw.config.get('wgNamespaceNumber')); | |||
var form; | |||
var initialSnapshot; | |||
var isNewPage; | |||
var pageTitle; | |||
if (!isSignedInEditor() || namespace !== 0 || /\/Change_History$/.test(String(mw.config.get('wgPageName') || ''))) { | |||
return; | |||
} | |||
form = document.querySelector('form#pfForm, form#sfForm, form#editform'); | |||
if (!form || form.dataset.hwVersionTrackingReady === '1') { | |||
return; | |||
} | |||
form.dataset.hwVersionTrackingReady = '1'; | |||
initialSnapshot = snapshotForForm(form); | |||
isNewPage = Number(mw.config.get('wgArticleId') || 0) === 0; | |||
pageTitle = normalTitle(mw.config.get('wgPageName')); | |||
form.addEventListener('submit', function (event) { | |||
var submitter = event.submitter; | |||
var outgoing; | |||
var submitName = submitter ? String(submitter.name || submitter.value || '') : ''; | |||
if (form.dataset.hwVersionSubmitting === '1' || /preview|diff|changes|cancel/i.test(submitName)) { | |||
return; | |||
} | |||
outgoing = snapshotForForm(form); | |||
if (!isNewPage && sameSnapshot(initialSnapshot, outgoing)) { | |||
return; | |||
} | |||
event.preventDefault(); | |||
form.dataset.hwVersionSubmitting = '1'; | |||
mw.notify('Saving the page and recording its version history…'); | |||
readCurrentVersion().then(function (versionInfo) { | |||
return recordAutomaticHistory(pageTitle, versionInfo, outgoing, isNewPage, false).then(function () { | |||
setPendingEnsure(pageTitle, versionInfo); | |||
}); | |||
}).catch(function (error) { | |||
mw.notify('The page will still be saved, but automatic history failed: ' + error.message, { type: 'warn' }); | |||
}).then(function () { | |||
if (form.requestSubmit) { | |||
form.requestSubmit(submitter || undefined); | |||
} else { | |||
form.submit(); | |||
} | |||
}); | |||
}, true); | |||
} | |||
function allMainPages() { | |||
var results = []; | |||
function next(apcontinue) { | |||
var params = { action: 'query', list: 'allpages', apnamespace: 0, aplimit: 'max', formatversion: 2 }; | |||
if (apcontinue) { | |||
params.apcontinue = apcontinue; | |||
} | |||
return api().get(params).then(function (data) { | |||
results = results.concat((data.query && data.query.allpages) || []); | |||
return data.continue && data.continue.apcontinue ? next(data.continue.apcontinue) : results; | |||
}); | |||
} | |||
return next(); | |||
} | |||
function backfillOne(title, versionInfo, forceCreated) { | |||
if (title === 'Change History' || /\/Change History$/.test(title)) { | |||
return Promise.resolve({ title: title, skipped: true }); | |||
} | |||
return getPageText(title).then(function (page) { | |||
if (!page.exists) { | |||
return { title: title, skipped: true }; | |||
} | |||
var snapshot = snapshotFromWikitext(page.text); | |||
return Promise.all([ | |||
ensureHistorySection(title, versionInfo), | |||
recordAutomaticHistory(title, versionInfo, snapshot, false, forceCreated) | |||
]).then(function () { | |||
return { title: title, skipped: false }; | |||
}); | |||
}); | |||
} | |||
function runBackfill(versionInfo, forceCreated, progress) { | |||
return allMainPages().then(function (pages) { | |||
var queue = pages.map(function (page) { return normalTitle(page.title); }); | |||
var done = 0; | |||
var failures = []; | |||
var workers = []; | |||
function worker() { | |||
var title = queue.shift(); | |||
if (!title) { | |||
return Promise.resolve(); | |||
} | |||
return backfillOne(title, versionInfo, forceCreated).catch(function (error) { | |||
failures.push(title + ': ' + error.message); | |||
}).then(function () { | |||
done += 1; | |||
progress(done, pages.length, title, failures.length); | |||
return worker(); | |||
}); | |||
} | |||
for (var i = 0; i < 3; i += 1) { | |||
workers.push(worker()); | |||
} | |||
return Promise.all(workers).then(function () { | |||
return { total: pages.length, failures: failures }; | |||
}); | |||
}); | |||
} | |||
function currentTemplateText(version) { | |||
return '<includeonly>' + version + '</includeonly><noinclude>Sitewide current patch version. Change this through [[HarvestWiki:Version control]]. Version numbers use x.xx.x. [[Category:Version templates]]</noinclude>'; | |||
} | |||
function currentSeriesText(series) { | |||
return '<includeonly>' + series + '</includeonly><noinclude>Parent release-series number for the current patch. Change this through [[HarvestWiki:Version control]]. [[Category:Version templates]]</noinclude>'; | |||
} | |||
function releasePageText(versionInfo, title, date) { | |||
return '{{Version\n|version=' + versionInfo.series + '\n|title=' + title + '\n|release date=' + date + '\n|current patch=' + versionInfo.version + '\n}}\n\n== Overview ==\nDescribe the goals and scope of this release series.\n\n== Changelogs ==\n=== ' + versionInfo.version + ' ===\n' + title + ' — ' + date + '\n* Add release changes here.\n\n[[Category:Versions]]\n'; | |||
} | |||
function updateReleasePage(versionInfo, title, date) { | |||
var pageTitle = 'Version ' + versionInfo.series; | |||
return getPageText(pageTitle).then(function (page) { | |||
var text; | |||
if (!page.exists) { | |||
return savePage(pageTitle, releasePageText(versionInfo, title, date), 'Create release page for ' + versionInfo.version); | |||
} | |||
text = page.text; | |||
if (/\|\s*current patch\s*=/.test(text)) { | |||
text = text.replace(/(\|\s*current patch\s*=\s*)[^\n|}]*/i, '$1' + versionInfo.version); | |||
} | |||
if (text.indexOf('=== ' + versionInfo.version + ' ===') === -1) { | |||
text = text.replace(/\s*\[\[Category:Versions\]\]\s*$/i, '') + | |||
'\n\n=== ' + versionInfo.version + ' ===\n' + title + ' — ' + date + '\n* Add release changes here.\n\n[[Category:Versions]]\n'; | |||
} | |||
return savePage(pageTitle, text, 'Set current patch to ' + versionInfo.version); | |||
}); | |||
} | |||
function applyVersion(version, title, date) { | |||
var versionInfo = { version: version, series: seriesFor(version) }; | |||
return Promise.all([ | |||
savePage('Template:Current version', currentTemplateText(version), 'Set current version to ' + version), | |||
savePage('Template:Current version page', currentSeriesText(versionInfo.series), 'Set current version series to ' + versionInfo.series), | |||
updateReleasePage(versionInfo, title, date) | |||
]).then(function () { | |||
return versionInfo; | |||
}); | |||
} | |||
function initVersionAdmin() { | |||
var root; | |||
var status; | |||
var versionInput; | |||
var titleInput; | |||
var dateInput; | |||
var applyButton; | |||
var backfillButton; | |||
var snapshotButton; | |||
if (normalTitle(mw.config.get('wgPageName')) !== ADMIN_PAGE) { | |||
return; | |||
} | |||
root = document.getElementById('hw-version-control-root'); | |||
if (!root) { | |||
return; | |||
} | |||
if (!isVersionAdmin()) { | |||
root.innerHTML = '<div class="hw-note">Administrator access is required.</div>'; | |||
return; | |||
} | |||
document.body.classList.add('hw-version-admin-authorized'); | |||
root.innerHTML = '<div class="hw-version-admin-card">' + | |||
'<h2>Current release</h2>' + | |||
'<div class="hw-version-admin-grid">' + | |||
'<label>Version number <input id="hw-version-number" type="text" placeholder="0.01.0" pattern="\d+\.\d{2}\.\d+"></label>' + | |||
'<label>Release title <input id="hw-version-title" type="text" placeholder="Early Development"></label>' + | |||
'<label>Release date <input id="hw-version-date" type="text" placeholder="29 July 2026"></label>' + | |||
'</div>' + | |||
'<div class="hw-version-admin-actions"><button id="hw-version-apply" type="button">Apply version and create release page</button></div>' + | |||
'</div>' + | |||
'<div class="hw-version-admin-card"><h2>Page history tools</h2>' + | |||
'<p><button id="hw-version-backfill" type="button">Backfill untracked pages as created now</button></p>' + | |||
'<p><button id="hw-version-snapshot" type="button">Snapshot every page in the current version</button></p>' + | |||
'<p class="hw-version-admin-help">Backfill is for legacy pages. Snapshot creates a current-version baseline only where the page differs from its last stored snapshot.</p>' + | |||
'</div>' + | |||
'<div id="hw-version-status" class="hw-version-status" role="status" aria-live="polite"></div>'; | |||
status = document.getElementById('hw-version-status'); | |||
versionInput = document.getElementById('hw-version-number'); | |||
titleInput = document.getElementById('hw-version-title'); | |||
dateInput = document.getElementById('hw-version-date'); | |||
applyButton = document.getElementById('hw-version-apply'); | |||
backfillButton = document.getElementById('hw-version-backfill'); | |||
snapshotButton = document.getElementById('hw-version-snapshot'); | |||
readCurrentVersion().then(function (info) { | |||
versionInput.value = info.version; | |||
}); | |||
function setBusy(busy) { | |||
applyButton.disabled = busy; | |||
backfillButton.disabled = busy; | |||
snapshotButton.disabled = busy; | |||
} | |||
function progress(done, total, title, failures) { | |||
status.textContent = 'Processed ' + done + ' of ' + total + ': ' + title + (failures ? ' (' + failures + ' failed)' : ''); | |||
} | |||
applyButton.addEventListener('click', function () { | |||
var version = versionInput.value.trim(); | |||
var title = titleInput.value.trim(); | |||
var date = dateInput.value.trim(); | |||
if (!validVersion(version) || !title || !date) { | |||
status.textContent = 'Enter a version in x.xx.x format, a title, and a release date.'; | |||
return; | |||
} | |||
setBusy(true); | |||
status.textContent = 'Applying ' + version + '…'; | |||
applyVersion(version, title, date).then(function () { | |||
status.textContent = 'Current version is now ' + version + '. The release page is ready.'; | |||
}).catch(function (error) { | |||
status.textContent = 'Version update failed: ' + error.message; | |||
}).then(function () { setBusy(false); }); | |||
}); | |||
backfillButton.addEventListener('click', function () { | |||
setBusy(true); | |||
status.textContent = 'Reading current version…'; | |||
readCurrentVersion().then(function (info) { | |||
return runBackfill(info, true, progress); | |||
}).then(function (result) { | |||
status.textContent = 'Backfill complete: ' + result.total + ' pages checked, ' + result.failures.length + ' failed.'; | |||
}).catch(function (error) { | |||
status.textContent = 'Backfill failed: ' + error.message; | |||
}).then(function () { setBusy(false); }); | |||
}); | |||
snapshotButton.addEventListener('click', function () { | |||
setBusy(true); | |||
status.textContent = 'Reading current version…'; | |||
readCurrentVersion().then(function (info) { | |||
return runBackfill(info, false, progress); | |||
}).then(function (result) { | |||
status.textContent = 'Snapshot complete: ' + result.total + ' pages checked, ' + result.failures.length + ' failed.'; | |||
}).catch(function (error) { | |||
status.textContent = 'Snapshot failed: ' + error.message; | |||
}).then(function () { setBusy(false); }); | |||
}); | |||
} | |||
function initAutomaticVersioning() { | |||
completePendingEnsure(); | |||
initTrackedForm(); | |||
initVersionAdmin(); | |||
window.setTimeout(initTrackedForm, 500); | |||
} | |||
if (mw.hook) { | |||
mw.hook('wikipage.editform').add(function () { window.setTimeout(initTrackedForm, 100); }); | |||
} | |||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', initAutomaticVersioning, { once: true }); | |||
} else { | |||
initAutomaticVersioning(); | |||
} | } | ||
}()); | }()); | ||
Revision as of 14:27, 29 July 2026
/* Any JavaScript here will be loaded for all users on every page load. */ /* HarvestWiki dynamic Cargo archive filters */ (function () { 'use strict'; function initHerbLocationFilters(root) { var catalogs = []; if (root && root.matches && root.matches('.hw-herb-catalog')) { catalogs.push(root); } if (root && root.querySelectorAll) { catalogs = catalogs.concat(Array.prototype.slice.call(root.querySelectorAll('.hw-herb-catalog'))); } catalogs.forEach(function (catalog) { if (catalog.dataset.hwLocationFiltersReady === '1') { return; } var table = catalog.querySelector('table'); var filterBar = document.querySelector('.hw-herb-filter'); if (!table || !filterBar) { return; } var headers = Array.prototype.slice.call(table.querySelectorAll('thead th')); var locationIndex = headers.findIndex(function (header) { return header.textContent.trim().toLowerCase() === 'location'; }); if (locationIndex < 0) { return; } var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr')); var counts = {}; rows.forEach(function (row) { var cell = row.querySelectorAll('td')[locationIndex]; var location = cell ? cell.textContent.trim() : ''; row.dataset.hwLocation = location; if (location) { counts[location] = (counts[location] || 0) + 1; } }); var locations = Object.keys(counts).sort(); var enabled = new Set(locations); filterBar.innerHTML = ''; var showAll = document.createElement('button'); showAll.type = 'button'; showAll.className = 'hw-filter-chip hw-filter-chip--all'; showAll.textContent = 'Show all'; filterBar.appendChild(showAll); var locationButtons = {}; locations.forEach(function (location) { var button = document.createElement('button'); button.type = 'button'; button.className = 'hw-filter-chip is-active'; button.setAttribute('aria-pressed', 'true'); button.textContent = location + ' (' + counts[location] + ')'; button.addEventListener('click', function () { if (enabled.has(location)) { enabled.delete(location); } else { enabled.add(location); } applyFilters(); }); locationButtons[location] = button; filterBar.appendChild(button); }); function applyFilters() { rows.forEach(function (row) { row.hidden = !enabled.has(row.dataset.hwLocation); }); locations.forEach(function (location) { var active = enabled.has(location); locationButtons[location].classList.toggle('is-active', active); locationButtons[location].setAttribute('aria-pressed', active ? 'true' : 'false'); }); showAll.disabled = enabled.size === locations.length; showAll.classList.toggle('is-active', enabled.size === locations.length); } showAll.addEventListener('click', function () { enabled = new Set(locations); applyFilters(); }); catalog.dataset.hwLocationFiltersReady = '1'; applyFilters(); }); } mw.hook('wikipage.content').add(function ($content) { initHerbLocationFilters($content && $content[0] ? $content[0] : document); }); }()); /* HarvestWiki admin-only Herb form UI */ (function () { 'use strict'; var groups = mw.config.get('wgUserGroups') || []; var categories = mw.config.get('wgCategories') || []; if (groups.indexOf('sysop') !== -1 || categories.indexOf('Herbs') === -1) { return; } function hideHerbFormActions() { ['ca-formedit', 'ca-formcreate', 'ca-formedit-sticky-header', 'ca-formcreate-sticky-header'].forEach(function (id) { var element = document.getElementById(id); if (element) { element.hidden = true; } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', hideHerbFormActions); } else { hideHerbFormActions(); } }()); /* HarvestWiki rarity-colored Herb and Crystal table rows */ (function () { 'use strict'; var rarities = { common: true, uncommon: true, rare: true, legendary: true, mythic: true, secret: true, mortal: true, refined: true, spirit: true }; function normalizeRarity(value) { var rarity = (value || '').trim().toLowerCase(); return rarities[rarity] ? rarity : ''; } function applyRarityColors(root) { var scope = root && root.querySelectorAll ? root : document; var tables = []; if (scope.matches && scope.matches('table')) { tables.push(scope); } tables = tables.concat(Array.prototype.slice.call(scope.querySelectorAll('table'))); tables.forEach(function (table) { var headerCells = Array.prototype.slice.call(table.querySelectorAll('tr:first-child th, tr:first-child td')); var itemIndex = headerCells.findIndex(function (cell) { var label = cell.textContent.trim().toLowerCase(); return label === 'herb' || label === 'crystal' || cell.classList.contains('field_Herb') || cell.classList.contains('field_Crystal'); }); var rarityIndex = headerCells.findIndex(function (cell) { return cell.textContent.trim().toLowerCase() === 'rarity' || cell.classList.contains('field_Rarity'); }); Array.prototype.slice.call(table.rows).forEach(function (row) { var cells = Array.prototype.slice.call(row.cells); var itemCell = row.querySelector('td.field_Herb, td.field_Crystal'); var rarityCell = row.querySelector('td.field_Rarity'); if (!itemCell && itemIndex >= 0) { itemCell = cells[itemIndex]; } if (!rarityCell && rarityIndex >= 0) { rarityCell = cells[rarityIndex]; } if (!itemCell || !rarityCell || itemCell.tagName !== 'TD') { return; } var rarity = normalizeRarity(rarityCell.textContent); if (!rarity) { return; } row.classList.add('hw-rarity-row'); row.dataset.hwRarity = rarity; itemCell.classList.add('hw-rarity-item'); rarityCell.classList.add('hw-rarity-label'); }); table.dataset.hwRarityColorsReady = '1'; }); } mw.hook('wikipage.content').add(function ($content) { applyRarityColors($content && $content[0] ? $content[0] : document); }); }()); /* HarvestWiki default Herb rarity order */ (function () { 'use strict'; var rarityRank = { common: 0, uncommon: 1, rare: 2, legendary: 3, mythic: 4, secret: 5 }; function sortHerbTables(root) { var scope = root && root.querySelectorAll ? root : document; var tables = []; if (scope.matches && scope.matches('table')) { tables.push(scope); } tables = tables.concat(Array.prototype.slice.call(scope.querySelectorAll('table'))); tables.forEach(function (table) { if (table.dataset.hwHerbRaritySorted === '1') { return; } var headers = Array.prototype.slice.call(table.querySelectorAll('thead th')); if (!headers.length && table.rows.length) { headers = Array.prototype.slice.call(table.rows[0].cells); } var herbIndex = headers.findIndex(function (header) { return header.textContent.trim().toLowerCase() === 'herb' || header.classList.contains('field_Herb'); }); var rarityIndex = headers.findIndex(function (header) { return header.textContent.trim().toLowerCase() === 'rarity' || header.classList.contains('field_Rarity'); }); if (herbIndex < 0 || rarityIndex < 0) { return; } Array.prototype.slice.call(table.tBodies).forEach(function (body) { var rows = Array.prototype.slice.call(body.rows).map(function (row, index) { var rarityCell = row.cells[rarityIndex]; var rarity = rarityCell ? rarityCell.textContent.trim().toLowerCase() : ''; return { row: row, index: index, rank: Object.prototype.hasOwnProperty.call(rarityRank, rarity) ? rarityRank[rarity] : 999 }; }); rows.sort(function (a, b) { return a.rank - b.rank || a.index - b.index; }); var fragment = document.createDocumentFragment(); rows.forEach(function (entry) { fragment.appendChild(entry.row); }); body.appendChild(fragment); }); table.dataset.hwHerbRaritySorted = '1'; }); } mw.hook('wikipage.content').add(function ($content) { sortHerbTables($content && $content[0] ? $content[0] : document); }); }()); /* HarvestWiki default Crystal rarity order */ (function () { 'use strict'; var crystalRarityRank = { mortal: 0, refined: 1, spirit: 2 }; function sortCrystalTables(root) { var scope = root && root.querySelectorAll ? root : document; var tables = []; if (scope.matches && scope.matches('table')) { tables.push(scope); } tables = tables.concat(Array.prototype.slice.call(scope.querySelectorAll('table'))); tables.forEach(function (table) { if (table.dataset.hwCrystalRaritySorted === '1') { return; } var headers = Array.prototype.slice.call(table.querySelectorAll('thead th')); if (!headers.length && table.rows.length) { headers = Array.prototype.slice.call(table.rows[0].cells); } var crystalIndex = headers.findIndex(function (header) { return header.textContent.trim().toLowerCase() === 'crystal' || header.classList.contains('field_Crystal'); }); var rarityIndex = headers.findIndex(function (header) { return header.textContent.trim().toLowerCase() === 'rarity' || header.classList.contains('field_Rarity'); }); if (crystalIndex < 0 || rarityIndex < 0) { return; } Array.prototype.slice.call(table.tBodies).forEach(function (body) { var rows = Array.prototype.slice.call(body.rows).map(function (row, index) { var rarityCell = row.cells[rarityIndex]; var rarity = rarityCell ? rarityCell.textContent.trim().toLowerCase() : ''; return { row: row, index: index, rank: Object.prototype.hasOwnProperty.call(crystalRarityRank, rarity) ? crystalRarityRank[rarity] : 999 }; }); rows.sort(function (a, b) { return a.rank - b.rank || a.index - b.index; }); var fragment = document.createDocumentFragment(); rows.forEach(function (entry) { fragment.appendChild(entry.row); }); body.appendChild(fragment); }); table.dataset.hwCrystalRaritySorted = '1'; }); } mw.hook('wikipage.content').add(function ($content) { sortCrystalTables($content && $content[0] ? $content[0] : document); }); }()); /* HarvestWiki admin-only Crystal form UI */ (function () { 'use strict'; var groups = mw.config.get('wgUserGroups') || []; var categories = mw.config.get('wgCategories') || []; if (groups.indexOf('sysop') !== -1 || categories.indexOf('Crystals') === -1) { return; } function hideCrystalFormActions() { ['ca-formedit', 'ca-formcreate', 'ca-formedit-sticky-header', 'ca-formcreate-sticky-header'].forEach(function (id) { var element = document.getElementById(id); if (element) { element.hidden = true; } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', hideCrystalFormActions); } else { hideCrystalFormActions(); } }()); /* HarvestWiki admin-only Weather form UI */ (function () { 'use strict'; var groups = mw.config.get('wgUserGroups') || []; var categories = mw.config.get('wgCategories') || []; if (groups.indexOf('sysop') !== -1 || categories.indexOf('Weather') === -1) { return; } function hideWeatherFormActions() { ['ca-formedit', 'ca-formcreate', 'ca-formedit-sticky-header', 'ca-formcreate-sticky-header'].forEach(function (id) { var element = document.getElementById(id); if (element) { element.hidden = true; } }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', hideWeatherFormActions); } else { hideWeatherFormActions(); } }()); /* HarvestWiki four-zodiac celestial calendar */ (function () { 'use strict'; var anchor = Date.UTC(2026, 0, 1, 0, 0, 0); var seasonLength = 8 * 60 * 60 * 1000; var seasonNames = [ 'Year of the Dragon', 'Year of the Tiger', 'Year of the Phoenix', 'Year of the Tortoise' ]; function formatDuration(milliseconds) { var seconds = Math.max(0, Math.ceil(milliseconds / 1000)); var hours = Math.floor(seconds / 3600); var minutes = Math.floor((seconds % 3600) / 60); var remainder = seconds % 60; return String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0') + ':' + String(remainder).padStart(2, '0'); } function initZodiacCycles(root) { var widgets = []; if (root && root.matches && root.matches('[data-hw-zodiac-cycle]')) { widgets.push(root); } if (root && root.querySelectorAll) { widgets = widgets.concat(Array.prototype.slice.call(root.querySelectorAll('[data-hw-zodiac-cycle]'))); } widgets.forEach(function (widget) { if (widget.dataset.hwZodiacReady === '1') { return; } widget.dataset.hwZodiacReady = '1'; var currentLabel = widget.querySelector('[data-hw-zodiac-current]'); var countdown = widget.querySelector('[data-hw-zodiac-countdown]'); var progress = widget.querySelector('[data-hw-zodiac-progress]'); var seasons = Array.prototype.slice.call(widget.querySelectorAll('[data-hw-zodiac-season]')); function updateZodiacCycle() { var now = Date.now(); var step = Math.floor((now - anchor) / seasonLength); var currentIndex = ((step % seasonNames.length) + seasonNames.length) % seasonNames.length; var nextBoundary = anchor + ((step + 1) * seasonLength); var remaining = Math.max(0, nextBoundary - now); var elapsedInSeason = ((now - anchor) % seasonLength + seasonLength) % seasonLength; if (currentLabel) { currentLabel.textContent = seasonNames[currentIndex]; } if (countdown) { countdown.textContent = formatDuration(remaining); countdown.title = 'Changes at ' + new Date(nextBoundary).toLocaleString(); } if (progress) { progress.style.width = ((elapsedInSeason / seasonLength) * 100).toFixed(3) + '%'; } seasons.forEach(function (season, index) { var offset = (index - currentIndex + seasonNames.length) % seasonNames.length; var state = season.querySelector('[data-hw-zodiac-state]'); var startsIn = remaining + (Math.max(0, offset - 1) * seasonLength); var changeAt = offset === 0 ? nextBoundary : now + startsIn; season.classList.toggle('is-current', offset === 0); season.setAttribute('aria-current', offset === 0 ? 'true' : 'false'); season.title = (offset === 0 ? 'Ends ' : 'Begins ') + new Date(changeAt).toLocaleString(); if (state) { if (offset === 0) { state.textContent = 'Current · ends in ' + formatDuration(remaining); } else if (offset === 1) { state.textContent = 'Next · starts in ' + formatDuration(startsIn); } else { state.textContent = 'Starts in ' + formatDuration(startsIn); } } }); } updateZodiacCycle(); window.setInterval(updateZodiacCycle, 1000); }); } mw.hook('wikipage.content').add(function ($content) { initZodiacCycles($content && $content[0] ? $content[0] : document); }); }()); /* HarvestWiki multi-page editor controls and archive refresh */ (function () { 'use strict'; function initBulkEditors(root) { var grids = []; if (root && root.matches && root.matches('.pfSpreadsheet')) { grids.push(root); } if (root && root.querySelectorAll) { grids = grids.concat(Array.prototype.slice.call(root.querySelectorAll('.pfSpreadsheet'))); } grids.forEach(function (grid) { if (grid.dataset.hwBulkEditorReady === '1') { return; } grid.dataset.hwBulkEditorReady = '1'; var parameters = new URLSearchParams(window.location.search); var templateName = parameters.get('template') || grid.id.replace(/Grid$/, ''); var archivePage = templateName === 'Herb' ? 'Herbs' : (templateName === 'Crystal' ? 'Crystals' : null); var userGroups = mw.config.get('wgUserGroups') || []; var canDelete = archivePage && userGroups.indexOf('sysop') !== -1; function purgeArchive() { if (!archivePage || typeof mw.Api !== 'function') { return Promise.resolve(); } return new mw.Api().post({ action: 'purge', titles: archivePage, formatversion: 2 }); } function wireRowActions() { Array.prototype.slice.call(grid.querySelectorAll('a.save-changes')).forEach(function (link) { if (link.dataset.hwBulkSaveReady === '1') { return; } link.dataset.hwBulkSaveReady = '1'; link.setAttribute('title', 'Save this row'); link.setAttribute('aria-label', 'Save this row'); link.addEventListener('click', function () { link.classList.remove('is-saved'); link.classList.add('is-saving'); function refreshArchive() { purgeArchive().then(function () { link.classList.remove('is-saving'); link.classList.add('is-saved'); window.setTimeout(function () { link.classList.remove('is-saved'); }, 2200); }); } window.setTimeout(refreshArchive, 1600); window.setTimeout(refreshArchive, 4200); }); }); if (!canDelete) { return; } Array.prototype.slice.call(grid.querySelectorAll('tbody tr')).forEach(function (row) { var pageCell = row.querySelector('td[data-x="0"]'); var actionBox = row.querySelector('.save-or-cancel'); if (!pageCell || !actionBox || actionBox.querySelector('.hw-delete-row')) { return; } var pageTitle = pageCell.textContent.trim(); if (!pageTitle) { return; } var deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'hw-delete-row'; deleteButton.textContent = 'Delete'; deleteButton.title = 'Delete page ' + pageTitle; deleteButton.setAttribute('aria-label', 'Delete page ' + pageTitle); deleteButton.addEventListener('click', function () { var confirmed = window.confirm( 'Permanently delete "' + pageTitle + '"?\n\n' + 'This removes its wiki page and all table data. This cannot be undone.' ); if (!confirmed) { return; } deleteButton.disabled = true; deleteButton.classList.add('is-deleting'); deleteButton.textContent = 'Deleting…'; new mw.Api().postWithToken('csrf', { action: 'delete', title: pageTitle, reason: 'Deleted from the multi-page editor', formatversion: 2 }).then(function () { return purgeArchive(); }).then(function () { deleteButton.textContent = 'Deleted'; window.setTimeout(function () { window.location.reload(); }, 700); }, function () { deleteButton.disabled = false; deleteButton.classList.remove('is-deleting'); deleteButton.textContent = 'Delete'; mw.notify('Could not delete "' + pageTitle + '". The record was not removed.', { type: 'error' }); }); }); actionBox.appendChild(deleteButton); }); } wireRowActions(); if (window.MutationObserver) { new MutationObserver(wireRowActions).observe(grid, { childList: true, subtree: true }); } if (!grid.querySelector('.hw-bulk-editor-note')) { var note = document.createElement('div'); note.className = 'hw-bulk-editor-note'; note.textContent = 'Scroll sideways to reach every field. Use Save at the right of each row.' + (archivePage ? ' Saved rows automatically refresh the ' + archivePage + ' index.' : '') + (canDelete ? ' Administrators can permanently remove a record with Delete.' : ''); grid.appendChild(note); } }); } mw.hook('wikipage.content').add(function ($content) { initBulkEditors($content && $content[0] ? $content[0] : document); }); }());
/* HarvestWiki automatic Herb, Crystal, Mutation, Shovel, Weather, TOD, and Zodiac link colors */
(function () {
'use strict';
var rarityColors = {
common: '#e5e7e7',
uncommon: '#87ff97',
rare: '#6d90ff',
legendary: '#fff687',
mythic: '#ff79cb',
secret: '#12005c',
mortal: '#cef1e8',
refined: '#464747',
spirit: '#00c3f2'
};
var mutationColors = {
silver: '#eef1f1',
golden: '#f4d05b',
vitality: '#e96869',
vampiric: '#de002b',
icy: '#9ee7d8',
frozen: '#6ee8ff',
spirit: '#ffabf6',
royal: '#ffec31',
imperial: '#b600ff',
century: '#5369ff',
millenium: '#6c47ff',
eon: '#7f15ff',
sovereign: '#ff26a5',
supreme: '#ed13ff'
};
var worldColors = {
clear: '#f1cb4f',
cloudy: '#949ba5',
rain: '#60a5e2',
storm: '#525964',
thunderstorm: '#f8d652',
'dao tempest': '#b9422f',
sunrise: '#ef9d49',
daytime: '#63ace0',
sunset: '#df6447',
night: '#abb6f9',
'year of the dragon': '#ffb86f',
'year of the tiger': '#f7f672',
'year of the phoenix': '#f87b84',
'year of the tortoise': '#4fc57d'
};
var shovelColors = {
'basic shovel': '#ffffff',
"journeyman's shovel": '#ffffff',
'root breaker': '#ffffff',
'steel spade': '#ffffff',
'autumn leaf': '#ffffff',
"king's destiny": '#ffffff'
};
var entityColors = {
'ancient woe herb': rarityColors.rare,
'breathless dawn herb': rarityColors.uncommon,
'crimson sun grass': rarityColors.common,
'fleeting dusk herb': rarityColors.uncommon,
'frigid moon grass': rarityColors.common,
'pursuing longevity herb': rarityColors.mythic,
'sparkling joy herb': rarityColors.rare,
'yang lotus': rarityColors.legendary,
'yin lotus': rarityColors.legendary,
'air crystal': rarityColors.mortal,
'earth crystal': rarityColors.mortal,
'flame crystal': rarityColors.mortal,
'water crystal': rarityColors.mortal,
'wood crystal': rarityColors.mortal,
'magma crystal': rarityColors.refined,
'mist crystal': rarityColors.refined,
'longevity crystal': rarityColors.spirit,
clear: worldColors.clear,
cloudy: worldColors.cloudy,
rain: worldColors.rain,
storm: worldColors.storm,
thunderstorm: worldColors.thunderstorm,
'dao tempest': worldColors['dao tempest'],
sunrise: worldColors.sunrise,
daytime: worldColors.daytime,
sunset: worldColors.sunset,
night: worldColors.night,
'year of the dragon': worldColors['year of the dragon'],
'year of the tiger': worldColors['year of the tiger'],
'year of the phoenix': worldColors['year of the phoenix'],
'year of the tortoise': worldColors['year of the tortoise'],
'basic shovel': shovelColors['basic shovel'],
"journeyman's shovel": shovelColors["journeyman's shovel"],
'root breaker': shovelColors['root breaker'],
'steel spade': shovelColors['steel spade'],
'autumn leaf': shovelColors['autumn leaf'],
"king's destiny": shovelColors["king's destiny"]
};
function normalizeTitle(value) {
return (value || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
}
function titleFromLink(link) {
var href = link.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') {
return '';
}
try {
var url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) {
return '';
}
var title = url.searchParams.get('title');
if (!title && url.pathname.indexOf('/index.php/') === 0) {
title = decodeURIComponent(url.pathname.slice('/index.php/'.length));
}
return normalizeTitle((title || '').split('#')[0]);
} catch (error) {
return '';
}
}
function cargoQuery(table, fields) {
return mw.loader.using('mediawiki.api').then(function () {
return new mw.Api().get({
action: 'cargoquery',
tables: table,
fields: fields,
limit: 500,
formatversion: 2
});
}).then(function (data) {
return data && data.cargoquery ? data.cargoquery : [];
}, function () {
return [];
});
}
function buildColorMap() {
return Promise.all([
cargoQuery('Herbs', '_pageName=Page,Rarity'),
cargoQuery('Crystals', '_pageName=Page,Rarity'),
cargoQuery('Mutations', '_pageName=Page,Color'),
cargoQuery('Weathers', '_pageName=Page,Color'),
cargoQuery('Shovels', '_pageName=Page,Color')
]).then(function (groups) {
var colorMap = Object.assign({}, entityColors);
groups[0].forEach(function (row) {
var data = row.title || {};
var color = rarityColors[normalizeTitle(data.Rarity)];
if (data.Page && color) {
colorMap[normalizeTitle(data.Page)] = color;
}
});
groups[1].forEach(function (row) {
var data = row.title || {};
var color = rarityColors[normalizeTitle(data.Rarity)];
if (data.Page && color) {
colorMap[normalizeTitle(data.Page)] = color;
}
});
groups[2].forEach(function (row) {
var data = row.title || {};
if (data.Page && data.Color) {
colorMap[normalizeTitle(data.Page)] = data.Color;
}
});
groups[3].forEach(function (row) {
var data = row.title || {};
if (data.Page && data.Color) {
colorMap[normalizeTitle(data.Page)] = data.Color;
}
});
groups[4].forEach(function (row) {
var data = row.title || {};
if (data.Page && data.Color) {
colorMap[normalizeTitle(data.Page)] = data.Color;
}
});
Object.keys(mutationColors).forEach(function (title) {
colorMap[title] = mutationColors[title];
});
return colorMap;
});
}
function applyLinkColors(root, colorMap) {
var scope = root && root.querySelectorAll ? root : document;
var links = [];
if (scope.matches && scope.matches('a[href]')) {
links.push(scope);
}
links = links.concat(Array.prototype.slice.call(scope.querySelectorAll('a[href]')));
links.forEach(function (link) {
var color = colorMap[titleFromLink(link)];
if (!color || link.classList.contains('new')) {
return;
}
link.classList.add('hw-colored-link');
link.style.setProperty('--hw-link-color', color);
});
}
function applyMutationRows(root, colorMap) {
var scope = root && root.querySelectorAll ? root : document;
var tables = [];
if (scope.matches && scope.matches('table')) {
tables.push(scope);
}
tables = tables.concat(Array.prototype.slice.call(scope.querySelectorAll('table')));
tables.forEach(function (table) {
var headers = Array.prototype.slice.call(table.querySelectorAll('thead th'));
if (!headers.length && table.rows.length) {
headers = Array.prototype.slice.call(table.rows[0].cells);
}
var mutationIndex = headers.findIndex(function (header) {
return header.textContent.trim().toLowerCase() === 'mutation' ||
header.classList.contains('field_Mutation');
});
if (mutationIndex < 0) {
return;
}
Array.prototype.slice.call(table.tBodies).forEach(function (body) {
Array.prototype.slice.call(body.rows).forEach(function (row) {
var mutationCell = row.cells[mutationIndex];
var mutationLink = mutationCell ? mutationCell.querySelector('a[href]') : null;
var color = mutationLink ? colorMap[titleFromLink(mutationLink)] : '';
if (!mutationCell || !color) {
return;
}
row.classList.add('hw-mutation-row');
row.style.setProperty('--hw-mutation-row-color', color);
mutationCell.classList.add('hw-mutation-item');
});
});
});
}
function applyShovelRows(root, colorMap) {
var scope = root && root.querySelectorAll ? root : document;
var tables = [];
if (scope.matches && scope.matches('table')) {
tables.push(scope);
}
tables = tables.concat(Array.prototype.slice.call(scope.querySelectorAll('table')));
tables.forEach(function (table) {
var headers = Array.prototype.slice.call(table.querySelectorAll('thead th'));
if (!headers.length && table.rows.length) {
headers = Array.prototype.slice.call(table.rows[0].cells);
}
var shovelIndex = headers.findIndex(function (header) {
return header.textContent.trim().toLowerCase() === 'shovel' ||
header.classList.contains('field_Shovel');
});
if (shovelIndex < 0) {
return;
}
Array.prototype.slice.call(table.tBodies).forEach(function (body) {
Array.prototype.slice.call(body.rows).forEach(function (row) {
var shovelCell = row.cells[shovelIndex];
var shovelLink = shovelCell ? shovelCell.querySelector('a[href]') : null;
var color = shovelLink ? colorMap[titleFromLink(shovelLink)] : '';
if (!shovelCell || !color) {
return;
}
row.classList.add('hw-shovel-row');
row.style.setProperty('--hw-shovel-row-color', color);
shovelCell.classList.add('hw-shovel-item');
});
});
});
}
var immediateColorMap = Object.assign({}, entityColors);
Object.keys(mutationColors).forEach(function (title) {
immediateColorMap[title] = mutationColors[title];
});
Object.keys(worldColors).forEach(function (title) {
immediateColorMap[title] = worldColors[title];
});
Object.keys(shovelColors).forEach(function (title) {
immediateColorMap[title] = shovelColors[title];
});
var colorMapPromise = buildColorMap();
mw.hook('wikipage.content').add(function ($content) {
var root = $content && $content[0] ? $content[0] : document;
applyMutationRows(root, immediateColorMap);
applyShovelRows(root, immediateColorMap);
applyLinkColors(root, immediateColorMap);
colorMapPromise.then(function (colorMap) {
applyMutationRows(root, colorMap);
applyShovelRows(root, colorMap);
applyLinkColors(root, colorMap);
});
});
}());
/* HarvestWiki hierarchical Citizen popout menu */
(function () {
'use strict';
var groups = [
{
key: 'herb',
title: 'Herb',
note: 'Harvesting & cultivation',
glyph: '草',
pages: ['Herbs', 'Mutations', 'Crystals', 'Shovels']
},
{
key: 'world',
title: 'World',
note: 'Places, people & conditions',
glyph: '界',
pages: ['Weather', 'Locations', 'NPCs']
}
];
function directLink(item) {
var children = item ? item.children : null;
var index;
if (!children) {
return null;
}
for (index = 0; index < children.length; index += 1) {
if (children[index].tagName === 'A') {
return children[index];
}
}
return null;
}
function linkTitle(link) {
var path;
if (!link) {
return '';
}
try {
path = new URL(link.href, location.href).pathname.split('/').pop() || '';
return decodeURIComponent(path).replace(/_/g, ' ');
} catch (error) {
return (link.textContent || '').trim();
}
}
function closeOtherGroups(root, keep) {
Array.prototype.slice.call(root.querySelectorAll('.hw-menu-group.is-open')).forEach(function (group) {
if (group !== keep) {
group.classList.remove('is-open');
group.querySelector('.hw-menu-trigger').setAttribute('aria-expanded', 'false');
}
});
}
function createGroup(config, items, root) {
var group = document.createElement('li');
var trigger = document.createElement('button');
var glyph = document.createElement('span');
var copy = document.createElement('span');
var title = document.createElement('span');
var note = document.createElement('span');
var chevron = document.createElement('span');
var submenu = document.createElement('ul');
var closeTimer = null;
function cancelClose() {
if (closeTimer !== null) {
window.clearTimeout(closeTimer);
closeTimer = null;
}
}
function closeGroup() {
group.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
}
function scheduleClose() {
cancelClose();
closeTimer = window.setTimeout(function () {
closeTimer = null;
if (!group.matches(':hover') && !group.contains(document.activeElement)) {
closeGroup();
}
}, 240);
}
group.className = 'hw-menu-group hw-menu-group--' + config.key;
trigger.type = 'button';
trigger.className = 'hw-menu-trigger';
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-haspopup', 'true');
glyph.className = 'hw-menu-glyph';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = config.glyph;
copy.className = 'hw-menu-trigger-copy';
title.className = 'hw-menu-trigger-title';
title.textContent = config.title;
note.className = 'hw-menu-trigger-note';
note.textContent = config.note;
copy.appendChild(title);
copy.appendChild(note);
chevron.className = 'hw-menu-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '›';
trigger.appendChild(glyph);
trigger.appendChild(copy);
trigger.appendChild(chevron);
submenu.className = 'hw-menu-submenu';
submenu.setAttribute('aria-label', config.title + ' pages');
items.forEach(function (item) {
submenu.appendChild(item);
});
trigger.addEventListener('click', function () {
var willOpen = !group.classList.contains('is-open');
cancelClose();
closeOtherGroups(root, group);
group.classList.toggle('is-open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
group.addEventListener('mouseenter', function () {
cancelClose();
closeOtherGroups(root, group);
group.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
});
group.addEventListener('mouseleave', scheduleClose);
submenu.addEventListener('mouseenter', cancelClose);
submenu.addEventListener('mouseleave', scheduleClose);
group.addEventListener('focusout', function (event) {
if (!group.contains(event.relatedTarget)) {
cancelClose();
closeGroup();
}
});
group.appendChild(trigger);
group.appendChild(submenu);
return group;
}
function enhanceMenu() {
var portlet = document.querySelector('#p-HarvestWiki');
var list;
var items;
var byTitle = {};
var newList;
var complete;
if (!portlet || portlet.classList.contains('hw-menu-ready')) {
return;
}
list = portlet.querySelector('.citizen-menu__content > ul');
if (!list) {
return;
}
items = Array.prototype.slice.call(list.children);
items.forEach(function (item) {
var link = directLink(item);
var title = linkTitle(link);
if (title) {
byTitle[title.toLowerCase()] = item;
}
});
complete = groups.every(function (config) {
return config.pages.every(function (page) {
return Boolean(byTitle[page.toLowerCase()]);
});
});
if (!complete) {
return;
}
newList = document.createElement('ul');
newList.className = 'hw-menu-root';
groups.forEach(function (config) {
var groupItems = config.pages.map(function (page) {
return byTitle[page.toLowerCase()];
});
newList.appendChild(createGroup(config, groupItems, newList));
});
list.replaceWith(newList);
portlet.classList.add('hw-menu-ready');
}
function initMenu() {
enhanceMenu();
new MutationObserver(enhanceMenu).observe(document.body, {
childList: true,
subtree: true
});
document.addEventListener('keydown', function (event) {
var root;
if (event.key !== 'Escape') {
return;
}
root = document.querySelector('.hw-menu-root');
if (root) {
closeOtherGroups(root, null);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMenu, { once: true });
} else {
initMenu();
}
}());
/* HarvestWiki automatic page release history */
(function () {
'use strict';
function addHistoryToNewPage() {
var action = mw.config.get('wgAction');
var articleId = Number(mw.config.get('wgArticleId') || 0);
var namespace = Number(mw.config.get('wgNamespaceNumber'));
var pageName = String(mw.config.get('wgPageName') || '');
var textbox;
var marker;
if (action !== 'edit' || articleId !== 0 || namespace !== 0 || /\/Change_History$/.test(pageName)) {
return;
}
textbox = document.getElementById('wpTextbox1');
if (!textbox || /\{\{\s*Change History\b/i.test(textbox.value)) {
return;
}
marker = '\n\n== Change History ==\n{{Change History|{{' + 'subst:Current version}}|{{' + 'subst:Current version page}}}}\n';
textbox.value = textbox.value.replace(/\s+$/, '') + marker;
textbox.dispatchEvent(new Event('input', { bubbles: true }));
}
function enableHistoryEditorTools() {
var userName = mw.config.get('wgUserName');
var probablyEditable = mw.config.get('wgIsProbablyEditable');
var releases;
if (!userName || probablyEditable === false) {
return;
}
releases = document.querySelectorAll('.hw-change-history-release');
if (!releases.length) {
return;
}
document.body.classList.add('hw-history-tools-enabled');
releases.forEach(function (release) {
var addLink = release.querySelector('.hw-history-add a, a.hw-history-add');
var version = String(release.getAttribute('data-current-version') || '').trim();
var series = String(release.getAttribute('data-current-series') || '').trim();
var historyPage = String(release.getAttribute('data-history-page') || '').trim();
var params;
if (!addLink || !version || !series || !historyPage) {
return;
}
params = new URLSearchParams();
params.set('title', historyPage);
params.set('action', 'edit');
params.set('section', 'new');
params.set('preload', 'Template:Change History/entry');
params.set('preloadtitle', 'Version ' + version);
params.append('preloadparams[]', version);
params.append('preloadparams[]', series);
addLink.href = mw.util.wikiScript('index') + '?' + params.toString();
});
}
function initVersionHistory() {
addHistoryToNewPage();
enableHistoryEditorTools();
window.setTimeout(addHistoryToNewPage, 350);
window.setTimeout(enableHistoryEditorTools, 350);
}
if (mw.hook) {
mw.hook('wikipage.editform').add(function () {
window.setTimeout(addHistoryToNewPage, 100);
});
mw.hook('wikipage.content').add(function () {
window.setTimeout(enableHistoryEditorTools, 100);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initVersionHistory, { once: true });
} else {
initVersionHistory();
}
}());
/* HarvestWiki version snapshots for source edits, Page Forms, and administrators */
(function () {
'use strict';
var MARKER_KEY = 'hwVersionHistoryPending';
var ADMIN_PAGE = 'HarvestWiki:Version control';
var AUTO_START = '<!-- HW-AUTO-START:';
function api() {
return new mw.Api();
}
function isSignedInEditor() {
return Boolean(mw.config.get('wgUserName')) && mw.config.get('wgIsProbablyEditable') !== false;
}
function isVersionAdmin() {
var groups = mw.config.get('wgUserGroups') || [];
return groups.indexOf('sysop') !== -1 || groups.indexOf('interface-admin') !== -1 || groups.indexOf('bureaucrat') !== -1;
}
function normalTitle(title) {
return String(title || '').replace(/_/g, ' ').trim();
}
function seriesFor(version) {
var parts = String(version || '').split('.');
return parts.length === 3 ? parts[0] + '.' + parts[1] : '';
}
function validVersion(version) {
return /^\d+\.\d{2}\.\d+$/.test(String(version || '').trim());
}
function getPageText(title) {
return api().get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
}).then(function (data) {
var page = data && data.query && data.query.pages && data.query.pages[0];
var revision = page && page.revisions && page.revisions[0];
var slots = revision && revision.slots;
return {
exists: Boolean(page && !page.missing),
text: slots && slots.main && typeof slots.main.content === 'string' ? slots.main.content : ''
};
});
}
function savePage(title, text, summary, createOnly) {
var params = {
action: 'edit',
title: title,
text: text,
summary: summary,
watchlist: 'nochange',
formatversion: 2
};
if (createOnly) {
params.createonly = 1;
}
return api().postWithToken('csrf', params);
}
function readCurrentVersion() {
return Promise.all([
getPageText('Template:Current version'),
getPageText('Template:Current version page')
]).then(function (pages) {
var versionMatch = pages[0].text.match(/\d+\.\d{2}\.\d+/);
var seriesMatch = pages[1].text.match(/\d+\.\d{2}/);
var version = versionMatch ? versionMatch[0] : '';
var series = seriesMatch ? seriesMatch[0] : seriesFor(version);
if (!validVersion(version) || !series) {
throw new Error('The current version templates do not contain a valid x.xx.x version.');
}
return { version: version, series: series };
});
}
function cleanFieldName(name) {
var raw = String(name || '');
var matches = raw.match(/\[([^\]]+)\]/g);
var last;
if (matches && matches.length) {
last = matches[matches.length - 1].replace(/^\[|\]$/g, '');
} else {
last = raw;
}
last = last.replace(/^pf_free_text$/i, 'Article content')
.replace(/^free_text$/i, 'Article content')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return last;
}
function labelFor(key) {
return String(key || '')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, function (letter) { return letter.toUpperCase(); });
}
function normalizeValue(value) {
return String(value == null ? '' : value).replace(/\r\n?/g, '\n').replace(/[ \t]+/g, ' ').trim();
}
function collectFormSnapshot(form) {
var values = {};
var ignored = /^(wp(EditToken|Starttime|Edittime|AutoSummary|Summary|Section|UltimateParam)|title|action|format|oldid)$/i;
Array.prototype.forEach.call(form.querySelectorAll('input[name], select[name], textarea[name]'), function (field) {
var type = String(field.type || '').toLowerCase();
var name = String(field.name || '');
var key;
var value;
if (!name || field.disabled || ignored.test(name) || /submit|button|reset|image|file/.test(type)) {
return;
}
if ((type === 'checkbox' || type === 'radio') && !field.checked) {
return;
}
if (type === 'hidden' && name.indexOf('[') === -1 && name !== 'pf_free_text') {
return;
}
key = cleanFieldName(name);
if (!key || /token|timestamp|summary/i.test(key)) {
return;
}
if (field.tagName === 'SELECT' && field.multiple) {
value = Array.prototype.filter.call(field.options, function (option) { return option.selected; })
.map(function (option) { return option.value; }).join('; ');
} else {
value = field.value;
}
value = normalizeValue(value);
if (Object.prototype.hasOwnProperty.call(values, key) && value && values[key] !== value) {
values[key] = normalizeValue(values[key] + '; ' + value);
} else if (!Object.prototype.hasOwnProperty.call(values, key) || value) {
values[key] = value;
}
});
return values;
}
function snapshotFromWikitext(text) {
var source = String(text || '');
var snapshot = {};
var match;
var paramPattern = /^\s*\|\s*([^=\n]+?)\s*=\s*([\s\S]*?)(?=^\s*\|\s*[^=\n]+?\s*=|^\s*}}|\z)/gm;
var cleaned = source.replace(/==\s*Change History\s*==[\s\S]*$/i, '').trim();
while ((match = paramPattern.exec(cleaned))) {
snapshot[cleanFieldName(match[1])] = normalizeValue(match[2]);
}
if (!Object.keys(snapshot).length) {
snapshot['Article content'] = normalizeValue(cleaned);
}
return snapshot;
}
function snapshotForForm(form) {
var textbox = form.querySelector('#wpTextbox1, textarea[name="wpTextbox1"]');
if (textbox && String(mw.config.get('wgAction')) === 'edit') {
return snapshotFromWikitext(textbox.value);
}
return collectFormSnapshot(form);
}
function sameSnapshot(a, b) {
var aKeys = Object.keys(a || {}).sort();
var bKeys = Object.keys(b || {}).sort();
if (aKeys.length !== bKeys.length) {
return false;
}
return aKeys.every(function (key, index) {
return key === bKeys[index] && normalizeValue(a[key]) === normalizeValue(b[key]);
});
}
function encodeSnapshot(snapshot) {
return btoa(unescape(encodeURIComponent(JSON.stringify(snapshot || {}))));
}
function decodeSnapshot(encoded) {
try {
return JSON.parse(decodeURIComponent(escape(atob(encoded))));
} catch (error) {
return {};
}
}
function historyBlocks(text) {
var blocks = [];
var pattern = /<!-- HW-AUTO-START:([0-9]+\.[0-9]{2}\.[0-9]+) -->([\s\S]*?)<!-- HW-AUTO-END:\1 -->/g;
var match;
var snapshotMatch;
while ((match = pattern.exec(String(text || '')))) {
snapshotMatch = match[2].match(/<!-- HW-SNAPSHOT:([A-Za-z0-9+/=]+) -->/);
blocks.push({
version: match[1],
full: match[0],
index: match.index,
created: /\*\s*Page created\./i.test(match[2]),
snapshot: snapshotMatch ? decodeSnapshot(snapshotMatch[1]) : {}
});
}
return blocks;
}
function shortValue(value) {
var clean = normalizeValue(value);
if (clean.length > 120) {
clean = clean.slice(0, 117) + '...';
}
return clean;
}
function changeMessages(before, after) {
var keys = {};
var messages = [];
Object.keys(before || {}).forEach(function (key) { keys[key] = true; });
Object.keys(after || {}).forEach(function (key) { keys[key] = true; });
Object.keys(keys).sort().forEach(function (key) {
var oldValue = normalizeValue((before || {})[key]);
var newValue = normalizeValue((after || {})[key]);
var label = labelFor(key);
if (oldValue === newValue) {
return;
}
if (!oldValue && newValue) {
messages.push('Added ' + label + ': “' + shortValue(newValue) + '”.');
} else if (oldValue && !newValue) {
messages.push('Removed ' + label + ' (previously “' + shortValue(oldValue) + '”).');
} else if (key === 'Article content') {
messages.push('Updated the article content.');
} else {
messages.push('Changed ' + label + ' from “' + shortValue(oldValue) + '” to “' + shortValue(newValue) + '”.');
}
});
if (messages.length > 12) {
messages = messages.slice(0, 12).concat(['Updated ' + (messages.length - 12) + ' additional fields.']);
}
return messages;
}
function buildBlock(version, series, messages, snapshot) {
return AUTO_START + version + ' -->\n' +
'== Version ' + version + ' ==\n' +
"'''{{Version link|" + version + '|' + series + "}}'''\n" +
messages.map(function (message) { return '* ' + message; }).join('\n') + '\n' +
'<!-- HW-SNAPSHOT:' + encodeSnapshot(snapshot) + ' -->\n' +
'<!-- HW-AUTO-END:' + version + ' -->';
}
function upsertHistory(existingText, versionInfo, afterSnapshot, isNewPage, forceCreated) {
var text = String(existingText || '').trim();
var blocks = historyBlocks(text);
var current = blocks.filter(function (block) { return block.version === versionInfo.version; })[0];
var previous = blocks.filter(function (block) { return block.version !== versionInfo.version && Object.keys(block.snapshot).length; })[0];
var beforeSnapshot = previous ? previous.snapshot : {};
var messages;
var replacement;
if (current && current.created) {
messages = ['Page created.'];
} else if ((isNewPage || forceCreated) && !blocks.length) {
messages = ['Page created.'];
} else {
messages = changeMessages(beforeSnapshot, afterSnapshot);
if (!messages.length) {
return { changed: false, text: text };
}
}
replacement = buildBlock(versionInfo.version, versionInfo.series, messages, afterSnapshot);
if (current) {
return { changed: replacement !== current.full, text: text.replace(current.full, replacement) };
}
return { changed: true, text: replacement + (text ? '\n\n' + text : '') };
}
function historyMarkup(versionInfo) {
return '\n\n== Change History ==\n{{Change History|' + versionInfo.version + '|' + versionInfo.series + '}}\n';
}
function ensureHistorySection(title, versionInfo) {
return getPageText(title).then(function (page) {
if (!page.exists || /{{\s*Change History\b/i.test(page.text)) {
return false;
}
return savePage(title, page.text.replace(/\s+$/, '') + historyMarkup(versionInfo), 'Add version change history section').then(function () {
return true;
});
});
}
function recordAutomaticHistory(title, versionInfo, afterSnapshot, isNewPage, forceCreated) {
var historyTitle = title + '/Change History';
return getPageText(historyTitle).then(function (historyPage) {
var updated = upsertHistory(historyPage.text, versionInfo, afterSnapshot, isNewPage, forceCreated);
if (!updated.changed) {
return false;
}
return savePage(historyTitle, updated.text + '\n', 'Record automatic change history for ' + versionInfo.version).then(function () {
return true;
});
});
}
function setPendingEnsure(title, versionInfo) {
try {
sessionStorage.setItem(MARKER_KEY, JSON.stringify({
title: title,
version: versionInfo.version,
series: versionInfo.series,
time: Date.now()
}));
} catch (error) {}
}
function completePendingEnsure() {
var raw;
var pending;
try {
raw = sessionStorage.getItem(MARKER_KEY);
pending = raw ? JSON.parse(raw) : null;
} catch (error) {
pending = null;
}
if (!pending || !pending.title || Date.now() - Number(pending.time || 0) > 300000) {
return;
}
if (normalTitle(mw.config.get('wgPageName')) !== normalTitle(pending.title) || Number(mw.config.get('wgArticleId') || 0) === 0) {
return;
}
sessionStorage.removeItem(MARKER_KEY);
ensureHistorySection(pending.title, { version: pending.version, series: pending.series }).then(function (changed) {
if (changed) {
mw.notify('The page was saved and its change-history section was added.', { type: 'success' });
window.setTimeout(function () { window.location.reload(); }, 700);
}
}).catch(function (error) {
mw.notify('The page saved, but its change-history section could not be added: ' + error.message, { type: 'error' });
});
}
function initTrackedForm() {
var namespace = Number(mw.config.get('wgNamespaceNumber'));
var form;
var initialSnapshot;
var isNewPage;
var pageTitle;
if (!isSignedInEditor() || namespace !== 0 || /\/Change_History$/.test(String(mw.config.get('wgPageName') || ''))) {
return;
}
form = document.querySelector('form#pfForm, form#sfForm, form#editform');
if (!form || form.dataset.hwVersionTrackingReady === '1') {
return;
}
form.dataset.hwVersionTrackingReady = '1';
initialSnapshot = snapshotForForm(form);
isNewPage = Number(mw.config.get('wgArticleId') || 0) === 0;
pageTitle = normalTitle(mw.config.get('wgPageName'));
form.addEventListener('submit', function (event) {
var submitter = event.submitter;
var outgoing;
var submitName = submitter ? String(submitter.name || submitter.value || '') : '';
if (form.dataset.hwVersionSubmitting === '1' || /preview|diff|changes|cancel/i.test(submitName)) {
return;
}
outgoing = snapshotForForm(form);
if (!isNewPage && sameSnapshot(initialSnapshot, outgoing)) {
return;
}
event.preventDefault();
form.dataset.hwVersionSubmitting = '1';
mw.notify('Saving the page and recording its version history…');
readCurrentVersion().then(function (versionInfo) {
return recordAutomaticHistory(pageTitle, versionInfo, outgoing, isNewPage, false).then(function () {
setPendingEnsure(pageTitle, versionInfo);
});
}).catch(function (error) {
mw.notify('The page will still be saved, but automatic history failed: ' + error.message, { type: 'warn' });
}).then(function () {
if (form.requestSubmit) {
form.requestSubmit(submitter || undefined);
} else {
form.submit();
}
});
}, true);
}
function allMainPages() {
var results = [];
function next(apcontinue) {
var params = { action: 'query', list: 'allpages', apnamespace: 0, aplimit: 'max', formatversion: 2 };
if (apcontinue) {
params.apcontinue = apcontinue;
}
return api().get(params).then(function (data) {
results = results.concat((data.query && data.query.allpages) || []);
return data.continue && data.continue.apcontinue ? next(data.continue.apcontinue) : results;
});
}
return next();
}
function backfillOne(title, versionInfo, forceCreated) {
if (title === 'Change History' || /\/Change History$/.test(title)) {
return Promise.resolve({ title: title, skipped: true });
}
return getPageText(title).then(function (page) {
if (!page.exists) {
return { title: title, skipped: true };
}
var snapshot = snapshotFromWikitext(page.text);
return Promise.all([
ensureHistorySection(title, versionInfo),
recordAutomaticHistory(title, versionInfo, snapshot, false, forceCreated)
]).then(function () {
return { title: title, skipped: false };
});
});
}
function runBackfill(versionInfo, forceCreated, progress) {
return allMainPages().then(function (pages) {
var queue = pages.map(function (page) { return normalTitle(page.title); });
var done = 0;
var failures = [];
var workers = [];
function worker() {
var title = queue.shift();
if (!title) {
return Promise.resolve();
}
return backfillOne(title, versionInfo, forceCreated).catch(function (error) {
failures.push(title + ': ' + error.message);
}).then(function () {
done += 1;
progress(done, pages.length, title, failures.length);
return worker();
});
}
for (var i = 0; i < 3; i += 1) {
workers.push(worker());
}
return Promise.all(workers).then(function () {
return { total: pages.length, failures: failures };
});
});
}
function currentTemplateText(version) {
return '<includeonly>' + version + '</includeonly><noinclude>Sitewide current patch version. Change this through [[HarvestWiki:Version control]]. Version numbers use x.xx.x. [[Category:Version templates]]</noinclude>';
}
function currentSeriesText(series) {
return '<includeonly>' + series + '</includeonly><noinclude>Parent release-series number for the current patch. Change this through [[HarvestWiki:Version control]]. [[Category:Version templates]]</noinclude>';
}
function releasePageText(versionInfo, title, date) {
return '{{Version\n|version=' + versionInfo.series + '\n|title=' + title + '\n|release date=' + date + '\n|current patch=' + versionInfo.version + '\n}}\n\n== Overview ==\nDescribe the goals and scope of this release series.\n\n== Changelogs ==\n=== ' + versionInfo.version + ' ===\n' + title + ' — ' + date + '\n* Add release changes here.\n\n[[Category:Versions]]\n';
}
function updateReleasePage(versionInfo, title, date) {
var pageTitle = 'Version ' + versionInfo.series;
return getPageText(pageTitle).then(function (page) {
var text;
if (!page.exists) {
return savePage(pageTitle, releasePageText(versionInfo, title, date), 'Create release page for ' + versionInfo.version);
}
text = page.text;
if (/\|\s*current patch\s*=/.test(text)) {
text = text.replace(/(\|\s*current patch\s*=\s*)[^\n|}]*/i, '$1' + versionInfo.version);
}
if (text.indexOf('=== ' + versionInfo.version + ' ===') === -1) {
text = text.replace(/\s*\[\[Category:Versions\]\]\s*$/i, '') +
'\n\n=== ' + versionInfo.version + ' ===\n' + title + ' — ' + date + '\n* Add release changes here.\n\n[[Category:Versions]]\n';
}
return savePage(pageTitle, text, 'Set current patch to ' + versionInfo.version);
});
}
function applyVersion(version, title, date) {
var versionInfo = { version: version, series: seriesFor(version) };
return Promise.all([
savePage('Template:Current version', currentTemplateText(version), 'Set current version to ' + version),
savePage('Template:Current version page', currentSeriesText(versionInfo.series), 'Set current version series to ' + versionInfo.series),
updateReleasePage(versionInfo, title, date)
]).then(function () {
return versionInfo;
});
}
function initVersionAdmin() {
var root;
var status;
var versionInput;
var titleInput;
var dateInput;
var applyButton;
var backfillButton;
var snapshotButton;
if (normalTitle(mw.config.get('wgPageName')) !== ADMIN_PAGE) {
return;
}
root = document.getElementById('hw-version-control-root');
if (!root) {
return;
}
if (!isVersionAdmin()) {
root.innerHTML = '<div class="hw-note">Administrator access is required.</div>';
return;
}
document.body.classList.add('hw-version-admin-authorized');
root.innerHTML = '<div class="hw-version-admin-card">' +
'<h2>Current release</h2>' +
'<div class="hw-version-admin-grid">' +
'<label>Version number <input id="hw-version-number" type="text" placeholder="0.01.0" pattern="\d+\.\d{2}\.\d+"></label>' +
'<label>Release title <input id="hw-version-title" type="text" placeholder="Early Development"></label>' +
'<label>Release date <input id="hw-version-date" type="text" placeholder="29 July 2026"></label>' +
'</div>' +
'<div class="hw-version-admin-actions"><button id="hw-version-apply" type="button">Apply version and create release page</button></div>' +
'</div>' +
'<div class="hw-version-admin-card"><h2>Page history tools</h2>' +
'<p><button id="hw-version-backfill" type="button">Backfill untracked pages as created now</button></p>' +
'<p><button id="hw-version-snapshot" type="button">Snapshot every page in the current version</button></p>' +
'<p class="hw-version-admin-help">Backfill is for legacy pages. Snapshot creates a current-version baseline only where the page differs from its last stored snapshot.</p>' +
'</div>' +
'<div id="hw-version-status" class="hw-version-status" role="status" aria-live="polite"></div>';
status = document.getElementById('hw-version-status');
versionInput = document.getElementById('hw-version-number');
titleInput = document.getElementById('hw-version-title');
dateInput = document.getElementById('hw-version-date');
applyButton = document.getElementById('hw-version-apply');
backfillButton = document.getElementById('hw-version-backfill');
snapshotButton = document.getElementById('hw-version-snapshot');
readCurrentVersion().then(function (info) {
versionInput.value = info.version;
});
function setBusy(busy) {
applyButton.disabled = busy;
backfillButton.disabled = busy;
snapshotButton.disabled = busy;
}
function progress(done, total, title, failures) {
status.textContent = 'Processed ' + done + ' of ' + total + ': ' + title + (failures ? ' (' + failures + ' failed)' : '');
}
applyButton.addEventListener('click', function () {
var version = versionInput.value.trim();
var title = titleInput.value.trim();
var date = dateInput.value.trim();
if (!validVersion(version) || !title || !date) {
status.textContent = 'Enter a version in x.xx.x format, a title, and a release date.';
return;
}
setBusy(true);
status.textContent = 'Applying ' + version + '…';
applyVersion(version, title, date).then(function () {
status.textContent = 'Current version is now ' + version + '. The release page is ready.';
}).catch(function (error) {
status.textContent = 'Version update failed: ' + error.message;
}).then(function () { setBusy(false); });
});
backfillButton.addEventListener('click', function () {
setBusy(true);
status.textContent = 'Reading current version…';
readCurrentVersion().then(function (info) {
return runBackfill(info, true, progress);
}).then(function (result) {
status.textContent = 'Backfill complete: ' + result.total + ' pages checked, ' + result.failures.length + ' failed.';
}).catch(function (error) {
status.textContent = 'Backfill failed: ' + error.message;
}).then(function () { setBusy(false); });
});
snapshotButton.addEventListener('click', function () {
setBusy(true);
status.textContent = 'Reading current version…';
readCurrentVersion().then(function (info) {
return runBackfill(info, false, progress);
}).then(function (result) {
status.textContent = 'Snapshot complete: ' + result.total + ' pages checked, ' + result.failures.length + ' failed.';
}).catch(function (error) {
status.textContent = 'Snapshot failed: ' + error.message;
}).then(function () { setBusy(false); });
});
}
function initAutomaticVersioning() {
completePendingEnsure();
initTrackedForm();
initVersionAdmin();
window.setTimeout(initTrackedForm, 500);
}
if (mw.hook) {
mw.hook('wikipage.editform').add(function () { window.setTimeout(initTrackedForm, 100); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAutomaticVersioning, { once: true });
} else {
initAutomaticVersioning();
}
}());