Created page with "console.log('OCA test loaded');" |
No edit summary |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
console.log('OCA | // ---- Bharatpedia-specific config ----------------------------------------- | ||
const BP_DOC_PAGE = 'MediaWiki:Gadget-OneClickArchiver'; | |||
const BP_SCRIPT_TAG = 'OneClickArchiver'; | |||
// -------------------------------------------------------------------------- | |||
const ui_strings = {}; | |||
ui_strings['en'] = { | |||
archive_link_text: 'Archive', | |||
archive_to_text: 'Archive to:', | |||
oca_status_enabled: 'OCA is enabled.', | |||
oca_status_disabled_category: 'OCA is disabled: page is in category <a href="/wiki/Category:$1" target="_blank">$1</a>.', | |||
oca_status_disabled_nottalk: 'OCA is disabled: this does not appear to be a talkpage.', | |||
oca_status_using_template: 'OCA is using configuration from template', | |||
oca_status_no_config: 'OCA is enabled on this page; however, OCA could not find any existing archiving configuration.', | |||
oca_loading_init: 'OCA is loading.', | |||
oca_loading_looking_for_config: 'OCA is looking for configuration.', | |||
oca_loading_looking_for_archive: 'OCA is looking for the next valid archive page.', | |||
oca_loading_adding_archive_links: 'OCA is adding archive links.', | |||
oca_status_heading: 'OneClickArchiver status', | |||
oca_info_button_name: 'OCA info', | |||
oca_info_button_description: 'Information about how OneClickArchiver is behaving on this page', | |||
archivethis_retrieving_content: 'Retrieving section content.', | |||
archivethis_content_retrieved: 'Section content retrieved.', | |||
archivethis_dnau_message: 'This section has been marked "Do Not Archive Until" $1, so archiving was aborted.', | |||
archivethis_dnau_see_docs: `Please see <a href="/wiki/${BP_DOC_PAGE}" title="${BP_DOC_PAGE}" target="_blank">the documentation</a> for details.`, | |||
archivethis_dnau_aborted: 'OneClickArchiver aborted!', | |||
archivethis_writing_existing_page: 'Writing to existing archive page <a href="/wiki/$1" target="_blank">$1</a>.', | |||
archivethis_writing_new_page: 'Writing to new archive page <a class="new" href="/wiki/$1" target="_blank">$1</a>.', | |||
archivethis_es_archived_using: `archived using [[${BP_DOC_PAGE}|OneClickArchiver]]`, | |||
archivethis_es_to: 'to', | |||
archivethis_successfully_added: 'Successfully added to archive. Removing from source page.', | |||
archivethis_success: 'Successfully removed from source page. Updating archive config if needed...', | |||
}; | |||
const ui_lang = window.OCAUILang !== undefined ? window.OCAUILang : 'en'; | |||
const ui_str = ui_strings[ui_lang]; | |||
if (!ui_str) { | |||
alert("You are trying to use OCA with a language that does not exist! Script will not work."); | |||
} | |||
const no_manual_archiving_categories = ['Pages that should not be manually archived']; | |||
const automatically_signed_nontalk_categories = ['Non-talk pages that are automatically signed']; | |||
var linkSize; | |||
if (!window.OCALinkSize) { | |||
linkSize = 0.6; | |||
} else { | |||
linkSize = window.OCALinkSize; | |||
} | |||
// ---- utility functions --------------------------------------------------- | |||
function findTemplateInPageRaw(pageText, templateName) { | |||
const escapedName = templateName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); | |||
const templateRegex = new RegExp( | |||
`(\\{\\{\\s*${escapedName}\\s*\\|)((?:[^{}]|\\{\\{(?:[^{}]|\\{\\{[^{}]*\\}\\})*\\}\\})*)(\\}\\})`, | |||
'i' | |||
); | |||
const match = pageText.match(templateRegex); | |||
if (!match) return null; | |||
return match; | |||
} | |||
function findTemplateInPage(pageText, templateName) { | |||
const match = findTemplateInPageRaw(pageText, templateName); | |||
if (!match) return null; | |||
return match[2]; | |||
} | |||
function parseTemplateParamsRaw(inputTemplateString) { | |||
const params = []; | |||
let currentParam = ''; | |||
let braceDepth = 0; | |||
for (let i = 0; i < inputTemplateString.length; i++) { | |||
const char = inputTemplateString[i]; | |||
if (char === '{' && inputTemplateString[i + 1] === '{') { | |||
braceDepth++; | |||
currentParam += '\x7B\x7B'; | |||
i++; | |||
} else if (char === '}' && inputTemplateString[i + 1] === '}') { | |||
braceDepth--; | |||
currentParam += '\x7D\x7D'; | |||
i++; | |||
} else if (char === '|' && braceDepth === 0) { | |||
params.push(currentParam); | |||
currentParam = ''; | |||
} else { | |||
currentParam += char; | |||
} | |||
} | |||
if (currentParam) params.push(currentParam); | |||
return params; | |||
} | |||
function parseTemplateParams(inputTemplateString, dontStripWhitespace) { | |||
const params = parseTemplateParamsRaw(inputTemplateString); | |||
const result = {}; | |||
params.forEach(param => { | |||
const eqIndex = param.indexOf('='); | |||
if (eqIndex !== -1) { | |||
const key = param.substring(0, eqIndex).trim(); | |||
const valueBase = param.substring(eqIndex + 1); | |||
const value = dontStripWhitespace | |||
? valueBase.replace(/^[\r\n]+|[\r\n]+$/g, '') | |||
: valueBase.trim(); | |||
if (key) result[key] = value; | |||
} | |||
}); | |||
return result; | |||
} | |||
function updateTemplateParamInPage(pageText, templateName, targetKey, newValue) { | |||
const [fullMatch, prefix, content, suffix] = findTemplateInPageRaw(pageText, templateName); | |||
const params = parseTemplateParamsRaw(content); | |||
let keyFound = false; | |||
const updatedParams = params.map(param => { | |||
const eqIndex = param.indexOf('='); | |||
if (eqIndex !== -1) { | |||
const key = param.substring(0, eqIndex).trim(); | |||
if (key === targetKey) { | |||
keyFound = true; | |||
const beforeEq = param.substring(0, eqIndex); | |||
const afterEq = param.substring(eqIndex + 1); | |||
const spaceMatch = afterEq.match(/^(\s*)/); | |||
const leadingSpaces = spaceMatch ? spaceMatch[1] : ''; | |||
const trailingSpaceMatch = afterEq.match(/(\s*)$/); | |||
const trailingSpaces = trailingSpaceMatch ? trailingSpaceMatch[1] : ''; | |||
return `${beforeEq}=${leadingSpaces}${newValue}${trailingSpaces}`; | |||
} | |||
} | |||
return param; | |||
}); | |||
if (!keyFound) updatedParams.push(`${targetKey} = ${newValue}\n`); | |||
const updatedTemplate = prefix + updatedParams.join('|') + suffix; | |||
return pageText.replace(fullMatch, updatedTemplate); | |||
} | |||
mw.loader.using(['mediawiki.util', 'mediawiki.api'], async function () { | |||
var OCATagIfAvailable; | |||
var config = mw.config.get([ | |||
'debug', 'wgAction', 'wgArticleId', 'wgCategories', 'wgMonthNames', | |||
'wgNamespaceNumber', 'wgPageName', 'wgRelevantUserName' | |||
]); | |||
var OCAStatus, OCALoading; | |||
function determinePageArchivability() { | |||
const categories = config.wgCategories || []; | |||
OCAStatus = ui_str.oca_status_enabled; | |||
for (const categoryName of no_manual_archiving_categories) { | |||
if (categories.includes(categoryName)) { | |||
OCAStatus = ui_str.oca_status_disabled_category.replaceAll('$1', categoryName); | |||
return false; | |||
} | |||
} | |||
for (const categoryName of automatically_signed_nontalk_categories) { | |||
if (categories.includes(categoryName)) return true; | |||
} | |||
const ns = config.wgNamespaceNumber; | |||
if (ns === 1 || ns === 3 || ns === 5 || ns === 7 || ns === 9 || ns === 11 || ns === 13 || ns === 15) { | |||
return true; | |||
} | |||
if (document.querySelector('#ca-addsection')) return true; | |||
OCAStatus = ui_str.oca_status_disabled_nottalk; | |||
return false; | |||
} | |||
async function doesTagExist(tagName) { | |||
try { | |||
const response = await new mw.Api().get({ | |||
action: 'query', | |||
list: 'tags', | |||
tgprop: 'active|source', | |||
tgcontinue: tagName, | |||
tglimit: 1 | |||
}); | |||
const tags = response.query && response.query.tags; | |||
if (tags && tags.length > 0 && tags[0].name === tagName) { | |||
const targetTag = tags[0]; | |||
return targetTag.active !== undefined && targetTag.source.includes('manual'); | |||
} | |||
return false; | |||
} catch (error) { | |||
return false; | |||
} | |||
} | |||
async function archiveThis(sectionNumber, archiveName, currentArchiveName, sectionName, archiveConfig) { | |||
document.body.insertAdjacentHTML( | |||
'beforeend', | |||
'<div class="overlay" style="background-color: #000000; opacity: 0.4; position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; z-index: 500;"></div>' | |||
); | |||
document.body.insertAdjacentHTML( | |||
'afterbegin', | |||
'<div class="arcProg" style="font-weight: bold; box-shadow: 7px 7px 5px #000000; font-size: 0.9em; line-height: 1.5em; z-index: 501; opacity: 1; position: fixed; width: 50%; left: 25%; top: 30%; background: #F7F7F7; border: #222222 ridge 1px; padding: 20px;"></div>' | |||
); | |||
const arcProg = document.querySelector('.arcProg'); | |||
function printMessage(message, color) { | |||
if (!color) color = "black"; | |||
arcProg.insertAdjacentHTML('beforeend', `<div style="color: ${color}">${message}</div>`); | |||
} | |||
const pageid = config.wgArticleId; | |||
const api = new mw.Api(); | |||
printMessage(ui_str.archivethis_retrieving_content); | |||
const sectionResponse = await api.get({ | |||
action: 'query', | |||
pageids: pageid, | |||
rvsection: sectionNumber, | |||
prop: ['revisions', 'info'], | |||
rvprop: 'content', | |||
indexpageids: 1, | |||
rawcontinue: '' | |||
}); | |||
var sectionContent = sectionResponse.query.pages[pageid].revisions[0]['*']; | |||
printMessage(ui_str.archivethis_content_retrieved, "green"); | |||
var dnau = sectionContent.match(/<!-- \[\[User:DoNotArchiveUntil\]\] ([\d]{2}):([\d]{2}),? ([\d]{1,2}) (January|February|March|April|May|June|July|August|September|October|November|December) ([\d]{4}) \(UTC\) -->/); | |||
var dnauDate; | |||
if (dnau === null || dnau === undefined) { | |||
dnauDate = Date.now(); | |||
dnau = null; | |||
} else { | |||
dnau = dnau[1] + ':' + dnau[2] + ' ' + dnau[3] + ' ' + dnau[4] + ' ' + dnau[5]; | |||
dnauDate = new Date(dnau); | |||
dnauDate = dnauDate.valueOf(); | |||
} | |||
if (dnauDate > Date.now()) { | |||
document.querySelectorAll('.arcProg, .overlay').forEach(element => element.remove()); | |||
const dnauAbortMsg = document.createElement('p'); | |||
dnauAbortMsg.innerHTML = `${ui_str.archivethis_dnau_message.replaceAll('$1', dnau)}<br /><br /><span style="font-size: larger;">${ui_str.archivethis_dnau_see_docs}</span>`; | |||
mw.notify(dnauAbortMsg, { title: ui_str.archivethis_dnau_aborted, tag: 'OCAdnau', autoHide: false }); | |||
return; | |||
} | |||
if (archiveName == currentArchiveName) { | |||
sectionContent = '\n\n\x7B\x7BClear\x7D\x7D\n' + sectionContent; | |||
printMessage(ui_str.archivethis_writing_existing_page.replaceAll('$1', archiveName)); | |||
} else { | |||
sectionContent = archiveConfig.archivePageHeader + '\n\n' + sectionContent; | |||
printMessage(ui_str.archivethis_writing_new_page.replaceAll('$1', archiveName)); | |||
} | |||
if (dnau != null) { | |||
sectionContent = sectionContent.replace(/<!-- \[\[User:DoNotArchiveUntil\]\] ([\d]{2}):([\d]{2}), ([\d]{1,2}) (January|February|March|April|May|June|July|August|September|October|November|December) ([\d]{4}) \(UTC\) -->/g, ''); | |||
} | |||
await api.postWithToken('edit', { | |||
action: 'edit', | |||
title: archiveName, | |||
appendtext: sectionContent, | |||
summary: `/* ${sectionName} */ ${ui_str.archivethis_es_archived_using}`, | |||
tags: OCATagIfAvailable | |||
}); | |||
printMessage(ui_str.archivethis_successfully_added, "green"); | |||
await new mw.Api().postWithToken('edit', { | |||
action: 'edit', | |||
section: sectionNumber, | |||
pageid: pageid, | |||
text: '', | |||
summary: `[[${BP_DOC_PAGE}|OneClickArchived]] "${sectionName}" ${ui_str.archivethis_es_to} [[${archiveName}]]`, | |||
tags: OCATagIfAvailable | |||
}); | |||
printMessage(ui_str.archivethis_success, "green"); | |||
await archiveConfig.updateConfigIfNeeded(pageid); | |||
location.reload(); | |||
} | |||
function extractSectionNumber(heading) { | |||
var parentWrapper = heading.parentElement; | |||
var secId = heading.getAttribute('data-mw-section-id') || | |||
(parentWrapper && parentWrapper.getAttribute && parentWrapper.getAttribute('data-mw-section-id')); | |||
if (secId && secId !== '-1' && secId !== '0') { | |||
return parseInt(secId, 10); | |||
} | |||
var editsection = heading.querySelector('.mw-editsection') || | |||
(parentWrapper && parentWrapper.querySelector('.mw-editsection')); | |||
if (editsection) { | |||
var editLink = editsection.querySelector('a'); | |||
if (editLink) { | |||
var href = editLink.getAttribute('href'); | |||
var match = href && href.match(/[?&]section=([0-9]+)/); | |||
if (match) return parseInt(match[1], 10); | |||
} | |||
} | |||
var anyLink = heading.querySelector('a[href*="section="]') || | |||
(parentWrapper && parentWrapper.querySelector('a[href*="section="]')); | |||
if (anyLink) { | |||
var href2 = anyLink.getAttribute('href'); | |||
var match2 = href2 && href2.match(/[?&]section=([0-9]+)/); | |||
if (match2) return parseInt(match2[1], 10); | |||
} | |||
return null; | |||
} | |||
function addArchiveLinks(headerLevel, archiveName, currentArchiveName, archiveConfig) { | |||
var headings = document.querySelectorAll('h' + headerLevel); | |||
console.log('OCA: found', headings.length, 'h' + headerLevel + ' headings'); | |||
Array.prototype.forEach.call(headings, function (heading) { | |||
if (heading.id === 'mw-toc-heading') return; | |||
if (heading.querySelector('.archiverLink')) return; | |||
var parentWrapper = heading.parentElement; | |||
if (parentWrapper && parentWrapper.querySelector && parentWrapper.querySelector('.archiverLink')) return; | |||
var sectionNumber = extractSectionNumber(heading); | |||
if (sectionNumber === null) { | |||
console.log('OCA: no section number for:', heading.textContent.slice(0, 40)); | |||
return; | |||
} | |||
var sectionName = heading.textContent.replace(/\[?\s*edit(\s+source)?\s*\]?\s*$/i, '').trim(); | |||
var archiveButtonWrapper = document.createElement('span'); | |||
archiveButtonWrapper.className = 'archiverLinkWrapper'; | |||
archiveButtonWrapper.style.fontSize = linkSize + 'em'; | |||
archiveButtonWrapper.style.fontWeight = 'bold'; | |||
archiveButtonWrapper.style.marginLeft = '0.5em'; | |||
var archiveButton = document.createElement('a'); | |||
archiveButton.href = '#archiverLink'; | |||
archiveButton.className = 'archiverLink'; | |||
archiveButton.setAttribute('data-section', String(sectionNumber)); | |||
archiveButton.title = ui_str.archive_to_text + ' ' + archiveName; | |||
archiveButton.innerText = ui_str.archive_link_text; | |||
archiveButton.addEventListener('click', function (e) { | |||
e.preventDefault(); | |||
archiveThis(sectionNumber, archiveName, currentArchiveName, sectionName, archiveConfig); | |||
}); | |||
archiveButtonWrapper.appendChild(document.createTextNode('[ ')); | |||
archiveButtonWrapper.appendChild(archiveButton); | |||
archiveButtonWrapper.appendChild(document.createTextNode(' ]')); | |||
heading.appendChild(archiveButtonWrapper); | |||
console.log('OCA: added link to section', sectionNumber, ':', sectionName.slice(0, 40)); | |||
}); | |||
} | |||
// ---- config classes -------------------------------------------------- | |||
class archiveBotConfig { | |||
constructor(counter, archivePageHeader, headerLevel) { | |||
this.counter = parseInt(counter); | |||
if (!counter) this.counter = 1; | |||
this.archivePageHeader = archivePageHeader; | |||
this.headerLevel = headerLevel; | |||
} | |||
getCurrentArchiveName() { throw new Error("You must implement this method!"); } | |||
getArchiveNameToWrite() { throw new Error("You must implement this method!"); } | |||
async updateConfigIfNeeded() { throw new Error("You must implement this method!"); } | |||
} | |||
class archiveBotConfigMisza extends archiveBotConfig { | |||
_parseToBytes(sizeString) { | |||
if (typeof sizeString !== 'string') return null; | |||
const cleanStr = sizeString.trim().toUpperCase(); | |||
const match = cleanStr.match(/^(\d+(?:\.\d+)?)\s*([KMGTPB]?)$/); | |||
if (!match) return null; | |||
const value = parseFloat(match[1]); | |||
const unit = match[2]; | |||
const multipliers = { '': 1, 'B': 1, 'K': 1e3, 'M': 1e6, 'G': 1e9 }; | |||
return value * (multipliers[unit] || 1); | |||
} | |||
_applyPythonTemplateSubstitutions(inputTemplate, counter) { | |||
const now = new Date(); | |||
const thisMonthNum = now.getMonth() + 1; | |||
const thisYear = now.getFullYear(); | |||
const thisMonthFullName = config.wgMonthNames[thisMonthNum]; | |||
const monthNamesShort = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |||
const thisMonthShortName = monthNamesShort[thisMonthNum]; | |||
const thisQuarter = Math.ceil(thisMonthNum / 4); | |||
const values = { | |||
year: thisYear, | |||
month: thisMonthNum, | |||
monthname: thisMonthFullName, | |||
monthnameshort: thisMonthShortName, | |||
quarter: thisQuarter, | |||
counter: counter | |||
}; | |||
const placeholderRegex = /%\((\w+)\)(0?\d+)?([ds])/g; | |||
return inputTemplate.replace(placeholderRegex, (match, key, pad) => { | |||
if (!(key in values)) return match; | |||
let val = String(values[key]); | |||
if (pad) { | |||
const padChar = pad.startsWith('0') ? '0' : ' '; | |||
const padLength = parseInt(pad, 10); | |||
val = val.padStart(padLength, padChar); | |||
} | |||
return val; | |||
}); | |||
} | |||
constructor(counter, archiveheader, archive, maxarchivesize, templateName, pageText) { | |||
const headerLevel = 2; | |||
if (!archiveheader) archiveheader = "\x7B\x7BArchive\x7D\x7D"; | |||
super(counter, archiveheader, headerLevel); | |||
this.pageText = pageText; | |||
this.templateName = templateName; | |||
this.parsedCounter = counter; | |||
this.archivePythonTemplate = archive; | |||
this.archiveSizeLimitType = null; | |||
if (maxarchivesize && maxarchivesize.endsWith("T")) { | |||
this.archiveSizeThreadLimit = parseInt(maxarchivesize.slice(0, -1)); | |||
if (this.archiveSizeThreadLimit) this.archiveSizeLimitType = "threads"; | |||
} else { | |||
this.archiveSizeByteLimit = this._parseToBytes(maxarchivesize); | |||
if (this.archiveSizeByteLimit) this.archiveSizeLimitType = "bytes"; | |||
} | |||
} | |||
getCurrentArchiveName() { | |||
return this._applyPythonTemplateSubstitutions(this.archivePythonTemplate, this.counter); | |||
} | |||
getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra) { | |||
this.toWriteCounter = this.counter + counterExtra; | |||
if (this.archiveSizeLimitType === "threads") { | |||
if (currentArchiveSections >= this.archiveSizeThreadLimit) this.toWriteCounter++; | |||
} else if (this.archiveSizeLimitType === "bytes") { | |||
if (currentArchiveBytes >= this.archiveSizeByteLimit) this.toWriteCounter++; | |||
} | |||
return this._applyPythonTemplateSubstitutions(this.archivePythonTemplate, this.toWriteCounter); | |||
} | |||
async updateConfigIfNeeded(pageid) { | |||
if (!this.templateName) return; | |||
const newContent = updateTemplateParamInPage(this.pageText, this.templateName, "counter", this.toWriteCounter); | |||
if (newContent != this.pageText) { | |||
await new mw.Api().postWithToken('edit', { | |||
action: 'edit', | |||
section: 0, | |||
pageid: pageid, | |||
text: newContent, | |||
summary: `[[${BP_DOC_PAGE}|OneClickArchiver]] updating counter.`, | |||
tags: OCATagIfAvailable | |||
}); | |||
} | |||
} | |||
} | |||
function parseNativeArchiveConfig(pageText) { | |||
const templateName = "Archive config"; | |||
const content = findTemplateInPage(pageText, templateName); | |||
if (!content) return; | |||
const cfg = parseTemplateParams(content); | |||
if (!cfg.archive) return; | |||
return new archiveBotConfigMisza( | |||
cfg.counter, | |||
cfg.header, | |||
cfg.archive, | |||
cfg.maxsize, | |||
templateName, | |||
pageText | |||
); | |||
} | |||
function createDefaultConfig(pageName, pageText) { | |||
const archivePattern = pageName + '/Archive %(counter)d'; | |||
return new archiveBotConfigMisza( | |||
1, | |||
'\x7B\x7BArchive\x7D\x7D', | |||
archivePattern, | |||
'100K', | |||
null, | |||
pageText | |||
); | |||
} | |||
function parseMiszaBotConfig(pageText) { | |||
const templateName = "User:MiszaBot/config"; | |||
const content = findTemplateInPage(pageText, templateName); | |||
if (!content) return; | |||
const config = parseTemplateParams(content); | |||
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText); | |||
} | |||
function parseArchiveBasics(pageText) { | |||
const templateName = "Archive basics"; | |||
const content = findTemplateInPage(pageText, templateName); | |||
if (!content) return; | |||
const config = parseTemplateParams(content); | |||
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText); | |||
} | |||
function parseHazardBotConfig(pageText) { | |||
const templateName = "User:Hazard-Bot/Archiver"; | |||
const content = findTemplateInPage(pageText, templateName); | |||
if (!content) return; | |||
const config = parseTemplateParams(content); | |||
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText); | |||
} | |||
class archiveBotConfigClueBotIII extends archiveBotConfig { | |||
_phpDate(format, timestamp) { | |||
const date = timestamp !== undefined | |||
? (timestamp instanceof Date ? timestamp : new Date(timestamp * 1000)) | |||
: new Date(); | |||
const pad = (val, len = 2) => String(val).padStart(len, '0'); | |||
const getDayOfYear = (d) => { | |||
const start = new Date(d.getFullYear(), 0, 0); | |||
const diff = d - start; | |||
const oneDay = 1000 * 60 * 60 * 24; | |||
return Math.floor(diff / oneDay) - 1; | |||
}; | |||
const getISOWeek = (d) => { | |||
const target = new Date(d.valueOf()); | |||
const dayNr = (d.getDay() + 6) % 7; | |||
target.setDate(target.getDate() - dayNr + 3); | |||
const firstThursday = target.valueOf(); | |||
target.setMonth(0, 1); | |||
if (target.getDay() !== 4) target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7)); | |||
return 1 + Math.ceil((firstThursday - target) / 604800000); | |||
}; | |||
const getSwatchTime = (d) => { | |||
const time = d.getTime(); | |||
const utc1 = time + (d.getTimezoneOffset() * 60000) + 3600000; | |||
const bmt = new Date(utc1); | |||
const beats = Math.floor((bmt.getHours() * 3600 + bmt.getMinutes() * 60 + bmt.getSeconds() + bmt.getMilliseconds() / 1000) / 86.4); | |||
return pad(beats, 3); | |||
}; | |||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; | |||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; | |||
const formatters = { | |||
d: d => pad(d.getDate()), | |||
D: d => days[d.getDay()].substring(0, 3), | |||
j: d => d.getDate(), | |||
l: d => days[d.getDay()], | |||
N: d => d.getDay() === 0 ? 7 : d.getDay(), | |||
S: d => { const j = d.getDate(); if (j % 10 === 1 && j !== 11) return 'st'; if (j % 10 === 2 && j !== 12) return 'nd'; if (j % 10 === 3 && j !== 13) return 'rd'; return 'th'; }, | |||
w: d => d.getDay(), | |||
z: d => getDayOfYear(d), | |||
W: d => pad(getISOWeek(d)), | |||
F: d => months[d.getMonth()], | |||
m: d => pad(d.getMonth() + 1), | |||
M: d => months[d.getMonth()].substring(0, 3), | |||
n: d => d.getMonth() + 1, | |||
t: d => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(), | |||
L: d => (new Date(d.getFullYear(), 1, 29).getMonth() === 1) ? 1 : 0, | |||
o: d => { const target = new Date(d.valueOf()); target.setDate(target.getDate() - ((d.getDay() + 6) % 7) + 3); return target.getFullYear(); }, | |||
Y: d => d.getFullYear(), | |||
y: d => String(d.getFullYear()).slice(-2), | |||
a: d => d.getHours() >= 12 ? 'pm' : 'am', | |||
A: d => d.getHours() >= 12 ? 'PM' : 'AM', | |||
B: d => getSwatchTime(d), | |||
g: d => d.getHours() % 12 || 12, | |||
G: d => d.getHours(), | |||
h: d => pad(d.getHours() % 12 || 12), | |||
H: d => pad(d.getHours()), | |||
i: d => pad(d.getMinutes()), | |||
s: d => pad(d.getSeconds()), | |||
u: d => pad(d.getMilliseconds() * 1000, 6), | |||
v: d => pad(d.getMilliseconds(), 3), | |||
e: d => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } }, | |||
I: d => { const jan = new Date(d.getFullYear(), 0, 1).getTimezoneOffset(); const jul = new Date(d.getFullYear(), 6, 1).getTimezoneOffset(); return d.getTimezoneOffset() < Math.max(jan, jul) ? 1 : 0; }, | |||
O: d => { const offset = d.getTimezoneOffset(); const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + pad(absOffset % 60); }, | |||
P: d => { const offset = d.getTimezoneOffset(); const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + ':' + pad(absOffset % 60); }, | |||
p: d => { const offset = d.getTimezoneOffset(); if (offset === 0) return 'Z'; const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + ':' + pad(absOffset % 60); }, | |||
T: d => { const matches = d.toTimeString().match(/\(([^)]+)\)$/); return matches ? matches[1] : 'UTC'; }, | |||
Z: d => -d.getTimezoneOffset() * 60, | |||
c: d => d.toISOString().replace(/\.\d+Z$/, '') + (d.getTimezoneOffset() === 0 ? 'Z' : formatters.P(d)), | |||
r: d => d.toUTCString(), | |||
U: d => Math.floor(d.getTime() / 1000) | |||
}; | |||
let result = ''; | |||
for (let i = 0; i < format.length; i++) { | |||
const char = format[i]; | |||
if (char === '\\') { | |||
if (i + 1 < format.length) result += format[++i]; | |||
continue; | |||
} | |||
if (formatters[char]) result += formatters[char](date); | |||
else result += char; | |||
} | |||
return result; | |||
} | |||
_renderPHPDateString(dateString, counter) { | |||
const dateStringCountersApplied = dateString.replace('%%i', counter); | |||
return this._phpDate(dateStringCountersApplied); | |||
} | |||
constructor(archiveprefix, format, header, numberstart, headerlevel, maxarchsize, templateName, pageText) { | |||
if (!headerlevel) headerlevel = 2; | |||
if (!header) header = "\x7B\x7BArchive\x7D\x7D"; | |||
super(numberstart, header, headerlevel); | |||
this.archiveprefix = archiveprefix; | |||
this.format = format; | |||
this.maxarchsize = maxarchsize || Infinity; | |||
this.templateName = templateName; | |||
this.pageText = pageText; | |||
} | |||
getCurrentArchiveName() { | |||
const archiveSuffix = this._renderPHPDateString(this.format, this.counter); | |||
return this.archiveprefix + archiveSuffix; | |||
} | |||
getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra) { | |||
var toWriteCounter = this.counter + counterExtra; | |||
if (this.format.includes('%%i')) { | |||
if (currentArchiveBytes >= this.maxarchsize) toWriteCounter++; | |||
} | |||
const archiveSuffix = this._renderPHPDateString(this.format, toWriteCounter); | |||
return this.archiveprefix + archiveSuffix; | |||
} | |||
async updateConfigIfNeeded() { return; } | |||
} | |||
function parseClueBotIIIConfig(pageText) { | |||
const templateName = "User:ClueBot III/ArchiveThis"; | |||
const content = findTemplateInPage(pageText, templateName); | |||
if (!content) return; | |||
const config = parseTemplateParams(content, true); | |||
return new archiveBotConfigClueBotIII(config.archiveprefix, config.format, config.header, config.numberstart, config.headerlevel, config.maxarchsize, templateName, pageText); | |||
} | |||
const archiveConfigsToTry = [ | |||
parseNativeArchiveConfig, | |||
parseMiszaBotConfig, | |||
parseClueBotIIIConfig, | |||
parseArchiveBasics, | |||
parseHazardBotConfig | |||
]; | |||
function OCAInfo() { | |||
const OCAInfoMsg = document.createElement('p'); | |||
OCAInfoMsg.innerHTML = `<b>${OCALoading}</b>${OCAStatus}`; | |||
mw.notify(OCAInfoMsg, { title: ui_str.oca_status_heading, tag: 'OCAstatus', autoHide: false }); | |||
} | |||
async function getPageSizeInfo(pageName, headerLevel) { | |||
const api = new mw.Api(); | |||
const [pageData, pageParse] = await Promise.all([ | |||
api.get({ | |||
action: 'query', | |||
prop: 'revisions', | |||
rvlimit: 1, | |||
rvprop: ['size', 'content'], | |||
titles: pageName, | |||
list: 'usercontribs', | |||
uclimit: 1, | |||
ucprop: 'timestamp', | |||
ucuser: config.wgRelevantUserName || 'Example', | |||
rawcontinue: '', | |||
}), | |||
api.get({ | |||
action: 'parse', | |||
page: pageName, | |||
prop: 'tocdata', | |||
}).catch(error => { | |||
if (error === 'missingtitle') return { parse: { tocdata: { sections: [] } } }; | |||
throw error; | |||
}) | |||
]); | |||
const page = Object.values((pageData && pageData.query && pageData.query.pages) || {})[0]; | |||
const pageBytes = parseInt(page && page.revisions && page.revisions[0] && page.revisions[0].size, 10) || -1; | |||
const sections = (pageParse && pageParse.parse && pageParse.parse.tocdata && pageParse.parse.tocdata.sections) || []; | |||
const TOClevel = headerLevel - 1; | |||
const pageSections = sections.filter(s => parseInt(s.tocLevel, 10) === TOClevel).length; | |||
return [pageBytes, pageSections]; | |||
} | |||
async function loadOCA() { | |||
var OCATagExistsPromise = doesTagExist(BP_SCRIPT_TAG); | |||
OCALoading = `${ui_str.oca_loading_init}<br />`; | |||
if (mw.config.get('wgNamespaceNumber') > 0) { | |||
const OCAInfoButton = mw.util.addPortletLink( | |||
'p-cactions', | |||
'#archiverLink', | |||
ui_str.oca_info_button_name, | |||
'pt-OCA', | |||
ui_str.oca_info_button_description, | |||
null | |||
); | |||
OCAInfoButton.addEventListener('click', function (e) { | |||
e.preventDefault(); | |||
OCAInfo(); | |||
}); | |||
} | |||
if (!determinePageArchivability()) { | |||
OCALoading = ""; | |||
return; | |||
} | |||
var pageid = config.wgArticleId; | |||
const pageSection0 = await new mw.Api().get({ | |||
action: 'query', | |||
prop: ['revisions', 'info'], | |||
rvsection: 0, | |||
rvprop: 'content', | |||
pageids: pageid, | |||
indexpageids: 1, | |||
rawcontinue: '' | |||
}); | |||
const content0 = pageSection0.query.pages[pageid].revisions[0]['*']; | |||
OCALoading = `${ui_str.oca_loading_looking_for_config}<br />\n`; | |||
var archiveConfig; | |||
for (const configLoader of archiveConfigsToTry) { | |||
archiveConfig = configLoader(content0); | |||
if (archiveConfig) { | |||
const templateQualifiedName = archiveConfig.templateName.includes(":") ? archiveConfig.templateName : "Template:" + archiveConfig.templateName; | |||
OCAStatus = OCAStatus + `\n${ui_str.oca_status_using_template} \x7B\x7B<a href="/wiki/${templateQualifiedName}">${archiveConfig.templateName}</a>\x7D\x7D.`; | |||
break; | |||
} | |||
} | |||
if (!archiveConfig) { | |||
archiveConfig = createDefaultConfig(config.wgPageName, content0); | |||
OCAStatus = OCAStatus + ` <i>(no config template found; using default: ${config.wgPageName}/Archive N)</i>`; | |||
} | |||
const initialArchiveName = archiveConfig.getCurrentArchiveName(); | |||
OCALoading = `${ui_str.oca_loading_looking_for_archive}<br />\n`; | |||
var archiveNameLast; | |||
var archiveNameCurrent = initialArchiveName; | |||
var counterExtra = 0; | |||
var maxArchiveIter = 30; | |||
while (archiveNameLast !== archiveNameCurrent && counterExtra < maxArchiveIter) { | |||
archiveNameLast = archiveNameCurrent; | |||
const sizeInfo = await getPageSizeInfo(archiveNameLast, archiveConfig.headerLevel); | |||
const currentArchiveBytes = sizeInfo[0]; | |||
const currentArchiveSections = sizeInfo[1]; | |||
console.log('OCA: checking', archiveNameLast, 'bytes=', currentArchiveBytes); | |||
if (currentArchiveBytes === -1) { | |||
archiveNameCurrent = archiveNameLast; | |||
break; | |||
} | |||
archiveNameCurrent = archiveConfig.getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra); | |||
counterExtra++; | |||
} | |||
const archivePageToWrite = archiveNameCurrent; | |||
console.log('OCA: archive page to write =', archivePageToWrite); | |||
if (await OCATagExistsPromise) { | |||
OCATagIfAvailable = BP_SCRIPT_TAG; | |||
} | |||
OCALoading = `${ui_str.oca_loading_adding_archive_links}<br />\n`; | |||
addArchiveLinks(archiveConfig.headerLevel, archivePageToWrite, initialArchiveName, archiveConfig); | |||
OCALoading = ""; | |||
} | |||
// ---- AUTO-START LOGIC ------------------------------------------------- | |||
// Wait for window.load (all resources) + small delay so skin/DOM is fully ready | |||
function startOCA() { | |||
if (window.OCA_already_started) return; | |||
window.OCA_already_started = true; | |||
console.log('OCA: starting loadOCA (auto)'); | |||
loadOCA(); | |||
} | |||
if (document.readyState === 'complete') { | |||
setTimeout(startOCA, 800); | |||
} else { | |||
window.addEventListener('load', function () { | |||
setTimeout(startOCA, 800); | |||
}); | |||
setTimeout(startOCA, 4000); | |||
} | |||
}); | |||
Latest revision as of 14:58, 6 October 2026
// ---- Bharatpedia-specific config -----------------------------------------
const BP_DOC_PAGE = 'MediaWiki:Gadget-OneClickArchiver';
const BP_SCRIPT_TAG = 'OneClickArchiver';
// --------------------------------------------------------------------------
const ui_strings = {};
ui_strings['en'] = {
archive_link_text: 'Archive',
archive_to_text: 'Archive to:',
oca_status_enabled: 'OCA is enabled.',
oca_status_disabled_category: 'OCA is disabled: page is in category <a href="/wiki/Category:$1" target="_blank">$1</a>.',
oca_status_disabled_nottalk: 'OCA is disabled: this does not appear to be a talkpage.',
oca_status_using_template: 'OCA is using configuration from template',
oca_status_no_config: 'OCA is enabled on this page; however, OCA could not find any existing archiving configuration.',
oca_loading_init: 'OCA is loading.',
oca_loading_looking_for_config: 'OCA is looking for configuration.',
oca_loading_looking_for_archive: 'OCA is looking for the next valid archive page.',
oca_loading_adding_archive_links: 'OCA is adding archive links.',
oca_status_heading: 'OneClickArchiver status',
oca_info_button_name: 'OCA info',
oca_info_button_description: 'Information about how OneClickArchiver is behaving on this page',
archivethis_retrieving_content: 'Retrieving section content.',
archivethis_content_retrieved: 'Section content retrieved.',
archivethis_dnau_message: 'This section has been marked "Do Not Archive Until" $1, so archiving was aborted.',
archivethis_dnau_see_docs: `Please see <a href="/wiki/${BP_DOC_PAGE}" title="${BP_DOC_PAGE}" target="_blank">the documentation</a> for details.`,
archivethis_dnau_aborted: 'OneClickArchiver aborted!',
archivethis_writing_existing_page: 'Writing to existing archive page <a href="/wiki/$1" target="_blank">$1</a>.',
archivethis_writing_new_page: 'Writing to new archive page <a class="new" href="/wiki/$1" target="_blank">$1</a>.',
archivethis_es_archived_using: `archived using [[${BP_DOC_PAGE}|OneClickArchiver]]`,
archivethis_es_to: 'to',
archivethis_successfully_added: 'Successfully added to archive. Removing from source page.',
archivethis_success: 'Successfully removed from source page. Updating archive config if needed...',
};
const ui_lang = window.OCAUILang !== undefined ? window.OCAUILang : 'en';
const ui_str = ui_strings[ui_lang];
if (!ui_str) {
alert("You are trying to use OCA with a language that does not exist! Script will not work.");
}
const no_manual_archiving_categories = ['Pages that should not be manually archived'];
const automatically_signed_nontalk_categories = ['Non-talk pages that are automatically signed'];
var linkSize;
if (!window.OCALinkSize) {
linkSize = 0.6;
} else {
linkSize = window.OCALinkSize;
}
// ---- utility functions ---------------------------------------------------
function findTemplateInPageRaw(pageText, templateName) {
const escapedName = templateName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const templateRegex = new RegExp(
`(\\{\\{\\s*${escapedName}\\s*\\|)((?:[^{}]|\\{\\{(?:[^{}]|\\{\\{[^{}]*\\}\\})*\\}\\})*)(\\}\\})`,
'i'
);
const match = pageText.match(templateRegex);
if (!match) return null;
return match;
}
function findTemplateInPage(pageText, templateName) {
const match = findTemplateInPageRaw(pageText, templateName);
if (!match) return null;
return match[2];
}
function parseTemplateParamsRaw(inputTemplateString) {
const params = [];
let currentParam = '';
let braceDepth = 0;
for (let i = 0; i < inputTemplateString.length; i++) {
const char = inputTemplateString[i];
if (char === '{' && inputTemplateString[i + 1] === '{') {
braceDepth++;
currentParam += '\x7B\x7B';
i++;
} else if (char === '}' && inputTemplateString[i + 1] === '}') {
braceDepth--;
currentParam += '\x7D\x7D';
i++;
} else if (char === '|' && braceDepth === 0) {
params.push(currentParam);
currentParam = '';
} else {
currentParam += char;
}
}
if (currentParam) params.push(currentParam);
return params;
}
function parseTemplateParams(inputTemplateString, dontStripWhitespace) {
const params = parseTemplateParamsRaw(inputTemplateString);
const result = {};
params.forEach(param => {
const eqIndex = param.indexOf('=');
if (eqIndex !== -1) {
const key = param.substring(0, eqIndex).trim();
const valueBase = param.substring(eqIndex + 1);
const value = dontStripWhitespace
? valueBase.replace(/^[\r\n]+|[\r\n]+$/g, '')
: valueBase.trim();
if (key) result[key] = value;
}
});
return result;
}
function updateTemplateParamInPage(pageText, templateName, targetKey, newValue) {
const [fullMatch, prefix, content, suffix] = findTemplateInPageRaw(pageText, templateName);
const params = parseTemplateParamsRaw(content);
let keyFound = false;
const updatedParams = params.map(param => {
const eqIndex = param.indexOf('=');
if (eqIndex !== -1) {
const key = param.substring(0, eqIndex).trim();
if (key === targetKey) {
keyFound = true;
const beforeEq = param.substring(0, eqIndex);
const afterEq = param.substring(eqIndex + 1);
const spaceMatch = afterEq.match(/^(\s*)/);
const leadingSpaces = spaceMatch ? spaceMatch[1] : '';
const trailingSpaceMatch = afterEq.match(/(\s*)$/);
const trailingSpaces = trailingSpaceMatch ? trailingSpaceMatch[1] : '';
return `${beforeEq}=${leadingSpaces}${newValue}${trailingSpaces}`;
}
}
return param;
});
if (!keyFound) updatedParams.push(`${targetKey} = ${newValue}\n`);
const updatedTemplate = prefix + updatedParams.join('|') + suffix;
return pageText.replace(fullMatch, updatedTemplate);
}
mw.loader.using(['mediawiki.util', 'mediawiki.api'], async function () {
var OCATagIfAvailable;
var config = mw.config.get([
'debug', 'wgAction', 'wgArticleId', 'wgCategories', 'wgMonthNames',
'wgNamespaceNumber', 'wgPageName', 'wgRelevantUserName'
]);
var OCAStatus, OCALoading;
function determinePageArchivability() {
const categories = config.wgCategories || [];
OCAStatus = ui_str.oca_status_enabled;
for (const categoryName of no_manual_archiving_categories) {
if (categories.includes(categoryName)) {
OCAStatus = ui_str.oca_status_disabled_category.replaceAll('$1', categoryName);
return false;
}
}
for (const categoryName of automatically_signed_nontalk_categories) {
if (categories.includes(categoryName)) return true;
}
const ns = config.wgNamespaceNumber;
if (ns === 1 || ns === 3 || ns === 5 || ns === 7 || ns === 9 || ns === 11 || ns === 13 || ns === 15) {
return true;
}
if (document.querySelector('#ca-addsection')) return true;
OCAStatus = ui_str.oca_status_disabled_nottalk;
return false;
}
async function doesTagExist(tagName) {
try {
const response = await new mw.Api().get({
action: 'query',
list: 'tags',
tgprop: 'active|source',
tgcontinue: tagName,
tglimit: 1
});
const tags = response.query && response.query.tags;
if (tags && tags.length > 0 && tags[0].name === tagName) {
const targetTag = tags[0];
return targetTag.active !== undefined && targetTag.source.includes('manual');
}
return false;
} catch (error) {
return false;
}
}
async function archiveThis(sectionNumber, archiveName, currentArchiveName, sectionName, archiveConfig) {
document.body.insertAdjacentHTML(
'beforeend',
'<div class="overlay" style="background-color: #000000; opacity: 0.4; position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; z-index: 500;"></div>'
);
document.body.insertAdjacentHTML(
'afterbegin',
'<div class="arcProg" style="font-weight: bold; box-shadow: 7px 7px 5px #000000; font-size: 0.9em; line-height: 1.5em; z-index: 501; opacity: 1; position: fixed; width: 50%; left: 25%; top: 30%; background: #F7F7F7; border: #222222 ridge 1px; padding: 20px;"></div>'
);
const arcProg = document.querySelector('.arcProg');
function printMessage(message, color) {
if (!color) color = "black";
arcProg.insertAdjacentHTML('beforeend', `<div style="color: ${color}">${message}</div>`);
}
const pageid = config.wgArticleId;
const api = new mw.Api();
printMessage(ui_str.archivethis_retrieving_content);
const sectionResponse = await api.get({
action: 'query',
pageids: pageid,
rvsection: sectionNumber,
prop: ['revisions', 'info'],
rvprop: 'content',
indexpageids: 1,
rawcontinue: ''
});
var sectionContent = sectionResponse.query.pages[pageid].revisions[0]['*'];
printMessage(ui_str.archivethis_content_retrieved, "green");
var dnau = sectionContent.match(/<!-- \[\[User:DoNotArchiveUntil\]\] ([\d]{2}):([\d]{2}),? ([\d]{1,2}) (January|February|March|April|May|June|July|August|September|October|November|December) ([\d]{4}) \(UTC\) -->/);
var dnauDate;
if (dnau === null || dnau === undefined) {
dnauDate = Date.now();
dnau = null;
} else {
dnau = dnau[1] + ':' + dnau[2] + ' ' + dnau[3] + ' ' + dnau[4] + ' ' + dnau[5];
dnauDate = new Date(dnau);
dnauDate = dnauDate.valueOf();
}
if (dnauDate > Date.now()) {
document.querySelectorAll('.arcProg, .overlay').forEach(element => element.remove());
const dnauAbortMsg = document.createElement('p');
dnauAbortMsg.innerHTML = `${ui_str.archivethis_dnau_message.replaceAll('$1', dnau)}<br /><br /><span style="font-size: larger;">${ui_str.archivethis_dnau_see_docs}</span>`;
mw.notify(dnauAbortMsg, { title: ui_str.archivethis_dnau_aborted, tag: 'OCAdnau', autoHide: false });
return;
}
if (archiveName == currentArchiveName) {
sectionContent = '\n\n\x7B\x7BClear\x7D\x7D\n' + sectionContent;
printMessage(ui_str.archivethis_writing_existing_page.replaceAll('$1', archiveName));
} else {
sectionContent = archiveConfig.archivePageHeader + '\n\n' + sectionContent;
printMessage(ui_str.archivethis_writing_new_page.replaceAll('$1', archiveName));
}
if (dnau != null) {
sectionContent = sectionContent.replace(/<!-- \[\[User:DoNotArchiveUntil\]\] ([\d]{2}):([\d]{2}), ([\d]{1,2}) (January|February|March|April|May|June|July|August|September|October|November|December) ([\d]{4}) \(UTC\) -->/g, '');
}
await api.postWithToken('edit', {
action: 'edit',
title: archiveName,
appendtext: sectionContent,
summary: `/* ${sectionName} */ ${ui_str.archivethis_es_archived_using}`,
tags: OCATagIfAvailable
});
printMessage(ui_str.archivethis_successfully_added, "green");
await new mw.Api().postWithToken('edit', {
action: 'edit',
section: sectionNumber,
pageid: pageid,
text: '',
summary: `[[${BP_DOC_PAGE}|OneClickArchived]] "${sectionName}" ${ui_str.archivethis_es_to} [[${archiveName}]]`,
tags: OCATagIfAvailable
});
printMessage(ui_str.archivethis_success, "green");
await archiveConfig.updateConfigIfNeeded(pageid);
location.reload();
}
function extractSectionNumber(heading) {
var parentWrapper = heading.parentElement;
var secId = heading.getAttribute('data-mw-section-id') ||
(parentWrapper && parentWrapper.getAttribute && parentWrapper.getAttribute('data-mw-section-id'));
if (secId && secId !== '-1' && secId !== '0') {
return parseInt(secId, 10);
}
var editsection = heading.querySelector('.mw-editsection') ||
(parentWrapper && parentWrapper.querySelector('.mw-editsection'));
if (editsection) {
var editLink = editsection.querySelector('a');
if (editLink) {
var href = editLink.getAttribute('href');
var match = href && href.match(/[?&]section=([0-9]+)/);
if (match) return parseInt(match[1], 10);
}
}
var anyLink = heading.querySelector('a[href*="section="]') ||
(parentWrapper && parentWrapper.querySelector('a[href*="section="]'));
if (anyLink) {
var href2 = anyLink.getAttribute('href');
var match2 = href2 && href2.match(/[?&]section=([0-9]+)/);
if (match2) return parseInt(match2[1], 10);
}
return null;
}
function addArchiveLinks(headerLevel, archiveName, currentArchiveName, archiveConfig) {
var headings = document.querySelectorAll('h' + headerLevel);
console.log('OCA: found', headings.length, 'h' + headerLevel + ' headings');
Array.prototype.forEach.call(headings, function (heading) {
if (heading.id === 'mw-toc-heading') return;
if (heading.querySelector('.archiverLink')) return;
var parentWrapper = heading.parentElement;
if (parentWrapper && parentWrapper.querySelector && parentWrapper.querySelector('.archiverLink')) return;
var sectionNumber = extractSectionNumber(heading);
if (sectionNumber === null) {
console.log('OCA: no section number for:', heading.textContent.slice(0, 40));
return;
}
var sectionName = heading.textContent.replace(/\[?\s*edit(\s+source)?\s*\]?\s*$/i, '').trim();
var archiveButtonWrapper = document.createElement('span');
archiveButtonWrapper.className = 'archiverLinkWrapper';
archiveButtonWrapper.style.fontSize = linkSize + 'em';
archiveButtonWrapper.style.fontWeight = 'bold';
archiveButtonWrapper.style.marginLeft = '0.5em';
var archiveButton = document.createElement('a');
archiveButton.href = '#archiverLink';
archiveButton.className = 'archiverLink';
archiveButton.setAttribute('data-section', String(sectionNumber));
archiveButton.title = ui_str.archive_to_text + ' ' + archiveName;
archiveButton.innerText = ui_str.archive_link_text;
archiveButton.addEventListener('click', function (e) {
e.preventDefault();
archiveThis(sectionNumber, archiveName, currentArchiveName, sectionName, archiveConfig);
});
archiveButtonWrapper.appendChild(document.createTextNode('[ '));
archiveButtonWrapper.appendChild(archiveButton);
archiveButtonWrapper.appendChild(document.createTextNode(' ]'));
heading.appendChild(archiveButtonWrapper);
console.log('OCA: added link to section', sectionNumber, ':', sectionName.slice(0, 40));
});
}
// ---- config classes --------------------------------------------------
class archiveBotConfig {
constructor(counter, archivePageHeader, headerLevel) {
this.counter = parseInt(counter);
if (!counter) this.counter = 1;
this.archivePageHeader = archivePageHeader;
this.headerLevel = headerLevel;
}
getCurrentArchiveName() { throw new Error("You must implement this method!"); }
getArchiveNameToWrite() { throw new Error("You must implement this method!"); }
async updateConfigIfNeeded() { throw new Error("You must implement this method!"); }
}
class archiveBotConfigMisza extends archiveBotConfig {
_parseToBytes(sizeString) {
if (typeof sizeString !== 'string') return null;
const cleanStr = sizeString.trim().toUpperCase();
const match = cleanStr.match(/^(\d+(?:\.\d+)?)\s*([KMGTPB]?)$/);
if (!match) return null;
const value = parseFloat(match[1]);
const unit = match[2];
const multipliers = { '': 1, 'B': 1, 'K': 1e3, 'M': 1e6, 'G': 1e9 };
return value * (multipliers[unit] || 1);
}
_applyPythonTemplateSubstitutions(inputTemplate, counter) {
const now = new Date();
const thisMonthNum = now.getMonth() + 1;
const thisYear = now.getFullYear();
const thisMonthFullName = config.wgMonthNames[thisMonthNum];
const monthNamesShort = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const thisMonthShortName = monthNamesShort[thisMonthNum];
const thisQuarter = Math.ceil(thisMonthNum / 4);
const values = {
year: thisYear,
month: thisMonthNum,
monthname: thisMonthFullName,
monthnameshort: thisMonthShortName,
quarter: thisQuarter,
counter: counter
};
const placeholderRegex = /%\((\w+)\)(0?\d+)?([ds])/g;
return inputTemplate.replace(placeholderRegex, (match, key, pad) => {
if (!(key in values)) return match;
let val = String(values[key]);
if (pad) {
const padChar = pad.startsWith('0') ? '0' : ' ';
const padLength = parseInt(pad, 10);
val = val.padStart(padLength, padChar);
}
return val;
});
}
constructor(counter, archiveheader, archive, maxarchivesize, templateName, pageText) {
const headerLevel = 2;
if (!archiveheader) archiveheader = "\x7B\x7BArchive\x7D\x7D";
super(counter, archiveheader, headerLevel);
this.pageText = pageText;
this.templateName = templateName;
this.parsedCounter = counter;
this.archivePythonTemplate = archive;
this.archiveSizeLimitType = null;
if (maxarchivesize && maxarchivesize.endsWith("T")) {
this.archiveSizeThreadLimit = parseInt(maxarchivesize.slice(0, -1));
if (this.archiveSizeThreadLimit) this.archiveSizeLimitType = "threads";
} else {
this.archiveSizeByteLimit = this._parseToBytes(maxarchivesize);
if (this.archiveSizeByteLimit) this.archiveSizeLimitType = "bytes";
}
}
getCurrentArchiveName() {
return this._applyPythonTemplateSubstitutions(this.archivePythonTemplate, this.counter);
}
getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra) {
this.toWriteCounter = this.counter + counterExtra;
if (this.archiveSizeLimitType === "threads") {
if (currentArchiveSections >= this.archiveSizeThreadLimit) this.toWriteCounter++;
} else if (this.archiveSizeLimitType === "bytes") {
if (currentArchiveBytes >= this.archiveSizeByteLimit) this.toWriteCounter++;
}
return this._applyPythonTemplateSubstitutions(this.archivePythonTemplate, this.toWriteCounter);
}
async updateConfigIfNeeded(pageid) {
if (!this.templateName) return;
const newContent = updateTemplateParamInPage(this.pageText, this.templateName, "counter", this.toWriteCounter);
if (newContent != this.pageText) {
await new mw.Api().postWithToken('edit', {
action: 'edit',
section: 0,
pageid: pageid,
text: newContent,
summary: `[[${BP_DOC_PAGE}|OneClickArchiver]] updating counter.`,
tags: OCATagIfAvailable
});
}
}
}
function parseNativeArchiveConfig(pageText) {
const templateName = "Archive config";
const content = findTemplateInPage(pageText, templateName);
if (!content) return;
const cfg = parseTemplateParams(content);
if (!cfg.archive) return;
return new archiveBotConfigMisza(
cfg.counter,
cfg.header,
cfg.archive,
cfg.maxsize,
templateName,
pageText
);
}
function createDefaultConfig(pageName, pageText) {
const archivePattern = pageName + '/Archive %(counter)d';
return new archiveBotConfigMisza(
1,
'\x7B\x7BArchive\x7D\x7D',
archivePattern,
'100K',
null,
pageText
);
}
function parseMiszaBotConfig(pageText) {
const templateName = "User:MiszaBot/config";
const content = findTemplateInPage(pageText, templateName);
if (!content) return;
const config = parseTemplateParams(content);
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText);
}
function parseArchiveBasics(pageText) {
const templateName = "Archive basics";
const content = findTemplateInPage(pageText, templateName);
if (!content) return;
const config = parseTemplateParams(content);
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText);
}
function parseHazardBotConfig(pageText) {
const templateName = "User:Hazard-Bot/Archiver";
const content = findTemplateInPage(pageText, templateName);
if (!content) return;
const config = parseTemplateParams(content);
return new archiveBotConfigMisza(config.counter, config.archiveheader, config.archive, config.maxarchivesize, templateName, pageText);
}
class archiveBotConfigClueBotIII extends archiveBotConfig {
_phpDate(format, timestamp) {
const date = timestamp !== undefined
? (timestamp instanceof Date ? timestamp : new Date(timestamp * 1000))
: new Date();
const pad = (val, len = 2) => String(val).padStart(len, '0');
const getDayOfYear = (d) => {
const start = new Date(d.getFullYear(), 0, 0);
const diff = d - start;
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay) - 1;
};
const getISOWeek = (d) => {
const target = new Date(d.valueOf());
const dayNr = (d.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const firstThursday = target.valueOf();
target.setMonth(0, 1);
if (target.getDay() !== 4) target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7));
return 1 + Math.ceil((firstThursday - target) / 604800000);
};
const getSwatchTime = (d) => {
const time = d.getTime();
const utc1 = time + (d.getTimezoneOffset() * 60000) + 3600000;
const bmt = new Date(utc1);
const beats = Math.floor((bmt.getHours() * 3600 + bmt.getMinutes() * 60 + bmt.getSeconds() + bmt.getMilliseconds() / 1000) / 86.4);
return pad(beats, 3);
};
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const formatters = {
d: d => pad(d.getDate()),
D: d => days[d.getDay()].substring(0, 3),
j: d => d.getDate(),
l: d => days[d.getDay()],
N: d => d.getDay() === 0 ? 7 : d.getDay(),
S: d => { const j = d.getDate(); if (j % 10 === 1 && j !== 11) return 'st'; if (j % 10 === 2 && j !== 12) return 'nd'; if (j % 10 === 3 && j !== 13) return 'rd'; return 'th'; },
w: d => d.getDay(),
z: d => getDayOfYear(d),
W: d => pad(getISOWeek(d)),
F: d => months[d.getMonth()],
m: d => pad(d.getMonth() + 1),
M: d => months[d.getMonth()].substring(0, 3),
n: d => d.getMonth() + 1,
t: d => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(),
L: d => (new Date(d.getFullYear(), 1, 29).getMonth() === 1) ? 1 : 0,
o: d => { const target = new Date(d.valueOf()); target.setDate(target.getDate() - ((d.getDay() + 6) % 7) + 3); return target.getFullYear(); },
Y: d => d.getFullYear(),
y: d => String(d.getFullYear()).slice(-2),
a: d => d.getHours() >= 12 ? 'pm' : 'am',
A: d => d.getHours() >= 12 ? 'PM' : 'AM',
B: d => getSwatchTime(d),
g: d => d.getHours() % 12 || 12,
G: d => d.getHours(),
h: d => pad(d.getHours() % 12 || 12),
H: d => pad(d.getHours()),
i: d => pad(d.getMinutes()),
s: d => pad(d.getSeconds()),
u: d => pad(d.getMilliseconds() * 1000, 6),
v: d => pad(d.getMilliseconds(), 3),
e: d => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } },
I: d => { const jan = new Date(d.getFullYear(), 0, 1).getTimezoneOffset(); const jul = new Date(d.getFullYear(), 6, 1).getTimezoneOffset(); return d.getTimezoneOffset() < Math.max(jan, jul) ? 1 : 0; },
O: d => { const offset = d.getTimezoneOffset(); const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + pad(absOffset % 60); },
P: d => { const offset = d.getTimezoneOffset(); const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + ':' + pad(absOffset % 60); },
p: d => { const offset = d.getTimezoneOffset(); if (offset === 0) return 'Z'; const sign = offset > 0 ? '-' : '+'; const absOffset = Math.abs(offset); return sign + pad(Math.floor(absOffset / 60)) + ':' + pad(absOffset % 60); },
T: d => { const matches = d.toTimeString().match(/\(([^)]+)\)$/); return matches ? matches[1] : 'UTC'; },
Z: d => -d.getTimezoneOffset() * 60,
c: d => d.toISOString().replace(/\.\d+Z$/, '') + (d.getTimezoneOffset() === 0 ? 'Z' : formatters.P(d)),
r: d => d.toUTCString(),
U: d => Math.floor(d.getTime() / 1000)
};
let result = '';
for (let i = 0; i < format.length; i++) {
const char = format[i];
if (char === '\\') {
if (i + 1 < format.length) result += format[++i];
continue;
}
if (formatters[char]) result += formatters[char](date);
else result += char;
}
return result;
}
_renderPHPDateString(dateString, counter) {
const dateStringCountersApplied = dateString.replace('%%i', counter);
return this._phpDate(dateStringCountersApplied);
}
constructor(archiveprefix, format, header, numberstart, headerlevel, maxarchsize, templateName, pageText) {
if (!headerlevel) headerlevel = 2;
if (!header) header = "\x7B\x7BArchive\x7D\x7D";
super(numberstart, header, headerlevel);
this.archiveprefix = archiveprefix;
this.format = format;
this.maxarchsize = maxarchsize || Infinity;
this.templateName = templateName;
this.pageText = pageText;
}
getCurrentArchiveName() {
const archiveSuffix = this._renderPHPDateString(this.format, this.counter);
return this.archiveprefix + archiveSuffix;
}
getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra) {
var toWriteCounter = this.counter + counterExtra;
if (this.format.includes('%%i')) {
if (currentArchiveBytes >= this.maxarchsize) toWriteCounter++;
}
const archiveSuffix = this._renderPHPDateString(this.format, toWriteCounter);
return this.archiveprefix + archiveSuffix;
}
async updateConfigIfNeeded() { return; }
}
function parseClueBotIIIConfig(pageText) {
const templateName = "User:ClueBot III/ArchiveThis";
const content = findTemplateInPage(pageText, templateName);
if (!content) return;
const config = parseTemplateParams(content, true);
return new archiveBotConfigClueBotIII(config.archiveprefix, config.format, config.header, config.numberstart, config.headerlevel, config.maxarchsize, templateName, pageText);
}
const archiveConfigsToTry = [
parseNativeArchiveConfig,
parseMiszaBotConfig,
parseClueBotIIIConfig,
parseArchiveBasics,
parseHazardBotConfig
];
function OCAInfo() {
const OCAInfoMsg = document.createElement('p');
OCAInfoMsg.innerHTML = `<b>${OCALoading}</b>${OCAStatus}`;
mw.notify(OCAInfoMsg, { title: ui_str.oca_status_heading, tag: 'OCAstatus', autoHide: false });
}
async function getPageSizeInfo(pageName, headerLevel) {
const api = new mw.Api();
const [pageData, pageParse] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
rvlimit: 1,
rvprop: ['size', 'content'],
titles: pageName,
list: 'usercontribs',
uclimit: 1,
ucprop: 'timestamp',
ucuser: config.wgRelevantUserName || 'Example',
rawcontinue: '',
}),
api.get({
action: 'parse',
page: pageName,
prop: 'tocdata',
}).catch(error => {
if (error === 'missingtitle') return { parse: { tocdata: { sections: [] } } };
throw error;
})
]);
const page = Object.values((pageData && pageData.query && pageData.query.pages) || {})[0];
const pageBytes = parseInt(page && page.revisions && page.revisions[0] && page.revisions[0].size, 10) || -1;
const sections = (pageParse && pageParse.parse && pageParse.parse.tocdata && pageParse.parse.tocdata.sections) || [];
const TOClevel = headerLevel - 1;
const pageSections = sections.filter(s => parseInt(s.tocLevel, 10) === TOClevel).length;
return [pageBytes, pageSections];
}
async function loadOCA() {
var OCATagExistsPromise = doesTagExist(BP_SCRIPT_TAG);
OCALoading = `${ui_str.oca_loading_init}<br />`;
if (mw.config.get('wgNamespaceNumber') > 0) {
const OCAInfoButton = mw.util.addPortletLink(
'p-cactions',
'#archiverLink',
ui_str.oca_info_button_name,
'pt-OCA',
ui_str.oca_info_button_description,
null
);
OCAInfoButton.addEventListener('click', function (e) {
e.preventDefault();
OCAInfo();
});
}
if (!determinePageArchivability()) {
OCALoading = "";
return;
}
var pageid = config.wgArticleId;
const pageSection0 = await new mw.Api().get({
action: 'query',
prop: ['revisions', 'info'],
rvsection: 0,
rvprop: 'content',
pageids: pageid,
indexpageids: 1,
rawcontinue: ''
});
const content0 = pageSection0.query.pages[pageid].revisions[0]['*'];
OCALoading = `${ui_str.oca_loading_looking_for_config}<br />\n`;
var archiveConfig;
for (const configLoader of archiveConfigsToTry) {
archiveConfig = configLoader(content0);
if (archiveConfig) {
const templateQualifiedName = archiveConfig.templateName.includes(":") ? archiveConfig.templateName : "Template:" + archiveConfig.templateName;
OCAStatus = OCAStatus + `\n${ui_str.oca_status_using_template} \x7B\x7B<a href="/wiki/${templateQualifiedName}">${archiveConfig.templateName}</a>\x7D\x7D.`;
break;
}
}
if (!archiveConfig) {
archiveConfig = createDefaultConfig(config.wgPageName, content0);
OCAStatus = OCAStatus + ` <i>(no config template found; using default: ${config.wgPageName}/Archive N)</i>`;
}
const initialArchiveName = archiveConfig.getCurrentArchiveName();
OCALoading = `${ui_str.oca_loading_looking_for_archive}<br />\n`;
var archiveNameLast;
var archiveNameCurrent = initialArchiveName;
var counterExtra = 0;
var maxArchiveIter = 30;
while (archiveNameLast !== archiveNameCurrent && counterExtra < maxArchiveIter) {
archiveNameLast = archiveNameCurrent;
const sizeInfo = await getPageSizeInfo(archiveNameLast, archiveConfig.headerLevel);
const currentArchiveBytes = sizeInfo[0];
const currentArchiveSections = sizeInfo[1];
console.log('OCA: checking', archiveNameLast, 'bytes=', currentArchiveBytes);
if (currentArchiveBytes === -1) {
archiveNameCurrent = archiveNameLast;
break;
}
archiveNameCurrent = archiveConfig.getArchiveNameToWrite(currentArchiveBytes, currentArchiveSections, counterExtra);
counterExtra++;
}
const archivePageToWrite = archiveNameCurrent;
console.log('OCA: archive page to write =', archivePageToWrite);
if (await OCATagExistsPromise) {
OCATagIfAvailable = BP_SCRIPT_TAG;
}
OCALoading = `${ui_str.oca_loading_adding_archive_links}<br />\n`;
addArchiveLinks(archiveConfig.headerLevel, archivePageToWrite, initialArchiveName, archiveConfig);
OCALoading = "";
}
// ---- AUTO-START LOGIC -------------------------------------------------
// Wait for window.load (all resources) + small delay so skin/DOM is fully ready
function startOCA() {
if (window.OCA_already_started) return;
window.OCA_already_started = true;
console.log('OCA: starting loadOCA (auto)');
loadOCA();
}
if (document.readyState === 'complete') {
setTimeout(startOCA, 800);
} else {
window.addEventListener('load', function () {
setTimeout(startOCA, 800);
});
setTimeout(startOCA, 4000);
}
});